Merge branch 'develop' into pr/SmartManoj/6859

This commit is contained in:
Matthias 2022-06-23 20:43:35 +02:00
commit 8bf0bf10c5
116 changed files with 11323 additions and 9511 deletions

View File

@ -13,6 +13,10 @@ on:
schedule: schedule:
- cron: '0 5 * * 4' - cron: '0 5 * * 4'
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
build_linux: build_linux:
@ -26,7 +30,7 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v3 uses: actions/setup-python@v4
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
@ -62,12 +66,12 @@ jobs:
- name: Tests - name: Tests
run: | run: |
pytest --random-order --cov=freqtrade --cov-config=.coveragerc pytest --random-order --cov=freqtrade --cov-config=.coveragerc
if: matrix.python-version != '3.9' if: matrix.python-version != '3.9' || matrix.os != 'ubuntu-22.04'
- name: Tests incl. ccxt compatibility tests - name: Tests incl. ccxt compatibility tests
run: | run: |
pytest --random-order --cov=freqtrade --cov-config=.coveragerc --longrun pytest --random-order --cov=freqtrade --cov-config=.coveragerc --longrun
if: matrix.python-version == '3.9' if: matrix.python-version == '3.9' && matrix.os == 'ubuntu-22.04'
- name: Coveralls - name: Coveralls
if: (runner.os == 'Linux' && matrix.python-version == '3.9') if: (runner.os == 'Linux' && matrix.python-version == '3.9')
@ -78,11 +82,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: |
@ -121,7 +127,7 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v3 uses: actions/setup-python@v4
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
@ -164,7 +170,8 @@ jobs:
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: |
@ -204,7 +211,7 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v3 uses: actions/setup-python@v4
with: with:
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
@ -256,7 +263,7 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v3 uses: actions/setup-python@v4
with: with:
python-version: "3.10" python-version: "3.10"
@ -275,7 +282,7 @@ jobs:
./tests/test_docs.sh ./tests/test_docs.sh
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v3 uses: actions/setup-python@v4
with: with:
python-version: "3.10" python-version: "3.10"
@ -293,18 +300,6 @@ jobs:
details: Freqtrade doc test failed! details: Freqtrade doc test failed!
webhookUrl: ${{ secrets.DISCORD_WEBHOOK }} webhookUrl: ${{ secrets.DISCORD_WEBHOOK }}
cleanup-prior-runs:
permissions:
actions: write # for rokroskar/workflow-run-cleanup-action to obtain workflow name & cancel it
contents: read # for rokroskar/workflow-run-cleanup-action to obtain branch
runs-on: ubuntu-20.04
steps:
- name: Cleanup previous runs on this branch
uses: rokroskar/workflow-run-cleanup-action@v0.3.3
if: "!startsWith(github.ref, 'refs/tags/') && github.ref != 'refs/heads/stable' && github.repository == 'freqtrade/freqtrade'"
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
# Notify only once - when CI completes (and after deploy) in case it's successfull # Notify only once - when CI completes (and after deploy) in case it's successfull
notify-complete: notify-complete:
needs: [ build_linux, build_macos, build_windows, docs_check, mypy_version_check ] needs: [ build_linux, build_macos, build_windows, docs_check, mypy_version_check ]
@ -341,7 +336,7 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Set up Python - name: Set up Python
uses: actions/setup-python@v3 uses: actions/setup-python@v4
with: with:
python-version: "3.9" python-version: "3.9"

View File

@ -13,11 +13,11 @@ repos:
- id: mypy - id: mypy
exclude: build_helpers exclude: build_helpers
additional_dependencies: additional_dependencies:
- types-cachetools==5.0.1 - types-cachetools==5.0.2
- types-filelock==3.2.5 - types-filelock==3.2.7
- types-requests==2.27.25 - types-requests==2.27.30
- types-tabulate==0.8.9 - types-tabulate==0.8.9
- types-python-dateutil==2.8.15 - types-python-dateutil==2.8.17
# 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.10.4-slim-bullseye as base FROM python:3.10.5-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

@ -7,4 +7,5 @@ FROM freqtradeorg/freqtrade:develop
# The below dependency - pyti - serves as an example. Please use whatever you need! # The below dependency - pyti - serves as an example. Please use whatever you need!
RUN pip install --user pyti RUN pip install --user pyti
# Switch back to user (only if you required root above)
# USER ftuser # USER ftuser

View File

@ -22,50 +22,79 @@ DataFrame of the candles that resulted in buy signals. Depending on how many buy
makes, this file may get quite large, so periodically check your `user_data/backtest_results` makes, this file may get quite large, so periodically check your `user_data/backtest_results`
folder to delete old exports. folder to delete old exports.
To analyze the buy tags, we need to use the `buy_reasons.py` script from
[froggleston's repo](https://github.com/froggleston/freqtrade-buyreasons). Follow the instructions
in their README to copy the script into your `freqtrade/scripts/` folder.
Before running your next backtest, make sure you either delete your old backtest results or run Before running your next backtest, make sure you either delete your old backtest results or run
backtesting with the `--cache none` option to make sure no cached results are used. backtesting with the `--cache none` option to make sure no cached results are used.
If all goes well, you should now see a `backtest-result-{timestamp}_signals.pkl` file in the If all goes well, you should now see a `backtest-result-{timestamp}_signals.pkl` file in the
`user_data/backtest_results` folder. `user_data/backtest_results` folder.
Now run the `buy_reasons.py` script, supplying a few options: To analyze the entry/exit tags, we now need to use the `freqtrade backtesting-analysis` command
with `--analysis-groups` option provided with space-separated arguments (default `0 1 2`):
``` bash ``` bash
python3 scripts/buy_reasons.py -c <config.json> -s <strategy_name> -t <timerange> -g0,1,2,3,4 freqtrade backtesting-analysis -c <config.json> --analysis-groups 0 1 2 3 4
``` ```
The `-g` option is used to specify the various tabular outputs, ranging from the simplest (0) This command will read from the last backtesting results. The `--analysis-groups` option is
to the most detailed per pair, per buy and per sell tag (4). More options are available by used to specify the various tabular outputs showing the profit fo each group or trade,
running with the `-h` option. ranging from the simplest (0) to the most detailed per pair, per buy and per sell tag (4):
* 1: profit summaries grouped by enter_tag
* 2: profit summaries grouped by enter_tag and exit_tag
* 3: profit summaries grouped by pair and enter_tag
* 4: profit summaries grouped by pair, enter_ and exit_tag (this can get quite large)
More options are available by running with the `-h` option.
### Using export-filename
Normally, `backtesting-analysis` uses the latest backtest results, but if you wanted to go
back to a previous backtest output, you need to supply the `--export-filename` option.
You can supply the same parameter to `backtest-analysis` with the name of the final backtest
output file. This allows you to keep historical versions of backtest results and re-analyse
them at a later date:
``` bash
freqtrade backtesting -c <config.json> --timeframe <tf> --strategy <strategy_name> --timerange=<timerange> --export=signals --export-filename=/tmp/mystrat_backtest.json
```
You should see some output similar to below in the logs with the name of the timestamped
filename that was exported:
```
2022-06-14 16:28:32,698 - freqtrade.misc - INFO - dumping json to "/tmp/mystrat_backtest-2022-06-14_16-28-32.json"
```
You can then use that filename in `backtesting-analysis`:
```
freqtrade backtesting-analysis -c <config.json> --export-filename=/tmp/mystrat_backtest-2022-06-14_16-28-32.json
```
### Tuning the buy tags and sell tags to display ### Tuning the buy tags and sell tags to display
To show only certain buy and sell tags in the displayed output, use the following two options: To show only certain buy and sell tags in the displayed output, use the following two options:
``` ```
--enter_reason_list : Comma separated list of enter signals to analyse. Default: "all" --enter-reason-list : Space-separated list of enter signals to analyse. Default: "all"
--exit_reason_list : Comma separated list of exit signals to analyse. Default: "stop_loss,trailing_stop_loss" --exit-reason-list : Space-separated list of exit signals to analyse. Default: "all"
``` ```
For example: For example:
```bash ```bash
python3 scripts/buy_reasons.py -c <config.json> -s <strategy_name> -t <timerange> -g0,1,2,3,4 --enter_reason_list "enter_tag_a,enter_tag_b" --exit_reason_list "roi,custom_exit_tag_a,stop_loss" freqtrade backtesting-analysis -c <config.json> --analysis-groups 0 2 --enter-reason-list enter_tag_a enter_tag_b --exit-reason-list roi custom_exit_tag_a stop_loss
``` ```
### Outputting signal candle indicators ### Outputting signal candle indicators
The real power of the buy_reasons.py script comes from the ability to print out the indicator The real power of `freqtrade backtesting-analysis` comes from the ability to print out the indicator
values present on signal candles to allow fine-grained investigation and tuning of buy signal values present on signal candles to allow fine-grained investigation and tuning of buy signal
indicators. To print out a column for a given set of indicators, use the `--indicator-list` indicators. To print out a column for a given set of indicators, use the `--indicator-list`
option: option:
```bash ```bash
python3 scripts/buy_reasons.py -c <config.json> -s <strategy_name> -t <timerange> -g0,1,2,3,4 --enter_reason_list "enter_tag_a,enter_tag_b" --exit_reason_list "roi,custom_exit_tag_a,stop_loss" --indicator_list "rsi,rsi_1h,bb_lowerband,ema_9,macd,macdsignal" freqtrade backtesting-analysis -c <config.json> --analysis-groups 0 2 --enter-reason-list enter_tag_a enter_tag_b --exit-reason-list roi custom_exit_tag_a stop_loss --indicator-list rsi rsi_1h bb_lowerband ema_9 macd macdsignal
``` ```
The indicators have to be present in your strategy's main DataFrame (either for your main The indicators have to be present in your strategy's main DataFrame (either for your main

View File

@ -98,6 +98,23 @@ class MyAwesomeStrategy(IStrategy):
!!! Note !!! Note
All overrides are optional and can be mixed/matched as necessary. All overrides are optional and can be mixed/matched as necessary.
### Dynamic parameters
Parameters can also be defined dynamically, but must be available to the instance once the * [`bot_start()` callback](strategy-callbacks.md#bot-start) has been called.
``` python
class MyAwesomeStrategy(IStrategy):
def bot_start(self, **kwargs) -> None:
self.buy_adx = IntParameter(20, 30, default=30, optimize=True)
# ...
```
!!! Warning
Parameters created this way will not show up in the `list-strategies` parameter count.
### Overriding Base estimator ### Overriding Base estimator
You can define your own estimator for Hyperopt by implementing `generate_estimator()` in the Hyperopt subclass. You can define your own estimator for Hyperopt by implementing `generate_estimator()` in the Hyperopt subclass.

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

View File

@ -300,6 +300,7 @@ A backtesting result will look like that:
| Absolute profit | 0.00762792 BTC | | Absolute profit | 0.00762792 BTC |
| Total profit % | 76.2% | | Total profit % | 76.2% |
| CAGR % | 460.87% | | CAGR % | 460.87% |
| Profit factor | 1.11 |
| Avg. stake amount | 0.001 BTC | | Avg. stake amount | 0.001 BTC |
| Total trade volume | 0.429 BTC | | Total trade volume | 0.429 BTC |
| | | | | |
@ -399,6 +400,7 @@ It contains some useful key metrics about performance of your strategy on backte
| Absolute profit | 0.00762792 BTC | | Absolute profit | 0.00762792 BTC |
| Total profit % | 76.2% | | Total profit % | 76.2% |
| CAGR % | 460.87% | | CAGR % | 460.87% |
| Profit factor | 1.11 |
| Avg. stake amount | 0.001 BTC | | Avg. stake amount | 0.001 BTC |
| Total trade volume | 0.429 BTC | | Total trade volume | 0.429 BTC |
| | | | | |
@ -444,6 +446,8 @@ It contains some useful key metrics about performance of your strategy on backte
- `Final balance`: Final balance - starting balance + absolute profit. - `Final balance`: Final balance - starting balance + absolute profit.
- `Absolute profit`: Profit made in stake currency. - `Absolute profit`: Profit made in stake currency.
- `Total profit %`: Total profit. Aligned to the `TOTAL` row's `Tot Profit %` from the first table. Calculated as `(End capital Starting capital) / Starting capital`. - `Total profit %`: Total profit. Aligned to the `TOTAL` row's `Tot Profit %` from the first table. Calculated as `(End capital Starting capital) / Starting capital`.
- `CAGR %`: Compound annual growth rate.
- `Profit factor`: profit / loss.
- `Avg. stake amount`: Average stake amount, either `stake_amount` or the average when using dynamic stake amount. - `Avg. stake amount`: Average stake amount, either `stake_amount` or the average when using dynamic stake amount.
- `Total trade volume`: Volume generated on the exchange to reach the above profit. - `Total trade volume`: Volume generated on the exchange to reach the above profit.
- `Best Pair` / `Worst Pair`: Best and worst performing pair, and it's corresponding `Cum Profit %`. - `Best Pair` / `Worst Pair`: Best and worst performing pair, and it's corresponding `Cum Profit %`.
@ -530,8 +534,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

@ -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

@ -314,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

@ -680,7 +680,7 @@ class MyAwesomeStrategy(IStrategy):
!!! Note !!! Note
Values in the configuration file will overwrite Parameter-file level parameters - and both will overwrite parameters within the strategy. Values in the configuration file will overwrite Parameter-file level parameters - and both will overwrite parameters within the strategy.
The prevalence is therefore: config > parameter file > strategy The prevalence is therefore: config > parameter file > strategy `*_params` > parameter default
### Understand Hyperopt ROI results ### Understand Hyperopt ROI results

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

@ -64,7 +64,10 @@ You will also have to pick a "margin mode" (explanation below) - with freqtrade
### Margin mode ### Margin mode
The possible values are: `isolated`, or `cross`(*currently unavailable*) On top of `trading_mode` - you will also have to configure your `margin_mode`.
While freqtrade currently only supports one margin mode, this will change, and by configuring it now you're all set for future updates.
The possible values are: `isolated`, or `cross`(*currently unavailable*).
#### Isolated margin mode #### Isolated margin mode
@ -82,6 +85,16 @@ One account is used to share collateral between markets (trading pairs). Margin
"margin_mode": "cross" "margin_mode": "cross"
``` ```
## Set leverage to use
Different strategies and risk profiles will require different levels of leverage.
While you could configure one static leverage value - freqtrade offers you the flexibility to adjust this via [strategy leverage callback](strategy-callbacks.md#leverage-callback) - which allows you to use different leverages by pair, or based on some other factor benefitting your strategy result.
If not implemented, leverage defaults to 1x (no leverage).
!!! Warning
Higher leverage also equals higher risk - be sure you fully understand the implications of using leverage!
## Understand `liquidation_buffer` ## Understand `liquidation_buffer`
*Defaults to `0.05`* *Defaults to `0.05`*
@ -101,6 +114,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.15 mkdocs-material==8.3.6
mdx_truly_sane_lists==1.2 mdx_truly_sane_lists==1.2
pymdown-extensions==9.4 pymdown-extensions==9.5
jinja2==3.1.2 jinja2==3.1.2

View File

@ -89,11 +89,12 @@ WHERE id=31;
If you'd still like to remove a trade from the database directly, you can use the below query. If you'd still like to remove a trade from the database directly, you can use the below query.
```sql !!! Danger
DELETE FROM trades WHERE id = <tradeid>; Some systems (Ubuntu) disable foreign keys in their sqlite3 packaging. When using sqlite - please ensure that foreign keys are on by running `PRAGMA foreign_keys = ON` before the above query.
```
```sql ```sql
DELETE FROM trades WHERE id = <tradeid>;
DELETE FROM trades WHERE id = 31; DELETE FROM trades WHERE id = 31;
``` ```
@ -102,13 +103,20 @@ DELETE FROM trades WHERE id = 31;
## Use a different database system ## Use a different database system
Freqtrade is using SQLAlchemy, which supports multiple different database systems. As such, a multitude of database systems should be supported.
Freqtrade does not depend or install any additional database driver. Please refer to the [SQLAlchemy docs](https://docs.sqlalchemy.org/en/14/core/engines.html#database-urls) on installation instructions for the respective database systems.
The following systems have been tested and are known to work with freqtrade:
* sqlite (default)
* PostgreSQL)
* MariaDB
!!! Warning !!! Warning
By using one of the below database systems, you acknowledge that you know how to manage such a system. Freqtrade will not provide any support with setup or maintenance (or backups) of the below database systems. By using one of the below database systems, you acknowledge that you know how to manage such a system. The freqtrade team will not provide any support with setup or maintenance (or backups) of the below database systems.
### PostgreSQL ### PostgreSQL
Freqtrade supports PostgreSQL by using SQLAlchemy, which supports multiple different database systems.
Installation: Installation:
`pip install psycopg2-binary` `pip install psycopg2-binary`

View File

@ -191,6 +191,19 @@ For example, simplified math:
!!! Tip !!! Tip
Make sure to have this value (`trailing_stop_positive_offset`) lower than minimal ROI, otherwise minimal ROI will apply first and sell the trade. Make sure to have this value (`trailing_stop_positive_offset`) lower than minimal ROI, otherwise minimal ROI will apply first and sell the trade.
## Stoploss and Leverage
Stoploss should be thought of as "risk on this trade" - so a stoploss of 10% on a 100$ trade means you are willing to lose 10$ (10%) on this trade - which would trigger if the price moves 10% to the downside.
When using leverage, the same principle is applied - with stoploss defining the risk on the trade (the amount you are willing to lose).
Therefore, a stoploss of 10% on a 10x trade would trigger on a 1% price move.
If your stake amount (own capital) was 100$ - this trade would be 1000$ at 10x (after leverage).
If price moves 1% - you've lost 10$ of your own capital - therfore stoploss will trigger in this case.
Make sure to be aware of this, and avoid using too tight stoploss (at 10x leverage, 10% risk may be too little to allow the trade to "breath" a little).
## Changing stoploss on open trades ## Changing stoploss on open trades
A stoploss on an open trade can be changed by changing the value in the configuration or strategy and use the `/reload_config` command (alternatively, completely stopping and restarting the bot also works). A stoploss on an open trade can be changed by changing the value in the configuration or strategy and use the `/reload_config` command (alternatively, completely stopping and restarting the bot also works).

View File

@ -46,6 +46,9 @@ class AwesomeStrategy(IStrategy):
self.cust_remote_data = requests.get('https://some_remote_source.example.com') self.cust_remote_data = requests.get('https://some_remote_source.example.com')
``` ```
During hyperopt, this runs only once at startup.
## Bot loop start ## Bot loop start
A simple callback which is called once at the start of every bot throttling iteration (roughly every 5 seconds, unless configured differently). A simple callback which is called once at the start of every bot throttling iteration (roughly every 5 seconds, unless configured differently).
@ -546,10 +549,12 @@ class AwesomeStrategy(IStrategy):
:param pair: Pair that's about to be bought/shorted. :param pair: Pair that's about to be bought/shorted.
:param order_type: Order type (as configured in order_types). usually limit or market. :param order_type: Order type (as configured in order_types). usually limit or market.
:param amount: Amount in target (quote) currency that's going to be traded. :param amount: Amount in target (base) currency that's going to be traded.
:param rate: Rate that's going to be used when using limit orders :param rate: Rate that's going to be used when using limit orders
or current rate for market orders.
:param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled).
:param current_time: datetime object, containing the current datetime :param current_time: datetime object, containing the current datetime
: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 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. :param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return bool: When True is returned, then the buy-order is placed on the exchange. :return bool: When True is returned, then the buy-order is placed on the exchange.
@ -563,6 +568,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
@ -575,7 +588,7 @@ class AwesomeStrategy(IStrategy):
rate: float, time_in_force: str, exit_reason: str, rate: float, time_in_force: str, exit_reason: str,
current_time: datetime, **kwargs) -> bool: current_time: datetime, **kwargs) -> bool:
""" """
Called right before placing a regular sell order. Called right before placing a regular exit order.
Timing for this function is critical, so avoid doing heavy computations or Timing for this function is critical, so avoid doing heavy computations or
network requests in this method. network requests in this method.
@ -583,17 +596,19 @@ class AwesomeStrategy(IStrategy):
When not implemented by a strategy, returns True (always confirming). When not implemented by a strategy, returns True (always confirming).
:param pair: Pair that's about to be sold. :param pair: Pair for trade that's about to be exited.
:param trade: trade object.
:param order_type: Order type (as configured in order_types). usually limit or market. :param order_type: Order type (as configured in order_types). usually limit or market.
:param amount: Amount in quote currency. :param amount: Amount in base currency.
:param rate: Rate that's going to be used when using limit orders :param rate: Rate that's going to be used when using limit orders
or current rate for market orders.
:param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled).
:param exit_reason: Exit reason. :param exit_reason: Exit reason.
Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss', Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss',
'exit_signal', 'force_exit', 'emergency_exit'] 'exit_signal', 'force_exit', 'emergency_exit']
:param current_time: datetime object, containing the current datetime :param current_time: datetime object, containing the current datetime
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy. :param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return bool: When True is returned, then the exit-order is placed on the exchange. :return bool: When True, then the exit-order is placed on the exchange.
False aborts the process False aborts the process
""" """
if exit_reason == 'force_exit' and trade.calc_profit_ratio(rate) < 0: if exit_reason == 'force_exit' and trade.calc_profit_ratio(rate) < 0:
@ -605,6 +620,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.
@ -656,7 +674,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
@ -664,7 +682,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.
@ -788,19 +806,23 @@ For markets / exchanges that don't support leverage, this method is ignored.
``` python ``` python
class AwesomeStrategy(IStrategy): class AwesomeStrategy(IStrategy):
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, entry_tag: Optional[str], side: str,
**kwargs) -> float: **kwargs) -> float:
""" """
Customize leverage for each new trade. Customize leverage for each new trade. This method is only called in futures mode.
:param pair: Pair that's currently analyzed :param pair: Pair that's currently analyzed
:param current_time: datetime object, containing the current datetime :param current_time: datetime object, containing the current datetime
:param current_rate: Rate, calculated based on pricing settings in exit_pricing. :param current_rate: Rate, calculated based on pricing settings in exit_pricing.
:param proposed_leverage: A leverage proposed by the bot. :param proposed_leverage: A leverage proposed by the bot.
:param max_leverage: Max leverage allowed on this pair :param max_leverage: Max leverage allowed on this pair
: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 side: 'long' or 'short' - indicating the direction of the proposed trade
:return: A leverage amount, which is between 1.0 and max_leverage. :return: A leverage amount, which is between 1.0 and max_leverage.
""" """
return 1.0 return 1.0
``` ```
All profit calculations include leverage. Stoploss / ROI also include leverage in their calculation.
Defining a stoploss of 10% at 10x leverage would trigger the stoploss with a 1% move to the downside.

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

@ -171,8 +171,8 @@ official commands. You can ask at any moment for help with `/help`.
| `/locks` | Show currently locked pairs. | `/locks` | Show currently locked pairs.
| `/unlock <pair or lock_id>` | Remove the lock for this pair (or for this lock id). | `/unlock <pair or lock_id>` | Remove the lock for this pair (or for this lock id).
| `/profit [<n>]` | Display a summary of your profit/loss from close trades and some stats about your performance, over the last n days (all trades by default) | `/profit [<n>]` | Display a summary of your profit/loss from close trades and some stats about your performance, over the last n days (all trades by default)
| `/forceexit <trade_id>` | Instantly exits the given trade (Ignoring `minimum_roi`). | `/forceexit <trade_id> | /fx <tradeid>` | Instantly exits the given trade (Ignoring `minimum_roi`).
| `/forceexit all` | Instantly exits all open trades (Ignoring `minimum_roi`). | `/forceexit all | /fx all` | Instantly exits all open trades (Ignoring `minimum_roi`).
| `/fx` | alias for `/forceexit` | `/fx` | alias for `/forceexit`
| `/forcelong <pair> [rate]` | Instantly buys the given pair. Rate is optional and only applies to limit orders. (`force_entry_enable` must be set to True) | `/forcelong <pair> [rate]` | Instantly buys the given pair. Rate is optional and only applies to limit orders. (`force_entry_enable` must be set to True)
| `/forceshort <pair> [rate]` | Instantly shorts the given pair. Rate is optional and only applies to limit orders. This will only work on non-spot markets. (`force_entry_enable` must be set to True) | `/forceshort <pair> [rate]` | Instantly shorts the given pair. Rate is optional and only applies to limit orders. This will only work on non-spot markets. (`force_entry_enable` must be set to True)
@ -270,10 +270,15 @@ Return a summary of your profit/loss and performance.
> **Latest Trade opened:** `2 minutes ago` > **Latest Trade opened:** `2 minutes ago`
> **Avg. Duration:** `2:33:45` > **Avg. Duration:** `2:33:45`
> **Best Performing:** `PAY/BTC: 50.23%` > **Best Performing:** `PAY/BTC: 50.23%`
> **Trading volume:** `0.5 BTC`
> **Profit factor:** `1.04`
> **Max Drawdown:** `9.23% (0.01255 BTC)`
The relative profit of `1.2%` is the average profit per trade. The relative profit of `1.2%` is the average profit per trade.
The relative profit of `15.2 Σ%` is be based on the starting capital - so in this case, the starting capital was `0.00485701 * 1.152 = 0.00738 BTC`. The relative profit of `15.2 Σ%` is be based on the starting capital - so in this case, the starting capital was `0.00485701 * 1.152 = 0.00738 BTC`.
Starting capital is either taken from the `available_capital` setting, or calculated by using current wallet size - profits. Starting capital is either taken from the `available_capital` setting, or calculated by using current wallet size - profits.
Profit Factor is calculated as gross profits / gross losses - and should serve as an overall metric for the strategy.
Max drawdown corresponds to the backtesting metric `Absolute Drawdown (Account)` - calculated as `(Absolute Drawdown) / (DrawdownHigh + startingBalance)`.
### /forceexit <trade_id> ### /forceexit <trade_id>
@ -281,6 +286,7 @@ Starting capital is either taken from the `available_capital` setting, or calcul
!!! Tip !!! Tip
You can get a list of all open trades by calling `/forceexit` without parameter, which will show a list of buttons to simply exit a trade. You can get a list of all open trades by calling `/forceexit` without parameter, which will show a list of buttons to simply exit a trade.
This command has an alias in `/fx` - which has the same capabilities, but is faster to type in "emergency" situations.
### /forcelong <pair> [rate] | /forceshort <pair> [rate] ### /forcelong <pair> [rate] | /forceshort <pair> [rate]
@ -328,11 +334,11 @@ Per default `/daily` will return the 7 last days. The example below if for `/dai
> **Daily Profit over the last 3 days:** > **Daily Profit over the last 3 days:**
``` ```
Day Profit BTC Profit USD Day (count) USDT USD Profit %
---------- -------------- ------------ -------------- ------------ ---------- ----------
2018-01-03 0.00224175 BTC 29,142 USD 2022-06-11 (1) -0.746 USDT -0.75 USD -0.08%
2018-01-02 0.00033131 BTC 4,307 USD 2022-06-10 (0) 0 USDT 0.00 USD 0.00%
2018-01-01 0.00269130 BTC 34.986 USD 2022-06-09 (5) 20 USDT 20.10 USD 5.00%
``` ```
### /weekly <n> ### /weekly <n>
@ -342,11 +348,11 @@ from Monday. The example below if for `/weekly 3`:
> **Weekly Profit over the last 3 weeks (starting from Monday):** > **Weekly Profit over the last 3 weeks (starting from Monday):**
``` ```
Monday Profit BTC Profit USD Monday (count) Profit BTC Profit USD Profit %
---------- -------------- ------------ ------------- -------------- ------------ ----------
2018-01-03 0.00224175 BTC 29,142 USD 2018-01-03 (5) 0.00224175 BTC 29,142 USD 4.98%
2017-12-27 0.00033131 BTC 4,307 USD 2017-12-27 (1) 0.00033131 BTC 4,307 USD 0.00%
2017-12-20 0.00269130 BTC 34.986 USD 2017-12-20 (4) 0.00269130 BTC 34.986 USD 5.12%
``` ```
### /monthly <n> ### /monthly <n>
@ -356,11 +362,11 @@ if for `/monthly 3`:
> **Monthly Profit over the last 3 months:** > **Monthly Profit over the last 3 months:**
``` ```
Month Profit BTC Profit USD Month (count) Profit BTC Profit USD Profit %
---------- -------------- ------------ ------------- -------------- ------------ ----------
2018-01 0.00224175 BTC 29,142 USD 2018-01 (20) 0.00224175 BTC 29,142 USD 4.98%
2017-12 0.00033131 BTC 4,307 USD 2017-12 (5) 0.00033131 BTC 4,307 USD 0.00%
2017-11 0.00269130 BTC 34.986 USD 2017-11 (10) 0.00269130 BTC 34.986 USD 5.10%
``` ```
### /whitelist ### /whitelist

View File

@ -32,4 +32,8 @@ Please ensure that you're also updating dependencies - otherwise things might br
``` bash ``` bash
git pull git pull
pip install -U -r requirements.txt pip install -U -r requirements.txt
pip install -e .
# Ensure freqUI is at the latest version
freqtrade install-ui
``` ```

View File

@ -651,6 +651,61 @@ Common arguments:
``` ```
## Detailed backtest analysis
Advanced backtest result analysis.
More details in the [Backtesting analysis](advanced-backtesting.md#analyze-the-buyentry-and-sellexit-tags) Section.
```
usage: freqtrade backtesting-analysis [-h] [-v] [--logfile FILE] [-V]
[-c PATH] [-d PATH] [--userdir PATH]
[--export-filename PATH]
[--analysis-groups {0,1,2,3,4} [{0,1,2,3,4} ...]]
[--enter-reason-list ENTER_REASON_LIST [ENTER_REASON_LIST ...]]
[--exit-reason-list EXIT_REASON_LIST [EXIT_REASON_LIST ...]]
[--indicator-list INDICATOR_LIST [INDICATOR_LIST ...]]
optional arguments:
-h, --help show this help message and exit
--export-filename PATH, --backtest-filename PATH
Use this filename for backtest results.Requires
`--export` to be set as well. Example: `--export-filen
ame=user_data/backtest_results/backtest_today.json`
--analysis-groups {0,1,2,3,4} [{0,1,2,3,4} ...]
grouping output - 0: simple wins/losses by enter tag,
1: by enter_tag, 2: by enter_tag and exit_tag, 3: by
pair and enter_tag, 4: by pair, enter_ and exit_tag
(this can get quite large)
--enter-reason-list ENTER_REASON_LIST [ENTER_REASON_LIST ...]
Comma separated list of entry signals to analyse.
Default: all. e.g. 'entry_tag_a,entry_tag_b'
--exit-reason-list EXIT_REASON_LIST [EXIT_REASON_LIST ...]
Comma separated list of exit signals to analyse.
Default: all. e.g.
'exit_tag_a,roi,stop_loss,trailing_stop_loss'
--indicator-list INDICATOR_LIST [INDICATOR_LIST ...]
Comma separated list of indicators to analyse. e.g.
'close,rsi,bb_lowerband,profit_abs'
Common arguments:
-v, --verbose Verbose mode (-vv for more, -vvv to get all messages).
--logfile FILE Log to the file specified. Special values are:
'syslog', 'journald'. See the documentation for more
details.
-V, --version show program's version number and exit
-c PATH, --config PATH
Specify configuration file (default:
`userdir/config.json` or `config.json` whichever
exists). Multiple --config options may be used. Can be
set to `-` to read config from stdin.
-d PATH, --datadir PATH
Path to directory with historical backtesting data.
--userdir PATH, --user-data-dir PATH
Path to userdata directory.
```
## List Hyperopt results ## List Hyperopt results
You can list the hyperoptimization epochs the Hyperopt module evaluated previously with the `hyperopt-list` sub-command. You can list the hyperoptimization epochs the Hyperopt module evaluated previously with the `hyperopt-list` sub-command.

View File

@ -239,3 +239,52 @@ Possible parameters are:
The fields in `webhook.webhookstatus` are used for regular status messages (Started / Stopped / ...). Parameters are filled using string.format. The fields in `webhook.webhookstatus` are used for regular status messages (Started / Stopped / ...). Parameters are filled using string.format.
The only possible value here is `{status}`. The only possible value here is `{status}`.
## Discord
A special form of webhooks is available for discord.
You can configure this as follows:
```json
"discord": {
"enabled": true,
"webhook_url": "https://discord.com/api/webhooks/<Your webhook URL ...>",
"exit_fill": [
{"Trade ID": "{trade_id}"},
{"Exchange": "{exchange}"},
{"Pair": "{pair}"},
{"Direction": "{direction}"},
{"Open rate": "{open_rate}"},
{"Close rate": "{close_rate}"},
{"Amount": "{amount}"},
{"Open date": "{open_date:%Y-%m-%d %H:%M:%S}"},
{"Close date": "{close_date:%Y-%m-%d %H:%M:%S}"},
{"Profit": "{profit_amount} {stake_currency}"},
{"Profitability": "{profit_ratio:.2%}"},
{"Enter tag": "{enter_tag}"},
{"Exit Reason": "{exit_reason}"},
{"Strategy": "{strategy}"},
{"Timeframe": "{timeframe}"},
],
"entry_fill": [
{"Trade ID": "{trade_id}"},
{"Exchange": "{exchange}"},
{"Pair": "{pair}"},
{"Direction": "{direction}"},
{"Open rate": "{open_rate}"},
{"Amount": "{amount}"},
{"Open date": "{open_date:%Y-%m-%d %H:%M:%S}"},
{"Enter tag": "{enter_tag}"},
{"Strategy": "{strategy} {timeframe}"},
]
}
```
The above represents the default (`exit_fill` and `entry_fill` are optional and will default to the above configuration) - modifications are obviously possible.
Available fields correspond to the fields for webhooks and are documented in the corresponding webhook sections.
The notifications will look as follows by default.
![discord-notification](assets/discord_notification.png)

View File

@ -6,6 +6,7 @@ Contains all start-commands, subcommands and CLI Interface creation.
Note: Be careful with file-scoped imports in these subfiles. Note: Be careful with file-scoped imports in these subfiles.
as they are parsed on startup, nothing containing optional modules should be loaded. as they are parsed on startup, nothing containing optional modules should be loaded.
""" """
from freqtrade.commands.analyze_commands import start_analysis_entries_exits
from freqtrade.commands.arguments import Arguments 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,

View File

@ -0,0 +1,69 @@
import logging
from pathlib import Path
from typing import Any, Dict
from freqtrade.configuration import setup_utils_configuration
from freqtrade.enums import RunMode
from freqtrade.exceptions import OperationalException
logger = logging.getLogger(__name__)
def setup_analyze_configuration(args: Dict[str, Any], method: RunMode) -> Dict[str, Any]:
"""
Prepare the configuration for the entry/exit reason analysis module
:param args: Cli args from Arguments()
:param method: Bot running mode
:return: Configuration
"""
config = setup_utils_configuration(args, method)
no_unlimited_runmodes = {
RunMode.BACKTEST: 'backtesting',
}
if method in no_unlimited_runmodes.keys():
from freqtrade.data.btanalysis import get_latest_backtest_filename
if 'exportfilename' in config:
if config['exportfilename'].is_dir():
btfile = Path(get_latest_backtest_filename(config['exportfilename']))
signals_file = f"{config['exportfilename']}/{btfile.stem}_signals.pkl"
else:
if config['exportfilename'].exists():
btfile = Path(config['exportfilename'])
signals_file = f"{btfile.parent}/{btfile.stem}_signals.pkl"
else:
raise OperationalException(f"{config['exportfilename']} does not exist.")
else:
raise OperationalException('exportfilename not in config.')
if (not Path(signals_file).exists()):
raise OperationalException(
(f"Cannot find latest backtest signals file: {signals_file}."
"Run backtesting with `--export signals`.")
)
return config
def start_analysis_entries_exits(args: Dict[str, Any]) -> None:
"""
Start analysis script
:param args: Cli args from Arguments()
:return: None
"""
from freqtrade.data.entryexitanalysis import process_entry_exit_reasons
# Initialize configuration
config = setup_analyze_configuration(args, RunMode.BACKTEST)
logger.info('Starting freqtrade in analysis mode')
process_entry_exit_reasons(config['exportfilename'],
config['exchange']['pair_whitelist'],
config['analysis_groups'],
config['enter_reason_list'],
config['exit_reason_list'],
config['indicator_list']
)

View File

@ -101,6 +101,9 @@ ARGS_HYPEROPT_SHOW = ["hyperopt_list_best", "hyperopt_list_profitable", "hyperop
"print_json", "hyperoptexportfilename", "hyperopt_show_no_header", "print_json", "hyperoptexportfilename", "hyperopt_show_no_header",
"disableparamexport", "backtest_breakdown"] "disableparamexport", "backtest_breakdown"]
ARGS_ANALYZE_ENTRIES_EXITS = ["exportfilename", "analysis_groups", "enter_reason_list",
"exit_reason_list", "indicator_list"]
NO_CONF_REQURIED = ["convert-data", "convert-trade-data", "download-data", "list-timeframes", NO_CONF_REQURIED = ["convert-data", "convert-trade-data", "download-data", "list-timeframes",
"list-markets", "list-pairs", "list-strategies", "list-data", "list-markets", "list-pairs", "list-strategies", "list-data",
"hyperopt-list", "hyperopt-show", "backtest-filter", "hyperopt-list", "hyperopt-show", "backtest-filter",
@ -182,8 +185,9 @@ class Arguments:
self.parser = argparse.ArgumentParser(description='Free, open source crypto trading bot') self.parser = argparse.ArgumentParser(description='Free, open source crypto trading bot')
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_analysis_entries_exits, start_backtesting,
start_convert_data, start_convert_db, start_convert_trades, start_backtesting_show, 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,
@ -283,6 +287,13 @@ class Arguments:
backtesting_show_cmd.set_defaults(func=start_backtesting_show) backtesting_show_cmd.set_defaults(func=start_backtesting_show)
self._build_args(optionlist=ARGS_BACKTEST_SHOW, parser=backtesting_show_cmd) self._build_args(optionlist=ARGS_BACKTEST_SHOW, parser=backtesting_show_cmd)
# Add backtesting analysis subcommand
analysis_cmd = subparsers.add_parser('backtesting-analysis',
help='Backtest Analysis module.',
parents=[_common_parser])
analysis_cmd.set_defaults(func=start_analysis_entries_exits)
self._build_args(optionlist=ARGS_ANALYZE_ENTRIES_EXITS, parser=analysis_cmd)
# Add edge subcommand # Add edge subcommand
edge_cmd = subparsers.add_parser('edge', help='Edge module.', edge_cmd = subparsers.add_parser('edge', help='Edge module.',
parents=[_common_parser, _strategy_parser]) parents=[_common_parser, _strategy_parser])

View File

@ -614,4 +614,37 @@ AVAILABLE_CLI_OPTIONS = {
"that do not contain any parameters."), "that do not contain any parameters."),
action="store_true", action="store_true",
), ),
"analysis_groups": Arg(
"--analysis-groups",
help=("grouping output - "
"0: simple wins/losses by enter tag, "
"1: by enter_tag, "
"2: by enter_tag and exit_tag, "
"3: by pair and enter_tag, "
"4: by pair, enter_ and exit_tag (this can get quite large)"),
nargs='+',
default=['0', '1', '2'],
choices=['0', '1', '2', '3', '4'],
),
"enter_reason_list": Arg(
"--enter-reason-list",
help=("Comma separated list of entry signals to analyse. Default: all. "
"e.g. 'entry_tag_a,entry_tag_b'"),
nargs='+',
default=['all'],
),
"exit_reason_list": Arg(
"--exit-reason-list",
help=("Comma separated list of exit signals to analyse. Default: all. "
"e.g. 'exit_tag_a,roi,stop_loss,trailing_stop_loss'"),
nargs='+',
default=['all'],
),
"indicator_list": Arg(
"--indicator-list",
help=("Comma separated list of indicators to analyse. "
"e.g. 'close,rsi,bb_lowerband,profit_abs'"),
nargs='+',
default=[],
),
} }

View File

@ -19,9 +19,9 @@ def start_convert_db(args: Dict[str, Any]) -> None:
config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE) config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE)
init_db(config['db_url'], False) init_db(config['db_url'])
session_target = Trade._session session_target = Trade._session
init_db(config['db_url_from'], False) init_db(config['db_url_from'])
logger.info("Starting db migration.") logger.info("Starting db migration.")
trade_count = 0 trade_count = 0

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

@ -95,6 +95,8 @@ class Configuration:
self._process_data_options(config) self._process_data_options(config)
self._process_analyze_options(config)
# Check if the exchange set by the user is supported # Check if the exchange set by the user is supported
check_exchange(config, config.get('experimental', {}).get('block_bad_exchanges', True)) check_exchange(config, config.get('experimental', {}).get('block_bad_exchanges', True))
@ -433,6 +435,19 @@ class Configuration:
self._args_to_config(config, argname='candle_types', self._args_to_config(config, argname='candle_types',
logstring='Detected --candle-types: {}') logstring='Detected --candle-types: {}')
def _process_analyze_options(self, config: Dict[str, Any]) -> None:
self._args_to_config(config, argname='analysis_groups',
logstring='Analysis reason groups: {}')
self._args_to_config(config, argname='enter_reason_list',
logstring='Analysis enter tag list: {}')
self._args_to_config(config, argname='exit_reason_list',
logstring='Analysis exit tag list: {}')
self._args_to_config(config, argname='indicator_list',
logstring='Analysis indicator list: {}')
def _process_runmode(self, config: Dict[str, Any]) -> None: def _process_runmode(self, config: Dict[str, Any]) -> None:
self._args_to_config(config, argname='dry_run', self._args_to_config(config, argname='dry_run',
@ -490,6 +505,7 @@ 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)
if isinstance(config['pairs'], list):
config['pairs'].sort() config['pairs'].sort()
return return
@ -501,5 +517,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

@ -302,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',
@ -336,6 +336,47 @@ CONF_SCHEMA = {
'webhookstatus': {'type': 'object'}, 'webhookstatus': {'type': 'object'},
}, },
}, },
'discord': {
'type': 'object',
'properties': {
'enabled': {'type': 'boolean'},
'webhook_url': {'type': 'string'},
"exit_fill": {
'type': 'array', 'items': {'type': 'object'},
'default': [
{"Trade ID": "{trade_id}"},
{"Exchange": "{exchange}"},
{"Pair": "{pair}"},
{"Direction": "{direction}"},
{"Open rate": "{open_rate}"},
{"Close rate": "{close_rate}"},
{"Amount": "{amount}"},
{"Open date": "{open_date:%Y-%m-%d %H:%M:%S}"},
{"Close date": "{close_date:%Y-%m-%d %H:%M:%S}"},
{"Profit": "{profit_amount} {stake_currency}"},
{"Profitability": "{profit_ratio:.2%}"},
{"Enter tag": "{enter_tag}"},
{"Exit Reason": "{exit_reason}"},
{"Strategy": "{strategy}"},
{"Timeframe": "{timeframe}"},
]
},
"entry_fill": {
'type': 'array', 'items': {'type': 'object'},
'default': [
{"Trade ID": "{trade_id}"},
{"Exchange": "{exchange}"},
{"Pair": "{pair}"},
{"Direction": "{direction}"},
{"Open rate": "{open_rate}"},
{"Amount": "{amount}"},
{"Open date": "{open_date:%Y-%m-%d %H:%M:%S}"},
{"Enter tag": "{enter_tag}"},
{"Strategy": "{strategy} {timeframe}"},
]
},
}
},
'api_server': { 'api_server': {
'type': 'object', 'type': 'object',
'properties': { 'properties': {

View File

@ -26,7 +26,7 @@ BT_DATA_COLUMNS = ['pair', 'stake_amount', 'amount', 'open_date', 'close_date',
'profit_ratio', 'profit_abs', 'exit_reason', 'profit_ratio', 'profit_abs', 'exit_reason',
'initial_stop_loss_abs', 'initial_stop_loss_ratio', 'stop_loss_abs', 'initial_stop_loss_abs', 'initial_stop_loss_ratio', 'stop_loss_abs',
'stop_loss_ratio', 'min_rate', 'max_rate', 'is_open', 'enter_tag', 'stop_loss_ratio', 'min_rate', 'max_rate', 'is_open', 'enter_tag',
'is_short' 'is_short', 'open_timestamp', 'close_timestamp', 'orders'
] ]
@ -283,6 +283,8 @@ def load_backtest_data(filename: Union[Path, str], strategy: Optional[str] = Non
if 'enter_tag' not in df.columns: if 'enter_tag' not in df.columns:
df['enter_tag'] = df['buy_tag'] df['enter_tag'] = df['buy_tag']
df = df.drop(['buy_tag'], axis=1) df = df.drop(['buy_tag'], axis=1)
if 'orders' not in df.columns:
df.loc[:, 'orders'] = None
else: else:
# old format - only with lists. # old format - only with lists.
@ -337,7 +339,7 @@ def trade_list_to_dataframe(trades: List[LocalTrade]) -> pd.DataFrame:
:param trades: List of trade objects :param trades: List of trade objects
:return: Dataframe with BT_DATA_COLUMNS :return: Dataframe with BT_DATA_COLUMNS
""" """
df = pd.DataFrame.from_records([t.to_json() for t in trades], columns=BT_DATA_COLUMNS) df = pd.DataFrame.from_records([t.to_json(True) for t in trades], columns=BT_DATA_COLUMNS)
if len(df) > 0: if len(df) > 0:
df.loc[:, 'close_date'] = pd.to_datetime(df['close_date'], utc=True) df.loc[:, 'close_date'] = pd.to_datetime(df['close_date'], utc=True)
df.loc[:, 'open_date'] = pd.to_datetime(df['open_date'], utc=True) df.loc[:, 'open_date'] = pd.to_datetime(df['open_date'], utc=True)
@ -353,7 +355,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

@ -0,0 +1,227 @@
import logging
from pathlib import Path
from typing import List, Optional
import joblib
import pandas as pd
from tabulate import tabulate
from freqtrade.data.btanalysis import (get_latest_backtest_filename, load_backtest_data,
load_backtest_stats)
from freqtrade.exceptions import OperationalException
logger = logging.getLogger(__name__)
def _load_signal_candles(backtest_dir: Path):
if backtest_dir.is_dir():
scpf = Path(backtest_dir,
Path(get_latest_backtest_filename(backtest_dir)).stem + "_signals.pkl"
)
else:
scpf = Path(backtest_dir.parent / f"{backtest_dir.stem}_signals.pkl")
try:
scp = open(scpf, "rb")
signal_candles = joblib.load(scp)
logger.info(f"Loaded signal candles: {str(scpf)}")
except Exception as e:
logger.error("Cannot load signal candles from pickled results: ", e)
return signal_candles
def _process_candles_and_indicators(pairlist, strategy_name, trades, signal_candles):
analysed_trades_dict = {}
analysed_trades_dict[strategy_name] = {}
try:
logger.info(f"Processing {strategy_name} : {len(pairlist)} pairs")
for pair in pairlist:
if pair in signal_candles[strategy_name]:
analysed_trades_dict[strategy_name][pair] = _analyze_candles_and_indicators(
pair,
trades,
signal_candles[strategy_name][pair])
except Exception as e:
print(f"Cannot process entry/exit reasons for {strategy_name}: ", e)
return analysed_trades_dict
def _analyze_candles_and_indicators(pair, trades, signal_candles):
buyf = signal_candles
if len(buyf) > 0:
buyf = buyf.set_index('date', drop=False)
trades_red = trades.loc[trades['pair'] == pair].copy()
trades_inds = pd.DataFrame()
if trades_red.shape[0] > 0 and buyf.shape[0] > 0:
for t, v in trades_red.open_date.items():
allinds = buyf.loc[(buyf['date'] < v)]
if allinds.shape[0] > 0:
tmp_inds = allinds.iloc[[-1]]
trades_red.loc[t, 'signal_date'] = tmp_inds['date'].values[0]
trades_red.loc[t, 'enter_reason'] = trades_red.loc[t, 'enter_tag']
tmp_inds.index.rename('signal_date', inplace=True)
trades_inds = pd.concat([trades_inds, tmp_inds])
if 'signal_date' in trades_red:
trades_red['signal_date'] = pd.to_datetime(trades_red['signal_date'], utc=True)
trades_red.set_index('signal_date', inplace=True)
try:
trades_red = pd.merge(trades_red, trades_inds, on='signal_date', how='outer')
except Exception as e:
raise e
return trades_red
else:
return pd.DataFrame()
def _do_group_table_output(bigdf, glist):
for g in glist:
# 0: summary wins/losses grouped by enter tag
if g == "0":
group_mask = ['enter_reason']
wins = bigdf.loc[bigdf['profit_abs'] >= 0] \
.groupby(group_mask) \
.agg({'profit_abs': ['sum']})
wins.columns = ['profit_abs_wins']
loss = bigdf.loc[bigdf['profit_abs'] < 0] \
.groupby(group_mask) \
.agg({'profit_abs': ['sum']})
loss.columns = ['profit_abs_loss']
new = bigdf.groupby(group_mask).agg({'profit_abs': [
'count',
lambda x: sum(x > 0),
lambda x: sum(x <= 0)]})
new = pd.concat([new, wins, loss], axis=1).fillna(0)
new['profit_tot'] = new['profit_abs_wins'] - abs(new['profit_abs_loss'])
new['wl_ratio_pct'] = (new.iloc[:, 1] / new.iloc[:, 0] * 100).fillna(0)
new['avg_win'] = (new['profit_abs_wins'] / new.iloc[:, 1]).fillna(0)
new['avg_loss'] = (new['profit_abs_loss'] / new.iloc[:, 2]).fillna(0)
new.columns = ['total_num_buys', 'wins', 'losses', 'profit_abs_wins', 'profit_abs_loss',
'profit_tot', 'wl_ratio_pct', 'avg_win', 'avg_loss']
sortcols = ['total_num_buys']
_print_table(new, sortcols, show_index=True)
else:
agg_mask = {'profit_abs': ['count', 'sum', 'median', 'mean'],
'profit_ratio': ['sum', 'median', 'mean']}
agg_cols = ['num_buys', 'profit_abs_sum', 'profit_abs_median',
'profit_abs_mean', 'median_profit_pct', 'mean_profit_pct',
'total_profit_pct']
sortcols = ['profit_abs_sum', 'enter_reason']
# 1: profit summaries grouped by enter_tag
if g == "1":
group_mask = ['enter_reason']
# 2: profit summaries grouped by enter_tag and exit_tag
if g == "2":
group_mask = ['enter_reason', 'exit_reason']
# 3: profit summaries grouped by pair and enter_tag
if g == "3":
group_mask = ['pair', 'enter_reason']
# 4: profit summaries grouped by pair, enter_ and exit_tag (this can get quite large)
if g == "4":
group_mask = ['pair', 'enter_reason', 'exit_reason']
if group_mask:
new = bigdf.groupby(group_mask).agg(agg_mask).reset_index()
new.columns = group_mask + agg_cols
new['median_profit_pct'] = new['median_profit_pct'] * 100
new['mean_profit_pct'] = new['mean_profit_pct'] * 100
new['total_profit_pct'] = new['total_profit_pct'] * 100
_print_table(new, sortcols)
else:
logger.warning("Invalid group mask specified.")
def _print_results(analysed_trades, stratname, analysis_groups,
enter_reason_list, exit_reason_list,
indicator_list, columns=None):
if columns is None:
columns = ['pair', 'open_date', 'close_date', 'profit_abs', 'enter_reason', 'exit_reason']
bigdf = pd.DataFrame()
for pair, trades in analysed_trades[stratname].items():
bigdf = pd.concat([bigdf, trades], ignore_index=True)
if bigdf.shape[0] > 0 and ('enter_reason' in bigdf.columns):
if analysis_groups:
_do_group_table_output(bigdf, analysis_groups)
if enter_reason_list and "all" not in enter_reason_list:
bigdf = bigdf.loc[(bigdf['enter_reason'].isin(enter_reason_list))]
if exit_reason_list and "all" not in exit_reason_list:
bigdf = bigdf.loc[(bigdf['exit_reason'].isin(exit_reason_list))]
if "all" in indicator_list:
print(bigdf)
elif indicator_list is not None:
available_inds = []
for ind in indicator_list:
if ind in bigdf:
available_inds.append(ind)
ilist = ["pair", "enter_reason", "exit_reason"] + available_inds
_print_table(bigdf[ilist], sortcols=['exit_reason'], show_index=False)
else:
print("\\_ No trades to show")
def _print_table(df, sortcols=None, show_index=False):
if (sortcols is not None):
data = df.sort_values(sortcols)
else:
data = df
print(
tabulate(
data,
headers='keys',
tablefmt='psql',
showindex=show_index
)
)
def process_entry_exit_reasons(backtest_dir: Path,
pairlist: List[str],
analysis_groups: Optional[List[str]] = ["0", "1", "2"],
enter_reason_list: Optional[List[str]] = ["all"],
exit_reason_list: Optional[List[str]] = ["all"],
indicator_list: Optional[List[str]] = []):
try:
backtest_stats = load_backtest_stats(backtest_dir)
for strategy_name, results in backtest_stats['strategy'].items():
trades = load_backtest_data(backtest_dir, strategy_name)
if not trades.empty:
signal_candles = _load_signal_candles(backtest_dir)
analysed_trades_dict = _process_candles_and_indicators(pairlist, strategy_name,
trades, signal_candles)
_print_results(analysed_trades_dict,
strategy_name,
analysis_groups,
enter_reason_list,
exit_reason_list,
indicator_list)
except ValueError as e:
raise OperationalException(e) from e

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.")
@ -277,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)

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

@ -52,12 +52,17 @@ class Binance(Exchange):
ordertype = 'stop' if self.trading_mode == TradingMode.FUTURES else 'stop_loss_limit' ordertype = 'stop' if self.trading_mode == TradingMode.FUTURES else 'stop_loss_limit'
return order['type'] == ordertype and ( return (
(side == "sell" and stop_loss > float(order['info']['stopPrice'])) or order.get('stopPrice', None) is None
(side == "buy" and stop_loss < float(order['info']['stopPrice'])) or (
order['type'] == ordertype
and (
(side == "sell" and stop_loss > float(order['stopPrice'])) or
(side == "buy" and stop_loss < float(order['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,7 +100,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: int = None 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

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

@ -92,7 +92,7 @@ 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 = None
self._markets: Dict = {} self._markets: Dict = {}
self._trading_fees: Dict[str, Any] = {} self._trading_fees: Dict[str, Any] = {}
@ -291,7 +291,7 @@ 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
@ -322,7 +322,7 @@ class Exchange:
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]:
@ -953,6 +953,12 @@ 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
@ -1158,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:
@ -1180,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:
@ -1206,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)
@ -1232,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):
@ -1345,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
@ -1373,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
@ -1712,7 +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: int = None until_ms: Optional[int] = None
) -> Tuple[str, str, str, List]: ) -> Tuple[str, str, str, List]:
""" """
Download historic ohlcv Download historic ohlcv
@ -1773,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
@ -2125,10 +2131,11 @@ class Exchange:
except ccxt.BaseError as e: except ccxt.BaseError as e:
raise OperationalException(e) from e raise OperationalException(e) from e
@retrier @retrier_async
def get_market_leverage_tiers(self, symbol) -> List[Dict]: async def get_market_leverage_tiers(self, symbol: str) -> Tuple[str, List[Dict]]:
try: try:
return self._api.fetch_market_leverage_tiers(symbol) tier = await self._api_async.fetch_market_leverage_tiers(symbol)
return symbol, tier
except ccxt.DDoSProtection as e: except ccxt.DDoSProtection as e:
raise DDosProtection(e) from e raise DDosProtection(e) from e
except (ccxt.NetworkError, ccxt.ExchangeError) as e: except (ccxt.NetworkError, ccxt.ExchangeError) as e:
@ -2162,8 +2169,14 @@ class Exchange:
f"Initializing leverage_tiers for {len(symbols)} markets. " f"Initializing leverage_tiers for {len(symbols)} markets. "
"This will take about a minute.") "This will take about a minute.")
for symbol in sorted(symbols): coros = [self.get_market_leverage_tiers(symbol) for symbol in sorted(symbols)]
tiers[symbol] = self.get_market_leverage_tiers(symbol)
for input_coro in chunks(coros, 100):
results = self.loop.run_until_complete(
asyncio.gather(*input_coro, return_exceptions=True))
for symbol, res in results:
tiers[symbol] = res
logger.info(f"Done initializing {len(symbols)} markets.") logger.info(f"Done initializing {len(symbols)} markets.")
@ -2413,14 +2426,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,

View File

@ -104,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)
@ -145,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

@ -3,6 +3,7 @@ import logging
from datetime import datetime from datetime import datetime
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from freqtrade.constants import BuySell
from freqtrade.enums import MarginMode, TradingMode from freqtrade.enums import MarginMode, TradingMode
from freqtrade.exceptions import OperationalException from freqtrade.exceptions import OperationalException
from freqtrade.exchange import Exchange from freqtrade.exchange import Exchange
@ -24,6 +25,8 @@ class Gateio(Exchange):
_ft_has: Dict = { _ft_has: Dict = {
"ohlcv_candle_limit": 1000, "ohlcv_candle_limit": 1000,
"ohlcv_volume_currency": "quote", "ohlcv_volume_currency": "quote",
"time_in_force_parameter": "timeInForce",
"order_time_in_force": ['gtc', 'ioc'],
"stoploss_order_types": {"limit": "limit"}, "stoploss_order_types": {"limit": "limit"},
"stoploss_on_exchange": True, "stoploss_on_exchange": True,
} }
@ -40,13 +43,33 @@ class Gateio(Exchange):
] ]
def validate_ordertypes(self, order_types: Dict) -> None: def validate_ordertypes(self, order_types: Dict) -> None:
super().validate_ordertypes(order_types)
if self.trading_mode != TradingMode.FUTURES: if self.trading_mode != TradingMode.FUTURES:
if any(v == 'market' for k, v in order_types.items()): if any(v == 'market' for k, v in order_types.items()):
raise OperationalException( raise OperationalException(
f'Exchange {self.name} does not support market orders.') f'Exchange {self.name} does not support market orders.')
def _get_params(
self,
side: BuySell,
ordertype: str,
leverage: float,
reduceOnly: bool,
time_in_force: str = 'gtc',
) -> Dict:
params = super()._get_params(
side=side,
ordertype=ordertype,
leverage=leverage,
reduceOnly=reduceOnly,
time_in_force=time_in_force,
)
if ordertype == 'market' and self.trading_mode == TradingMode.FUTURES:
params['type'] = 'market'
param = self._ft_has.get('time_in_force_parameter', '')
params.update({param: 'ioc'})
return params
def get_trades_for_order(self, order_id: str, pair: str, since: datetime, def get_trades_for_order(self, order_id: str, pair: str, since: datetime,
params: Optional[Dict] = None) -> List: params: Optional[Dict] = None) -> List:
trades = super().get_trades_for_order(order_id, pair, since, params) trades = super().get_trades_for_order(order_id, pair, since, params)
@ -61,7 +84,8 @@ class Gateio(Exchange):
pair_fees = self._trading_fees.get(pair, {}) pair_fees = self._trading_fees.get(pair, {})
if pair_fees: if pair_fees:
for idx, trade in enumerate(trades): for idx, trade in enumerate(trades):
if trade.get('fee', {}).get('cost') is None: fee = trade.get('fee', {})
if fee and fee.get('cost') is None:
takerOrMaker = trade.get('takerOrMaker', 'taker') takerOrMaker = trade.get('takerOrMaker', 'taker')
if pair_fees.get(takerOrMaker) is not None: if pair_fees.get(takerOrMaker) is not None:
trades[idx]['fee'] = { trades[idx]['fee'] = {
@ -71,14 +95,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,
@ -90,5 +114,7 @@ class Gateio(Exchange):
Verify stop_loss against stoploss-order value (limit or price) Verify stop_loss against stoploss-order value (limit or price)
Returns True if adjustment is necessary. Returns True if adjustment is necessary.
""" """
return ((side == "sell" and stop_loss > float(order['stopPrice'])) or return (order.get('stopPrice', None) is None or (
(side == "buy" and stop_loss < float(order['stopPrice']))) side == "sell" and stop_loss > float(order['stopPrice'])) or
(side == "buy" and stop_loss < float(order['stopPrice']))
)

View File

@ -27,7 +27,13 @@ class Huobi(Exchange):
Verify stop_loss against stoploss-order value (limit or price) Verify stop_loss against stoploss-order value (limit or price)
Returns True if adjustment is necessary. Returns True if adjustment is necessary.
""" """
return order['type'] == 'stop' and stop_loss > float(order['stopPrice']) return (
order.get('stopPrice', None) is None
or (
order['type'] == 'stop'
and stop_loss > float(order['stopPrice'])
)
)
def _get_stop_params(self, ordertype: str, stop_price: float) -> Dict: def _get_stop_params(self, ordertype: str, stop_price: float) -> Dict:

View File

@ -45,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']]))

View File

@ -33,7 +33,10 @@ class Kucoin(Exchange):
Verify stop_loss against stoploss-order value (limit or price) Verify stop_loss against stoploss-order value (limit or price)
Returns True if adjustment is necessary. Returns True if adjustment is necessary.
""" """
return order['info'].get('stop') is not None and stop_loss > float(order['stopPrice']) return (
order.get('stopPrice', None) is None
or stop_loss > float(order['stopPrice'])
)
def _get_stop_params(self, ordertype: str, stop_price: float) -> Dict: def _get_stop_params(self, ordertype: str, stop_price: float) -> Dict:

View File

@ -4,7 +4,7 @@ Freqtrade is the main module of this bot. It contains the class Freqtrade()
import copy import copy
import logging import logging
import traceback import traceback
from datetime import datetime, time, timezone from datetime import datetime, time, timedelta, timezone
from math import isclose from math import isclose
from threading import Lock from threading import Lock
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
@ -67,14 +67,12 @@ 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['db_url'], clean_open_orders=self.config['dry_run']) init_db(self.config['db_url'])
self.wallets = Wallets(self.config, self.exchange) self.wallets = Wallets(self.config, self.exchange)
PairLocks.timeframe = self.config['timeframe'] PairLocks.timeframe = self.config['timeframe']
self.protections = ProtectionManager(self.config, self.strategy.protections)
# RPC runs in separate threads, can start handling external commands just after # RPC runs in separate threads, can start handling external commands just after
# initialization, even before Freqtradebot has a chance to start its throttling, # initialization, even before Freqtradebot has a chance to start its throttling,
# so anything in the Freqtradebot instance should be ready (initialized), including # so anything in the Freqtradebot instance should be ready (initialized), including
@ -123,7 +121,9 @@ 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()
# Initialize protections AFTER bot start - otherwise parameters are not loaded.
self.protections = ProtectionManager(self.config, self.strategy.protections)
def notify_status(self, msg: str) -> None: def notify_status(self, msg: str) -> None:
""" """
@ -227,7 +227,7 @@ class FreqtradeBot(LoggingMixin):
Notify the user when the bot is stopped (not reloaded) Notify the user when the bot is stopped (not reloaded)
and there are still open trades active. and there are still open trades active.
""" """
open_trades = Trade.get_trades([Trade.is_open.is_(True)]).all() open_trades = Trade.get_open_trades()
if len(open_trades) != 0 and self.state != State.RELOAD_CONFIG: if len(open_trades) != 0 and self.state != State.RELOAD_CONFIG:
msg = { msg = {
@ -299,7 +299,17 @@ 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 InvalidOrderException as e:
logger.warning(f"Error updating Order {order.order_id} due to {e}.")
if order.order_date_utc - timedelta(days=5) < datetime.now(timezone.utc):
logger.warning(
"Order is older than 5 days. Assuming order was fully cancelled.")
fo = order.to_ccxt_object()
fo['status'] = 'canceled'
self.handle_timedout_order(fo, order.trade)
except ExchangeError as e: except ExchangeError as e:
@ -780,7 +790,7 @@ class FreqtradeBot(LoggingMixin):
current_rate=enter_limit_requested, current_rate=enter_limit_requested,
proposed_leverage=1.0, proposed_leverage=1.0,
max_leverage=max_leverage, max_leverage=max_leverage,
side=trade_side, side=trade_side, entry_tag=entry_tag,
) if self.trading_mode != TradingMode.SPOT else 1.0 ) if self.trading_mode != TradingMode.SPOT else 1.0
# Cap leverage between 1.0 and max_leverage. # Cap leverage between 1.0 and max_leverage.
leverage = min(max(leverage, 1.0), max_leverage) leverage = min(max(leverage, 1.0), max_leverage)
@ -1018,7 +1028,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:
@ -1105,7 +1115,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),
@ -1113,11 +1123,12 @@ 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)
if exited:
return True return True
return False return False
@ -1201,15 +1212,15 @@ class FreqtradeBot(LoggingMixin):
current_order_rate=order_obj.price, entry_tag=trade.enter_tag, current_order_rate=order_obj.price, entry_tag=trade.enter_tag,
side=trade.entry_side) side=trade.entry_side)
full_cancel = False replacing = True
cancel_reason = constants.CANCEL_REASON['REPLACE'] cancel_reason = constants.CANCEL_REASON['REPLACE']
if not adjusted_entry_price: if not adjusted_entry_price:
full_cancel = True if trade.nr_of_successful_entries == 0 else False replacing = False
cancel_reason = constants.CANCEL_REASON['USER_CANCEL'] cancel_reason = constants.CANCEL_REASON['USER_CANCEL']
if order_obj.price != adjusted_entry_price: if order_obj.price != adjusted_entry_price:
# cancel existing order if new price is supplied or None # cancel existing order if new price is supplied or None
self.handle_cancel_enter(trade, order, cancel_reason, self.handle_cancel_enter(trade, order, cancel_reason,
allow_full_cancel=full_cancel) replacing=replacing)
if adjusted_entry_price: if adjusted_entry_price:
# place new order only if new price is supplied # place new order only if new price is supplied
self.execute_entry( self.execute_entry(
@ -1243,10 +1254,11 @@ class FreqtradeBot(LoggingMixin):
def handle_cancel_enter( def handle_cancel_enter(
self, trade: Trade, order: Dict, reason: str, self, trade: Trade, order: Dict, reason: str,
allow_full_cancel: Optional[bool] = True replacing: Optional[bool] = False
) -> bool: ) -> bool:
""" """
Buy cancel - cancel order Buy cancel - cancel order
:param replacing: Replacing order - prevent trade deletion.
:return: True if order was fully cancelled :return: True if order was fully cancelled
""" """
was_trade_fully_canceled = False was_trade_fully_canceled = False
@ -1284,7 +1296,7 @@ class FreqtradeBot(LoggingMixin):
if isclose(filled_amount, 0.0, abs_tol=constants.MATH_CLOSE_PREC): if isclose(filled_amount, 0.0, abs_tol=constants.MATH_CLOSE_PREC):
# 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
open_order_count = len([order for order in trade.orders if order.status == 'open']) open_order_count = len([order for order in trade.orders if order.status == 'open'])
if open_order_count <= 1 and allow_full_cancel: if open_order_count <= 1 and trade.nr_of_successful_entries == 0 and not replacing:
logger.info(f'{side} order fully cancelled. Removing {trade} from database.') 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
@ -1293,7 +1305,7 @@ class FreqtradeBot(LoggingMixin):
# FIXME TODO: This could possibly reworked to not duplicate the code 15 lines below. # FIXME TODO: This could possibly reworked to not duplicate the code 15 lines below.
self.update_trade_state(trade, trade.open_order_id, corder) self.update_trade_state(trade, trade.open_order_id, corder)
trade.open_order_id = None trade.open_order_id = None
logger.info(f'Partial {side} order timeout for {trade}.') logger.info(f'{side} Order timeout for {trade}.')
else: else:
# if trade is partially complete, edit the stake details for the trade # if trade is partially complete, edit the stake details for the trade
# and close the order # and close the order
@ -1405,7 +1417,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,
@ -1452,7 +1464,7 @@ class FreqtradeBot(LoggingMixin):
time_in_force=time_in_force, exit_reason=exit_reason, time_in_force=time_in_force, exit_reason=exit_reason,
sell_reason=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:
@ -1663,7 +1675,7 @@ class FreqtradeBot(LoggingMixin):
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, trade.trade_direction) 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)

View File

@ -187,7 +187,8 @@ 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 +276,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(
@ -496,7 +501,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))
@ -527,15 +533,23 @@ 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 exit_reason = exit_.exit_reason
@ -562,7 +576,8 @@ class Backtesting:
if order_type == 'limit': if order_type == 'limit':
close_rate = strategy_safe_wrapper(self.strategy.custom_exit_price, close_rate = strategy_safe_wrapper(self.strategy.custom_exit_price,
default_retval=close_rate)( 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=close_rate, current_profit=current_profit, proposed_rate=close_rate, current_profit=current_profit,
exit_tag=exit_reason) exit_tag=exit_reason)
@ -576,7 +591,10 @@ class Backtesting:
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,
trade=trade, # type: ignore[arg-type]
order_type='limit',
amount=trade.amount,
rate=close_rate, rate=close_rate,
time_in_force=time_in_force, time_in_force=time_in_force,
sell_reason=exit_reason, # deprecated sell_reason=exit_reason, # deprecated
@ -652,7 +670,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]:
@ -686,7 +704,7 @@ class Backtesting:
current_rate=row[OPEN_IDX], current_rate=row[OPEN_IDX],
proposed_leverage=1.0, proposed_leverage=1.0,
max_leverage=max_leverage, max_leverage=max_leverage,
side=direction, side=direction, entry_tag=entry_tag,
) if self._can_short else 1.0 ) if self._can_short else 1.0
# Cap leverage between 1.0 and max_leverage. # Cap leverage between 1.0 and max_leverage.
leverage = min(max(leverage, 1.0), max_leverage) leverage = min(max(leverage, 1.0), max_leverage)
@ -726,8 +744,9 @@ class Backtesting:
order_type = self.strategy.order_types['entry'] order_type = self.strategy.order_types['entry']
pos_adjust = trade is not None and requested_rate is 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
) )
@ -876,28 +895,34 @@ class Backtesting:
self.protections.stop_per_pair(pair, current_time, side) self.protections.stop_per_pair(pair, current_time, side)
self.protections.global_stop(current_time, side) self.protections.global_stop(current_time, side)
def manage_open_orders(self, trade: LocalTrade, current_time, row: Tuple) -> bool: def manage_open_orders(self, trade: LocalTrade, current_time: datetime, row: Tuple) -> bool:
""" """
Check if any open order needs to be cancelled or replaced. Check if any open order needs to be cancelled or replaced.
Returns True if the trade should be deleted. 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): oc = self.check_order_cancel(trade, order, current_time)
if oc:
# delete trade due to order timeout # delete trade due to order timeout
return True return True
elif self.check_order_replace(trade, order, current_time, row): elif oc is None and self.check_order_replace(trade, order, current_time, row):
# delete trade due to user request # delete trade due to user request
self.canceled_trade_entries += 1 self.canceled_trade_entries += 1
return True return True
# default maintain trade # default maintain trade
return False return False
def check_order_cancel(self, trade: LocalTrade, order: Order, current_time) -> bool: def check_order_cancel(
self, trade: LocalTrade, order: Order, current_time: datetime) -> Optional[bool]:
""" """
Check if current analyzed order has to be canceled. Check if current analyzed order has to be canceled.
Returns True if the trade should be Deleted (initial order was canceled). Returns True if the trade should be Deleted (initial order was canceled),
False if it's Canceled
None if the order is still active.
""" """
timedout = self.strategy.ft_check_timed_out(trade, order, current_time) timedout = self.strategy.ft_check_timed_out(
trade, # type: ignore[arg-type]
order, current_time)
if timedout: if timedout:
if order.side == trade.entry_side: if order.side == trade.entry_side:
self.timedout_entry_orders += 1 self.timedout_entry_orders += 1
@ -907,12 +932,15 @@ class Backtesting:
else: else:
# Close additional entry order # Close additional entry order
del trade.orders[trade.orders.index(order)] del trade.orders[trade.orders.index(order)]
trade.open_order_id = None
return False
if order.side == trade.exit_side: if order.side == trade.exit_side:
self.timedout_exit_orders += 1 self.timedout_exit_orders += 1
# Close exit order and retry exiting on next signal. # Close exit order and retry exiting on next signal.
del trade.orders[trade.orders.index(order)] del trade.orders[trade.orders.index(order)]
trade.open_order_id = None
return False return False
return None
def check_order_replace(self, trade: LocalTrade, order: Order, current_time, def check_order_replace(self, trade: LocalTrade, order: Order, current_time,
row: Tuple) -> bool: row: Tuple) -> bool:
@ -926,7 +954,8 @@ class Backtesting:
if order.side == trade.entry_side and current_time > order.order_date_utc: if order.side == trade.entry_side and current_time > order.order_date_utc:
requested_rate = strategy_safe_wrapper(self.strategy.adjust_entry_price, requested_rate = strategy_safe_wrapper(self.strategy.adjust_entry_price,
default_retval=order.price)( default_retval=order.price)(
trade=trade, order=order, pair=trade.pair, current_time=current_time, 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, proposed_rate=row[OPEN_IDX], current_order_rate=order.price,
entry_tag=trade.enter_tag, side=trade.trade_direction entry_tag=trade.enter_tag, side=trade.trade_direction
) # default value is current order price ) # default value is current order price
@ -937,6 +966,7 @@ class Backtesting:
return False return False
else: else:
del trade.orders[trade.orders.index(order)] del trade.orders[trade.orders.index(order)]
trade.open_order_id = None
self.canceled_entry_orders += 1 self.canceled_entry_orders += 1
# place new order if result was not None # place new order if result was not None
@ -1025,6 +1055,7 @@ class Backtesting:
# Close trade # Close trade
open_trade_count -= 1 open_trade_count -= 1
open_trades[pair].remove(t) open_trades[pair].remove(t)
LocalTrade.trades_open.remove(t)
self.wallets.update() self.wallets.update()
# 2. Process entries. # 2. Process entries.
@ -1048,6 +1079,8 @@ class Backtesting:
open_trade_count += 1 open_trade_count += 1
# logger.debug(f"{pair} - Emulate creation of new trade: {trade}.") # logger.debug(f"{pair} - Emulate creation of new trade: {trade}.")
open_trades[pair].append(trade) open_trades[pair].append(trade)
LocalTrade.add_bt_trade(trade)
self.wallets.update()
for trade in list(open_trades[pair]): for trade in list(open_trades[pair]):
# 3. Process entry orders. # 3. Process entry orders.
@ -1055,7 +1088,6 @@ class Backtesting:
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, trade) order.close_bt_order(current_time, trade)
trade.open_order_id = None trade.open_order_id = None
LocalTrade.add_bt_trade(trade)
self.wallets.update() self.wallets.update()
# 4. Create exit orders (if any) # 4. Create exit orders (if any)
@ -1065,6 +1097,7 @@ class Backtesting:
# 5. Process exit orders. # 5. Process exit orders.
order = trade.select_order(trade.exit_side, is_open=True) order = trade.select_order(trade.exit_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, trade)
trade.open_order_id = None trade.open_order_id = None
trade.close_date = current_time trade.close_date = current_time
trade.close(order.price, show_msg=False) trade.close(order.price, show_msg=False)
@ -1233,13 +1266,14 @@ class Backtesting:
self.results['strategy_comparison'].extend(results['strategy_comparison']) self.results['strategy_comparison'].extend(results['strategy_comparison'])
else: else:
self.results = results self.results = results
dt_appendix = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
if self.config.get('export', 'none') in ('trades', 'signals'): if self.config.get('export', 'none') in ('trades', 'signals'):
store_backtest_stats(self.config['exportfilename'], self.results) store_backtest_stats(self.config['exportfilename'], self.results, dt_appendix)
if (self.config.get('export', 'none') == 'signals' and if (self.config.get('export', 'none') == 'signals' and
self.dataprovider.runmode == RunMode.BACKTEST): self.dataprovider.runmode == RunMode.BACKTEST):
store_backtest_signal_candles(self.config['exportfilename'], self.processed_dfs) store_backtest_signal_candles(
self.config['exportfilename'], self.processed_dfs, dt_appendix)
# Results may be mixed up now. Sort them so they follow --strategy-list order. # Results may be mixed up now. Sort them so they follow --strategy-list order.
if 'strategy_list' in self.config and len(self.results) > 0: if 'strategy_list' in self.config and len(self.results) > 0:

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'])
@ -429,7 +429,7 @@ class Hyperopt:
return new_list return new_list
i = 0 i = 0
asked_non_tried: List[List[Any]] = [] asked_non_tried: List[List[Any]] = []
is_random: List[bool] = [] is_random_non_tried: List[bool] = []
while i < 5 and len(asked_non_tried) < n_points: while i < 5 and len(asked_non_tried) < n_points:
if i < 3: if i < 3:
self.opt.cache_ = {} self.opt.cache_ = {}
@ -438,7 +438,7 @@ class Hyperopt:
else: else:
asked = unique_list(self.opt.space.rvs(n_samples=n_points * 5)) asked = unique_list(self.opt.space.rvs(n_samples=n_points * 5))
is_random = [True for _ in range(len(asked))] is_random = [True for _ in range(len(asked))]
is_random += [rand for x, rand in zip(asked, is_random) is_random_non_tried += [rand for x, rand in zip(asked, is_random)
if x not in self.opt.Xi if x not in self.opt.Xi
and x not in asked_non_tried] and x not in asked_non_tried]
asked_non_tried += [x for x in asked asked_non_tried += [x for x in asked
@ -449,7 +449,7 @@ class Hyperopt:
if asked_non_tried: if asked_non_tried:
return ( return (
asked_non_tried[:min(len(asked_non_tried), n_points)], asked_non_tried[:min(len(asked_non_tried), n_points)],
is_random[:min(len(asked_non_tried), n_points)] is_random_non_tried[:min(len(asked_non_tried), n_points)]
) )
else: else:
return self.opt.ask(n_points=n_points), [False for _ in range(n_points)] return self.opt.ask(n_points=n_points), [False for _ in range(n_points)]

View File

@ -4,7 +4,6 @@ from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Union from typing import Any, Dict, List, Union
from numpy import int64
from pandas import DataFrame, to_datetime from pandas import DataFrame, to_datetime
from tabulate import tabulate from tabulate import tabulate
@ -18,21 +17,21 @@ from freqtrade.optimize.backtest_caching import get_backtest_metadata_filename
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def store_backtest_stats(recordfilename: Path, stats: Dict[str, DataFrame]) -> None: def store_backtest_stats(
recordfilename: Path, stats: Dict[str, DataFrame], dtappendix: str) -> None:
""" """
Stores backtest results Stores backtest results
:param recordfilename: Path object, which can either be a filename or a directory. :param recordfilename: Path object, which can either be a filename or a directory.
Filenames will be appended with a timestamp right before the suffix Filenames will be appended with a timestamp right before the suffix
while for directories, <directory>/backtest-result-<datetime>.json will be used as filename while for directories, <directory>/backtest-result-<datetime>.json will be used as filename
:param stats: Dataframe containing the backtesting statistics :param stats: Dataframe containing the backtesting statistics
:param dtappendix: Datetime to use for the filename
""" """
if recordfilename.is_dir(): if recordfilename.is_dir():
filename = (recordfilename / filename = (recordfilename / f'backtest-result-{dtappendix}.json')
f'backtest-result-{datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}.json')
else: else:
filename = Path.joinpath( filename = Path.joinpath(
recordfilename.parent, recordfilename.parent, f'{recordfilename.stem}-{dtappendix}'
f'{recordfilename.stem}-{datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}'
).with_suffix(recordfilename.suffix) ).with_suffix(recordfilename.suffix)
# Store metadata separately. # Store metadata separately.
@ -45,7 +44,8 @@ def store_backtest_stats(recordfilename: Path, stats: Dict[str, DataFrame]) -> N
file_dump_json(latest_filename, {'latest_backtest': str(filename.name)}) file_dump_json(latest_filename, {'latest_backtest': str(filename.name)})
def store_backtest_signal_candles(recordfilename: Path, candles: Dict[str, Dict]) -> Path: def store_backtest_signal_candles(
recordfilename: Path, candles: Dict[str, Dict], dtappendix: str) -> Path:
""" """
Stores backtest trade signal candles Stores backtest trade signal candles
:param recordfilename: Path object, which can either be a filename or a directory. :param recordfilename: Path object, which can either be a filename or a directory.
@ -53,14 +53,13 @@ def store_backtest_signal_candles(recordfilename: Path, candles: Dict[str, Dict]
while for directories, <directory>/backtest-result-<datetime>_signals.pkl will be used while for directories, <directory>/backtest-result-<datetime>_signals.pkl will be used
as filename as filename
:param stats: Dict containing the backtesting signal candles :param stats: Dict containing the backtesting signal candles
:param dtappendix: Datetime to use for the filename
""" """
if recordfilename.is_dir(): if recordfilename.is_dir():
filename = (recordfilename / filename = (recordfilename / f'backtest-result-{dtappendix}_signals.pkl')
f'backtest-result-{datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}_signals.pkl')
else: else:
filename = Path.joinpath( filename = Path.joinpath(
recordfilename.parent, recordfilename.parent, f'{recordfilename.stem}-{dtappendix}_signals.pkl'
f'{recordfilename.stem}-{datetime.now().strftime("%Y-%m-%d_%H-%M-%S")}_signals.pkl'
) )
file_dump_joblib(filename, candles) file_dump_joblib(filename, candles)
@ -417,9 +416,9 @@ def generate_strategy_stats(pairlist: List[str],
key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None
worst_pair = min([pair for pair in pair_results if pair['key'] != 'TOTAL'], worst_pair = min([pair for pair in pair_results if pair['key'] != 'TOTAL'],
key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None
if not results.empty: winning_profit = results.loc[results['profit_abs'] > 0, 'profit_abs'].sum()
results['open_timestamp'] = results['open_date'].view(int64) // 1e6 losing_profit = results.loc[results['profit_abs'] < 0, 'profit_abs'].sum()
results['close_timestamp'] = results['close_date'].view(int64) // 1e6 profit_factor = winning_profit / abs(losing_profit) if losing_profit else 0.0
backtest_days = (max_date - min_date).days or 1 backtest_days = (max_date - min_date).days or 1
strat_stats = { strat_stats = {
@ -447,6 +446,7 @@ def generate_strategy_stats(pairlist: List[str],
'profit_total_long_abs': results.loc[~results['is_short'], 'profit_abs'].sum(), 'profit_total_long_abs': results.loc[~results['is_short'], 'profit_abs'].sum(),
'profit_total_short_abs': results.loc[results['is_short'], 'profit_abs'].sum(), 'profit_total_short_abs': results.loc[results['is_short'], 'profit_abs'].sum(),
'cagr': calculate_cagr(backtest_days, start_balance, content['final_balance']), 'cagr': calculate_cagr(backtest_days, start_balance, content['final_balance']),
'profit_factor': profit_factor,
'backtest_start': min_date.strftime(DATETIME_PRINT_FORMAT), 'backtest_start': min_date.strftime(DATETIME_PRINT_FORMAT),
'backtest_start_ts': int(min_date.timestamp() * 1000), 'backtest_start_ts': int(min_date.timestamp() * 1000),
'backtest_end': max_date.strftime(DATETIME_PRINT_FORMAT), 'backtest_end': max_date.strftime(DATETIME_PRINT_FORMAT),
@ -501,8 +501,10 @@ 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 = Underwater
(_, _, _, _, _, max_relative_drawdown) = calculate_max_drawdown( (_, _, _, _, _, max_relative_drawdown) = calculate_max_drawdown(
results, value_col='profit_abs', starting_balance=start_balance, relative=True) 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,
@ -781,6 +783,8 @@ def text_table_add_metrics(strat_results: Dict) -> str:
strat_results['stake_currency'])), strat_results['stake_currency'])),
('Total profit %', f"{strat_results['profit_total']:.2%}"), ('Total profit %', f"{strat_results['profit_total']:.2%}"),
('CAGR %', f"{strat_results['cagr']:.2%}" if 'cagr' in strat_results else 'N/A'), ('CAGR %', f"{strat_results['cagr']:.2%}" if 'cagr' in strat_results else 'N/A'),
('Profit factor', f'{strat_results["profit_factor"]:.2f}' if 'profit_factor'
in strat_results else 'N/A'),
('Trades per day', strat_results['trades_per_day']), ('Trades per day', strat_results['trades_per_day']),
('Avg. daily profit %', ('Avg. daily profit %',
f"{(strat_results['profit_total'] / strat_results['backtest_days']):.2%}"), f"{(strat_results['profit_total'] / strat_results['backtest_days']):.2%}"),

View File

@ -1,5 +1,5 @@
# flake8: noqa: F401 # flake8: noqa: F401
from freqtrade.persistence.models import clean_dry_run_db, cleanup_db, init_db from freqtrade.persistence.models import cleanup_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 from freqtrade.persistence.trade_model import LocalTrade, Order, Trade

View File

@ -201,16 +201,18 @@ def migrate_orders_table(engine, table_back_name: str, cols_order: List):
ft_fee_base = get_column_def(cols_order, 'ft_fee_base', 'null') ft_fee_base = get_column_def(cols_order, 'ft_fee_base', 'null')
average = get_column_def(cols_order, 'average', 'null') average = get_column_def(cols_order, 'average', 'null')
stop_price = get_column_def(cols_order, 'stop_price', 'null')
# sqlite does not support literals for booleans # sqlite does not support literals for booleans
with engine.begin() as connection: with engine.begin() as connection:
connection.execute(text(f""" connection.execute(text(f"""
insert into orders (id, ft_trade_id, ft_order_side, ft_pair, ft_is_open, order_id, insert into orders (id, ft_trade_id, ft_order_side, ft_pair, ft_is_open, order_id,
status, symbol, order_type, side, price, amount, filled, average, remaining, cost, status, symbol, order_type, side, price, amount, filled, average, remaining, cost,
order_date, order_filled_date, order_update_date, ft_fee_base) stop_price, order_date, order_filled_date, order_update_date, ft_fee_base)
select id, ft_trade_id, ft_order_side, ft_pair, ft_is_open, order_id, select id, ft_trade_id, ft_order_side, ft_pair, ft_is_open, order_id,
status, symbol, order_type, side, price, amount, filled, {average} average, remaining, status, symbol, order_type, side, price, amount, filled, {average} average, remaining,
cost, order_date, order_filled_date, order_update_date, {ft_fee_base} ft_fee_base cost, {stop_price} stop_price, order_date, order_filled_date,
order_update_date, {ft_fee_base} ft_fee_base
from {table_back_name} from {table_back_name}
""")) """))
@ -247,6 +249,35 @@ def set_sqlite_to_wal(engine):
connection.execute(text("PRAGMA journal_mode=wal")) connection.execute(text("PRAGMA journal_mode=wal"))
def fix_old_dry_orders(engine):
with engine.begin() as connection:
connection.execute(
text(
"""
update orders
set ft_is_open = 0
where ft_is_open = 1 and (ft_trade_id, order_id) not in (
select id, stoploss_order_id from trades where stoploss_order_id is not null
) and ft_order_side = 'stoploss'
and order_id like 'dry_%'
"""
)
)
connection.execute(
text(
"""
update orders
set ft_is_open = 0
where ft_is_open = 1
and (ft_trade_id, order_id) not in (
select id, open_order_id from trades where open_order_id is not null
) and ft_order_side != 'stoploss'
and order_id like 'dry_%'
"""
)
)
def check_migrate(engine, decl_base, previous_tables) -> None: def check_migrate(engine, decl_base, previous_tables) -> None:
""" """
Checks if migration is necessary and migrates if necessary Checks if migration is necessary and migrates if necessary
@ -265,9 +296,8 @@ def check_migrate(engine, decl_base, previous_tables) -> None:
# Check if migration necessary # Check if migration necessary
# Migrates both trades and orders table! # Migrates both trades and orders table!
# if ('orders' not in previous_tables if not has_column(cols_orders, 'stop_price'):
# or not has_column(cols_orders, 'leverage')): # if not has_column(cols_trades, 'base_currency'):
if not has_column(cols_trades, 'base_currency'):
logger.info(f"Running database migration for trades - " logger.info(f"Running database migration for trades - "
f"backup: {table_back_name}, {order_table_bak_name}") f"backup: {table_back_name}, {order_table_bak_name}")
migrate_trades_and_orders_table( migrate_trades_and_orders_table(
@ -288,3 +318,4 @@ def check_migrate(engine, decl_base, previous_tables) -> None:
"start with a fresh database.") "start with a fresh database.")
set_sqlite_to_wal(engine) set_sqlite_to_wal(engine)
fix_old_dry_orders(engine)

View File

@ -21,14 +21,12 @@ logger = logging.getLogger(__name__)
_SQL_DOCS_URL = 'http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls' _SQL_DOCS_URL = 'http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls'
def init_db(db_url: str, clean_open_orders: bool = False) -> None: def init_db(db_url: str) -> None:
""" """
Initializes this module with the given config, Initializes this module with the given config,
registers all known command handlers registers all known command handlers
and starts polling for message updates and starts polling for message updates
:param db_url: Database to use :param db_url: Database to use
:param clean_open_orders: Remove open orders from the database.
Useful for dry-run or if all orders have been reset on the exchange.
:return: None :return: None
""" """
kwargs = {} kwargs = {}
@ -64,10 +62,6 @@ def init_db(db_url: str, clean_open_orders: bool = False) -> None:
_DECL_BASE.metadata.create_all(engine) _DECL_BASE.metadata.create_all(engine)
check_migrate(engine, decl_base=_DECL_BASE, previous_tables=previous_tables) check_migrate(engine, decl_base=_DECL_BASE, previous_tables=previous_tables)
# Clean dry_run DB if the db is not in-memory
if clean_open_orders and db_url != 'sqlite://':
clean_dry_run_db()
def cleanup_db() -> None: def cleanup_db() -> None:
""" """
@ -75,15 +69,3 @@ def cleanup_db() -> None:
:return: None :return: None
""" """
Trade.commit() Trade.commit()
def clean_dry_run_db() -> None:
"""
Remove open_order_id from a Dry_run DB
:return: None
"""
for trade in Trade.query.filter(Trade.open_order_id.isnot(None)).all():
# Check we are updating only a dry_run order not a prod one
if 'dry_run' in trade.open_order_id:
trade.open_order_id = None
Trade.commit()

View File

@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional
from sqlalchemy import (Boolean, Column, DateTime, Enum, Float, ForeignKey, Integer, String, from sqlalchemy import (Boolean, Column, DateTime, Enum, Float, ForeignKey, Integer, String,
UniqueConstraint, desc, func) UniqueConstraint, desc, func)
from sqlalchemy.orm import Query, relationship from sqlalchemy.orm import Query, lazyload, relationship
from freqtrade.constants import DATETIME_PRINT_FORMAT, NON_OPEN_EXCHANGE_STATES, BuySell, LongShort from freqtrade.constants import DATETIME_PRINT_FORMAT, NON_OPEN_EXCHANGE_STATES, BuySell, LongShort
from freqtrade.enums import ExitType, TradingMode from freqtrade.enums import ExitType, TradingMode
@ -57,6 +57,7 @@ class Order(_DECL_BASE):
filled = Column(Float, nullable=True) filled = Column(Float, nullable=True)
remaining = Column(Float, nullable=True) remaining = Column(Float, nullable=True)
cost = Column(Float, nullable=True) cost = Column(Float, nullable=True)
stop_price = Column(Float, nullable=True)
order_date = Column(DateTime, nullable=True, default=datetime.utcnow) order_date = Column(DateTime, nullable=True, default=datetime.utcnow)
order_filled_date = Column(DateTime, nullable=True) order_filled_date = Column(DateTime, nullable=True)
order_update_date = Column(DateTime, nullable=True) order_update_date = Column(DateTime, nullable=True)
@ -74,7 +75,7 @@ class Order(_DECL_BASE):
@property @property
def safe_filled(self) -> float: def safe_filled(self) -> float:
return self.filled or self.amount or 0.0 return self.filled if self.filled is not None else self.amount or 0.0
@property @property
def safe_fee_base(self) -> float: def safe_fee_base(self) -> float:
@ -107,6 +108,7 @@ class Order(_DECL_BASE):
self.average = order.get('average', self.average) self.average = order.get('average', self.average)
self.remaining = order.get('remaining', self.remaining) self.remaining = order.get('remaining', self.remaining)
self.cost = order.get('cost', self.cost) self.cost = order.get('cost', self.cost)
self.stop_price = order.get('stopPrice', self.stop_price)
if 'timestamp' in order and order['timestamp'] is not None: if 'timestamp' in order and order['timestamp'] is not None:
self.order_date = datetime.fromtimestamp(order['timestamp'] / 1000, tz=timezone.utc) self.order_date = datetime.fromtimestamp(order['timestamp'] / 1000, tz=timezone.utc)
@ -118,17 +120,43 @@ class Order(_DECL_BASE):
self.order_filled_date = datetime.now(timezone.utc) self.order_filled_date = datetime.now(timezone.utc)
self.order_update_date = datetime.now(timezone.utc) self.order_update_date = datetime.now(timezone.utc)
def to_json(self, entry_side: str) -> Dict[str, Any]: def to_ccxt_object(self) -> Dict[str, Any]:
return { return {
'id': self.order_id,
'symbol': self.ft_pair,
'price': self.price,
'average': self.average,
'amount': self.amount,
'cost': self.cost,
'type': self.order_type,
'side': self.ft_order_side,
'filled': self.filled,
'remaining': self.remaining,
'stopPrice': self.stop_price,
'datetime': self.order_date_utc.strftime('%Y-%m-%dT%H:%M:%S.%f'),
'timestamp': int(self.order_date_utc.timestamp() * 1000),
'status': self.status,
'fee': None,
'info': {},
}
def to_json(self, entry_side: str, minified: bool = False) -> Dict[str, Any]:
resp = {
'amount': self.amount,
'safe_price': self.safe_price,
'ft_order_side': self.ft_order_side,
'order_filled_timestamp': int(self.order_filled_date.replace(
tzinfo=timezone.utc).timestamp() * 1000) if self.order_filled_date else None,
'ft_is_entry': self.ft_order_side == entry_side,
}
if not minified:
resp.update({
'pair': self.ft_pair, 'pair': self.ft_pair,
'order_id': self.order_id, 'order_id': self.order_id,
'status': self.status, 'status': self.status,
'amount': self.amount,
'average': round(self.average, 8) if self.average else 0, 'average': round(self.average, 8) if self.average else 0,
'safe_price': self.safe_price,
'cost': self.cost if self.cost else 0, 'cost': self.cost if self.cost else 0,
'filled': self.filled, 'filled': self.filled,
'ft_order_side': self.ft_order_side,
'is_open': self.ft_is_open, 'is_open': self.ft_is_open,
'order_date': self.order_date.strftime(DATETIME_PRINT_FORMAT) 'order_date': self.order_date.strftime(DATETIME_PRINT_FORMAT)
if self.order_date else None, if self.order_date else None,
@ -136,17 +164,16 @@ class Order(_DECL_BASE):
tzinfo=timezone.utc).timestamp() * 1000) if self.order_date else None, tzinfo=timezone.utc).timestamp() * 1000) if self.order_date else None,
'order_filled_date': self.order_filled_date.strftime(DATETIME_PRINT_FORMAT) 'order_filled_date': self.order_filled_date.strftime(DATETIME_PRINT_FORMAT)
if self.order_filled_date else None, if self.order_filled_date else None,
'order_filled_timestamp': int(self.order_filled_date.replace(
tzinfo=timezone.utc).timestamp() * 1000) if self.order_filled_date else None,
'order_type': self.order_type, 'order_type': self.order_type,
'price': self.price, 'price': self.price,
'ft_is_entry': self.ft_order_side == entry_side,
'remaining': self.remaining, 'remaining': self.remaining,
} })
return resp
def close_bt_order(self, close_date: datetime, trade: 'LocalTrade'): def close_bt_order(self, close_date: datetime, trade: 'LocalTrade'):
self.order_filled_date = close_date self.order_filled_date = close_date
self.filled = self.amount self.filled = self.amount
self.remaining = 0
self.status = 'closed' self.status = 'closed'
self.ft_is_open = False self.ft_is_open = False
if (self.ft_order_side == trade.entry_side if (self.ft_order_side == trade.entry_side
@ -190,6 +217,14 @@ class Order(_DECL_BASE):
""" """
return Order.query.filter(Order.ft_is_open.is_(True)).all() return Order.query.filter(Order.ft_is_open.is_(True)).all()
@staticmethod
def order_by_id(order_id: str) -> Optional['Order']:
"""
Retrieve order based on order_id
:return: Order or None
"""
return Order.query.filter(Order.order_id == order_id).first()
class LocalTrade(): class LocalTrade():
""" """
@ -366,9 +401,9 @@ class LocalTrade():
f'open_rate={self.open_rate:.8f}, open_since={open_since})' f'open_rate={self.open_rate:.8f}, open_since={open_since})'
) )
def to_json(self) -> Dict[str, Any]: def to_json(self, minified: bool = False) -> Dict[str, Any]:
filled_orders = self.select_filled_orders() filled_orders = self.select_filled_or_open_orders()
orders = [order.to_json(self.entry_side) for order in filled_orders] orders = [order.to_json(self.entry_side, minified) for order in filled_orders]
return { return {
'trade_id': self.id, 'trade_id': self.id,
@ -592,8 +627,8 @@ class LocalTrade():
""" """
self.close_rate = rate self.close_rate = rate
self.close_date = self.close_date or datetime.utcnow() self.close_date = self.close_date or datetime.utcnow()
self.close_profit = self.calc_profit_ratio() self.close_profit = self.calc_profit_ratio(rate)
self.close_profit_abs = self.calc_profit() self.close_profit_abs = self.calc_profit(rate)
self.is_open = False self.is_open = False
self.exit_order_status = 'closed' self.exit_order_status = 'closed'
self.open_order_id = None self.open_order_id = None
@ -661,10 +696,9 @@ class LocalTrade():
""" """
self.open_trade_value = self._calc_open_trade_value() self.open_trade_value = self._calc_open_trade_value()
def calculate_interest(self, interest_rate: Optional[float] = None) -> Decimal: def calculate_interest(self) -> Decimal:
""" """
:param interest_rate: interest_charge for borrowing this coin(optional). Calculate interest for this trade. Only applicable for Margin trading.
If interest_rate is not set self.interest_rate will be used
""" """
zero = Decimal(0.0) zero = Decimal(0.0)
# If nothing was borrowed # If nothing was borrowed
@ -677,34 +711,26 @@ class LocalTrade():
total_seconds = Decimal((now - open_date).total_seconds()) total_seconds = Decimal((now - open_date).total_seconds())
hours = total_seconds / sec_per_hour or zero hours = total_seconds / sec_per_hour or zero
rate = Decimal(interest_rate or self.interest_rate) rate = Decimal(self.interest_rate)
borrowed = Decimal(self.borrowed) borrowed = Decimal(self.borrowed)
return interest(exchange_name=self.exchange, borrowed=borrowed, rate=rate, hours=hours) return interest(exchange_name=self.exchange, borrowed=borrowed, rate=rate, hours=hours)
def _calc_base_close(self, amount: Decimal, rate: Optional[float] = None, def _calc_base_close(self, amount: Decimal, rate: float, fee: float) -> Decimal:
fee: Optional[float] = None) -> Decimal:
close_trade = Decimal(amount) * Decimal(rate or self.close_rate) # type: ignore close_trade = amount * Decimal(rate)
fees = close_trade * Decimal(fee or self.fee_close) fees = close_trade * Decimal(fee)
if self.is_short: if self.is_short:
return close_trade + fees return close_trade + fees
else: else:
return close_trade - fees return close_trade - fees
def calc_close_trade_value(self, rate: Optional[float] = None, def calc_close_trade_value(self, rate: float) -> float:
fee: Optional[float] = None,
interest_rate: Optional[float] = None) -> float:
""" """
Calculate the close_rate including fee Calculate the Trade's close value including fees
:param fee: fee to use on the close rate (optional). :param rate: rate to compare with.
If rate is not set self.fee will be used :return: value in stake currency of the open trade
:param rate: rate to compare with (optional).
If rate is not set self.close_rate will be used
:param interest_rate: interest_charge for borrowing this coin (optional).
If interest_rate is not set self.interest_rate will be used
:return: Price in BTC of the open trade
""" """
if rate is None and not self.close_rate: if rate is None and not self.close_rate:
return 0.0 return 0.0
@ -713,49 +739,38 @@ class LocalTrade():
trading_mode = self.trading_mode or TradingMode.SPOT trading_mode = self.trading_mode or TradingMode.SPOT
if trading_mode == TradingMode.SPOT: if trading_mode == TradingMode.SPOT:
return float(self._calc_base_close(amount, rate, fee)) return float(self._calc_base_close(amount, rate, self.fee_close))
elif (trading_mode == TradingMode.MARGIN): elif (trading_mode == TradingMode.MARGIN):
total_interest = self.calculate_interest(interest_rate) total_interest = self.calculate_interest()
if self.is_short: if self.is_short:
amount = amount + total_interest amount = amount + total_interest
return float(self._calc_base_close(amount, rate, fee)) return float(self._calc_base_close(amount, rate, self.fee_close))
else: else:
# Currency already owned for longs, no need to purchase # Currency already owned for longs, no need to purchase
return float(self._calc_base_close(amount, rate, fee) - total_interest) return float(self._calc_base_close(amount, rate, self.fee_close) - total_interest)
elif (trading_mode == TradingMode.FUTURES): elif (trading_mode == TradingMode.FUTURES):
funding_fees = self.funding_fees or 0.0 funding_fees = self.funding_fees or 0.0
# Positive funding_fees -> Trade has gained from fees. # Positive funding_fees -> Trade has gained from fees.
# Negative funding_fees -> Trade had to pay the fees. # Negative funding_fees -> Trade had to pay the fees.
if self.is_short: if self.is_short:
return float(self._calc_base_close(amount, rate, fee)) - funding_fees return float(self._calc_base_close(amount, rate, self.fee_close)) - funding_fees
else: else:
return float(self._calc_base_close(amount, rate, fee)) + funding_fees return float(self._calc_base_close(amount, rate, self.fee_close)) + funding_fees
else: else:
raise OperationalException( raise OperationalException(
f"{self.trading_mode.value} trading is not yet available using freqtrade") f"{self.trading_mode.value} trading is not yet available using freqtrade")
def calc_profit(self, rate: Optional[float] = None, def calc_profit(self, rate: float) -> float:
fee: Optional[float] = None,
interest_rate: Optional[float] = None) -> float:
""" """
Calculate the absolute profit in stake currency between Close and Open trade Calculate the absolute profit in stake currency between Close and Open trade
:param fee: fee to use on the close rate (optional). :param rate: close rate to compare with.
If fee is not set self.fee will be used
:param rate: close rate to compare with (optional).
If rate is not set self.close_rate will be used
:param interest_rate: interest_charge for borrowing this coin (optional).
If interest_rate is not set self.interest_rate will be used
:return: profit in stake currency as float :return: profit in stake currency as float
""" """
close_trade_value = self.calc_close_trade_value( close_trade_value = self.calc_close_trade_value(rate)
rate=(rate or self.close_rate),
fee=(fee or self.fee_close),
interest_rate=(interest_rate or self.interest_rate)
)
if self.is_short: if self.is_short:
profit = self.open_trade_value - close_trade_value profit = self.open_trade_value - close_trade_value
@ -763,23 +778,13 @@ class LocalTrade():
profit = close_trade_value - self.open_trade_value profit = close_trade_value - self.open_trade_value
return float(f"{profit:.8f}") return float(f"{profit:.8f}")
def calc_profit_ratio(self, rate: Optional[float] = None, def calc_profit_ratio(self, rate: float) -> float:
fee: Optional[float] = None,
interest_rate: Optional[float] = None) -> float:
""" """
Calculates the profit as ratio (including fee). Calculates the profit as ratio (including fee).
:param rate: rate to compare with (optional). :param rate: rate to compare with.
If rate is not set self.close_rate will be used
:param fee: fee to use on the close rate (optional).
:param interest_rate: interest_charge for borrowing this coin (optional).
If interest_rate is not set self.interest_rate will be used
:return: profit ratio as float :return: profit ratio as float
""" """
close_trade_value = self.calc_close_trade_value( close_trade_value = self.calc_close_trade_value(rate)
rate=(rate or self.close_rate),
fee=(fee or self.fee_close),
interest_rate=(interest_rate or self.interest_rate)
)
short_close_zero = (self.is_short and close_trade_value == 0.0) short_close_zero = (self.is_short and close_trade_value == 0.0)
long_close_zero = (not self.is_short and self.open_trade_value == 0.0) long_close_zero = (not self.is_short and self.open_trade_value == 0.0)
@ -796,14 +801,6 @@ class LocalTrade():
return float(f"{profit_ratio:.8f}") return float(f"{profit_ratio:.8f}")
def recalc_trade_from_orders(self): def recalc_trade_from_orders(self):
# We need at least 2 entry orders for averaging amounts and rates.
# TODO: this condition could probably be removed
if len(self.select_filled_orders(self.entry_side)) < 2:
self.stake_amount = self.amount * self.open_rate / self.leverage
# Just in case, still recalc open trade value
self.recalc_open_trade_value()
return
total_amount = 0.0 total_amount = 0.0
total_stake = 0.0 total_stake = 0.0
@ -815,8 +812,6 @@ class LocalTrade():
tmp_amount = o.safe_amount_after_fee tmp_amount = o.safe_amount_after_fee
tmp_price = o.average or o.price tmp_price = o.average or o.price
if o.filled is not None:
tmp_amount = o.filled
if tmp_amount > 0.0 and tmp_price is not None: if tmp_amount > 0.0 and tmp_price is not None:
total_amount += tmp_amount total_amount += tmp_amount
total_stake += tmp_price * tmp_amount total_stake += tmp_price * tmp_amount
@ -841,8 +836,8 @@ class LocalTrade():
return o return o
return None return None
def select_order( def select_order(self, order_side: Optional[str] = None,
self, order_side: str = None, is_open: Optional[bool] = None) -> Optional[Order]: is_open: Optional[bool] = None) -> Optional[Order]:
""" """
Finds latest order for this orderside and status Finds latest order for this orderside and status
:param order_side: ft_order_side of the order (either 'buy', 'sell' or 'stoploss') :param order_side: ft_order_side of the order (either 'buy', 'sell' or 'stoploss')
@ -870,6 +865,21 @@ class LocalTrade():
(o.filled or 0) > 0 and (o.filled or 0) > 0 and
o.status in NON_OPEN_EXCHANGE_STATES] o.status in NON_OPEN_EXCHANGE_STATES]
def select_filled_or_open_orders(self) -> List['Order']:
"""
Finds filled or open orders
:param order_side: Side of the order (either 'buy', 'sell', or None)
:return: array of Order objects
"""
return [o for o in self.orders if
(
o.ft_is_open is False
and (o.filled or 0) > 0
and o.status in NON_OPEN_EXCHANGE_STATES
)
or (o.ft_is_open is True and o.status is not None)
]
@property @property
def nr_of_successful_entries(self) -> int: def nr_of_successful_entries(self) -> int:
""" """
@ -1108,7 +1118,7 @@ class Trade(_DECL_BASE, LocalTrade):
) )
@staticmethod @staticmethod
def get_trades(trade_filter=None) -> Query: def get_trades(trade_filter=None, include_orders: bool = True) -> Query:
""" """
Helper function to query Trades using filters. Helper function to query Trades using filters.
NOTE: Not supported in Backtesting. NOTE: Not supported in Backtesting.
@ -1123,9 +1133,14 @@ class Trade(_DECL_BASE, LocalTrade):
if trade_filter is not None: if trade_filter is not None:
if not isinstance(trade_filter, list): if not isinstance(trade_filter, list):
trade_filter = [trade_filter] trade_filter = [trade_filter]
return Trade.query.filter(*trade_filter) this_query = Trade.query.filter(*trade_filter)
else: else:
return Trade.query this_query = Trade.query
if not include_orders:
# Don't load order relations
# Consider using noload or raiseload instead of lazyload
this_query = this_query.options(lazyload(Trade.orders))
return this_query
@staticmethod @staticmethod
def get_open_order_trades() -> List['Trade']: def get_open_order_trades() -> List['Trade']:
@ -1345,3 +1360,18 @@ class Trade(_DECL_BASE, LocalTrade):
.group_by(Trade.pair) \ .group_by(Trade.pair) \
.order_by(desc('profit_sum')).first() .order_by(desc('profit_sum')).first()
return best_pair return best_pair
@staticmethod
def get_trading_volume(start_date: datetime = datetime.fromtimestamp(0)) -> float:
"""
Get Trade volume based on Orders
NOTE: Not supported in Backtesting.
:returns: Tuple containing (pair, profit_sum)
"""
trading_volume = Order.query.with_entities(
func.sum(Order.cost).label('volume')
).filter(
Order.order_filled_date >= start_date,
Order.status == 'closed'
).scalar()
return trading_volume

View File

@ -633,7 +633,7 @@ 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() 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']

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

@ -47,26 +47,7 @@ class StrategyResolver(IResolver):
strategy: IStrategy = StrategyResolver._load_strategy( strategy: IStrategy = StrategyResolver._load_strategy(
strategy_name, config=config, strategy_name, config=config,
extra_dir=config.get('strategy_path')) extra_dir=config.get('strategy_path'))
strategy.ft_load_params_from_file()
if strategy._ft_params_from_file:
# Set parameters from Hyperopt results file
params = strategy._ft_params_from_file
strategy.minimal_roi = params.get('roi', getattr(strategy, 'minimal_roi', {}))
strategy.stoploss = params.get('stoploss', {}).get(
'stoploss', getattr(strategy, 'stoploss', -0.1))
trailing = params.get('trailing', {})
strategy.trailing_stop = trailing.get(
'trailing_stop', getattr(strategy, 'trailing_stop', False))
strategy.trailing_stop_positive = trailing.get(
'trailing_stop_positive', getattr(strategy, 'trailing_stop_positive', None))
strategy.trailing_stop_positive_offset = trailing.get(
'trailing_stop_positive_offset',
getattr(strategy, 'trailing_stop_positive_offset', 0))
strategy.trailing_only_offset_is_reached = trailing.get(
'trailing_only_offset_is_reached',
getattr(strategy, 'trailing_only_offset_is_reached', 0.0))
# Set attributes # Set attributes
# Check if we need to override configuration # Check if we need to override configuration
# (Attribute name, default, subkey) # (Attribute name, default, subkey)

View File

@ -1,6 +1,7 @@
import asyncio import asyncio
import logging import logging
from copy import deepcopy from copy import deepcopy
from datetime import datetime
from typing import Any, Dict, List from typing import Any, Dict, List
from fastapi import APIRouter, BackgroundTasks, Depends from fastapi import APIRouter, BackgroundTasks, Depends
@ -102,7 +103,10 @@ async def api_start_backtest(bt_settings: BacktestRequest, background_tasks: Bac
min_date=min_date, max_date=max_date) min_date=min_date, max_date=max_date)
if btconfig.get('export', 'none') == 'trades': if btconfig.get('export', 'none') == 'trades':
store_backtest_stats(btconfig['exportfilename'], ApiServer._bt.results) store_backtest_stats(
btconfig['exportfilename'], ApiServer._bt.results,
datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
)
logger.info("Backtest finished.") logger.info("Backtest finished.")

View File

@ -104,6 +104,10 @@ class Profit(BaseModel):
best_pair_profit_ratio: float best_pair_profit_ratio: float
winning_trades: int winning_trades: int
losing_trades: int losing_trades: int
profit_factor: float
max_drawdown: float
max_drawdown_abs: float
trading_volume: Optional[float]
class SellReason(BaseModel): class SellReason(BaseModel):
@ -120,6 +124,8 @@ class Stats(BaseModel):
class DailyRecord(BaseModel): class DailyRecord(BaseModel):
date: date date: date
abs_profit: float abs_profit: float
rel_profit: float
starting_balance: float
fiat_value: float fiat_value: float
trade_count: int trade_count: int
@ -166,7 +172,7 @@ class ShowConfig(BaseModel):
trailing_stop_positive: Optional[float] trailing_stop_positive: Optional[float]
trailing_stop_positive_offset: Optional[float] trailing_stop_positive_offset: Optional[float]
trailing_only_offset_is_reached: Optional[bool] trailing_only_offset_is_reached: Optional[bool]
unfilledtimeout: UnfilledTimeout unfilledtimeout: Optional[UnfilledTimeout] # Empty in webserver mode
order_types: Optional[OrderTypes] order_types: Optional[OrderTypes]
use_custom_stoploss: Optional[bool] use_custom_stoploss: Optional[bool]
timeframe: Optional[str] timeframe: Optional[str]
@ -256,6 +262,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]
@ -276,6 +283,7 @@ class OpenTradeSchema(TradeSchema):
class TradeResponse(BaseModel): class TradeResponse(BaseModel):
trades: List[TradeSchema] trades: List[TradeSchema]
trades_count: int trades_count: int
offset: int
total_trades: int total_trades: int

View File

@ -36,7 +36,8 @@ logger = logging.getLogger(__name__)
# versions 2.xx -> futures/short branch # versions 2.xx -> futures/short branch
# 2.14: Add entry/exit orders to trade response # 2.14: Add entry/exit orders to trade response
# 2.15: Add backtest history endpoints # 2.15: Add backtest history endpoints
API_VERSION = 2.15 # 2.16: Additional daily metrics
API_VERSION = 2.16
# Public API, requires no auth. # Public API, requires no auth.
router_public = APIRouter() router_public = APIRouter()
@ -86,7 +87,7 @@ def stats(rpc: RPC = Depends(get_rpc)):
@router.get('/daily', response_model=Daily, tags=['info']) @router.get('/daily', response_model=Daily, tags=['info'])
def daily(timescale: int = 7, rpc: RPC = Depends(get_rpc), config=Depends(get_config)): def daily(timescale: int = 7, rpc: RPC = Depends(get_rpc), config=Depends(get_config)):
return rpc._rpc_daily_profit(timescale, config['stake_currency'], return rpc._rpc_timeunit_profit(timescale, config['stake_currency'],
config.get('fiat_display_currency', '')) config.get('fiat_display_currency', ''))

59
freqtrade/rpc/discord.py Normal file
View File

@ -0,0 +1,59 @@
import logging
from typing import Any, Dict
from freqtrade.enums.rpcmessagetype import RPCMessageType
from freqtrade.rpc import RPC
from freqtrade.rpc.webhook import Webhook
logger = logging.getLogger(__name__)
class Discord(Webhook):
def __init__(self, rpc: 'RPC', config: Dict[str, Any]):
# super().__init__(rpc, config)
self.rpc = rpc
self.config = config
self.strategy = config.get('strategy', '')
self.timeframe = config.get('timeframe', '')
self._url = self.config['discord']['webhook_url']
self._format = 'json'
self._retries = 1
self._retry_delay = 0.1
def cleanup(self) -> None:
"""
Cleanup pending module resources.
This will do nothing for webhooks, they will simply not be called anymore
"""
pass
def send_msg(self, msg) -> None:
logger.info(f"Sending discord message: {msg}")
if msg['type'].value in self.config['discord']:
msg['strategy'] = self.strategy
msg['timeframe'] = self.timeframe
fields = self.config['discord'].get(msg['type'].value)
color = 0x0000FF
if msg['type'] in (RPCMessageType.EXIT, RPCMessageType.EXIT_FILL):
profit_ratio = msg.get('profit_ratio')
color = (0x00FF00 if profit_ratio > 0 else 0xFF0000)
embeds = [{
'title': f"Trade: {msg['pair']} {msg['type'].value}",
'color': color,
'fields': [],
}]
for f in fields:
for k, v in f.items():
v = v.format(**msg)
embeds[0]['fields'].append( # type: ignore
{'name': k, 'value': v, 'inline': True})
# Send the message to discord channel
payload = {'embeds': embeds}
self._send_msg(payload)

View File

@ -18,6 +18,7 @@ from freqtrade import __version__
from freqtrade.configuration.timerange import TimeRange from freqtrade.configuration.timerange import TimeRange
from freqtrade.constants import CANCEL_REASON, DATETIME_PRINT_FORMAT from freqtrade.constants import CANCEL_REASON, DATETIME_PRINT_FORMAT
from freqtrade.data.history import load_data from freqtrade.data.history import load_data
from freqtrade.data.metrics import calculate_max_drawdown
from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, SignalDirection, State, from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, SignalDirection, State,
TradingMode) TradingMode)
from freqtrade.exceptions import ExchangeError, PricingError from freqtrade.exceptions import ExchangeError, PricingError
@ -283,33 +284,57 @@ class RPC:
columns.append('# Entries') columns.append('# Entries')
return trades_list, columns, fiat_profit_sum return trades_list, columns, fiat_profit_sum
def _rpc_daily_profit( def _rpc_timeunit_profit(
self, timescale: int, self, timescale: int,
stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]: stake_currency: str, fiat_display_currency: str,
today = datetime.now(timezone.utc).date() timeunit: str = 'days') -> Dict[str, Any]:
profit_days: Dict[date, Dict] = {} """
:param timeunit: Valid entries are 'days', 'weeks', 'months'
"""
start_date = datetime.now(timezone.utc).date()
if timeunit == 'weeks':
# weekly
start_date = start_date - timedelta(days=start_date.weekday()) # Monday
if timeunit == 'months':
start_date = start_date.replace(day=1)
def time_offset(step: int):
if timeunit == 'months':
return relativedelta(months=step)
return timedelta(**{timeunit: step})
if not (isinstance(timescale, int) and timescale > 0): if not (isinstance(timescale, int) and timescale > 0):
raise RPCException('timescale must be an integer greater than 0') raise RPCException('timescale must be an integer greater than 0')
profit_units: Dict[date, Dict] = {}
daily_stake = self._freqtrade.wallets.get_total_stake_amount()
for day in range(0, timescale): for day in range(0, timescale):
profitday = today - timedelta(days=day) profitday = start_date - time_offset(day)
trades = Trade.get_trades(trade_filter=[ # Only query for necessary columns for performance reasons.
trades = Trade.query.session.query(Trade.close_profit_abs).filter(
Trade.is_open.is_(False), Trade.is_open.is_(False),
Trade.close_date >= profitday, Trade.close_date >= profitday,
Trade.close_date < (profitday + timedelta(days=1)) Trade.close_date < (profitday + time_offset(1))
]).order_by(Trade.close_date).all() ).order_by(Trade.close_date).all()
curdayprofit = sum( curdayprofit = sum(
trade.close_profit_abs for trade in trades if trade.close_profit_abs is not None) trade.close_profit_abs for trade in trades if trade.close_profit_abs is not None)
profit_days[profitday] = { # Calculate this periods starting balance
daily_stake = daily_stake - curdayprofit
profit_units[profitday] = {
'amount': curdayprofit, 'amount': curdayprofit,
'trades': len(trades) 'daily_stake': daily_stake,
'rel_profit': round(curdayprofit / daily_stake, 8) if daily_stake > 0 else 0,
'trades': len(trades),
} }
data = [ data = [
{ {
'date': key, 'date': f"{key.year}-{key.month:02d}" if timeunit == 'months' else key,
'abs_profit': value["amount"], 'abs_profit': value["amount"],
'starting_balance': value["daily_stake"],
'rel_profit': value["rel_profit"],
'fiat_value': self._fiat_converter.convert_amount( 'fiat_value': self._fiat_converter.convert_amount(
value['amount'], value['amount'],
stake_currency, stake_currency,
@ -317,92 +342,7 @@ class RPC:
) if self._fiat_converter else 0, ) if self._fiat_converter else 0,
'trade_count': value["trades"], 'trade_count': value["trades"],
} }
for key, value in profit_days.items() for key, value in profit_units.items()
]
return {
'stake_currency': stake_currency,
'fiat_display_currency': fiat_display_currency,
'data': data
}
def _rpc_weekly_profit(
self, timescale: int,
stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]:
today = datetime.now(timezone.utc).date()
first_iso_day_of_week = today - timedelta(days=today.weekday()) # Monday
profit_weeks: Dict[date, Dict] = {}
if not (isinstance(timescale, int) and timescale > 0):
raise RPCException('timescale must be an integer greater than 0')
for week in range(0, timescale):
profitweek = first_iso_day_of_week - timedelta(weeks=week)
trades = Trade.get_trades(trade_filter=[
Trade.is_open.is_(False),
Trade.close_date >= profitweek,
Trade.close_date < (profitweek + timedelta(weeks=1))
]).order_by(Trade.close_date).all()
curweekprofit = sum(
trade.close_profit_abs for trade in trades if trade.close_profit_abs is not None)
profit_weeks[profitweek] = {
'amount': curweekprofit,
'trades': len(trades)
}
data = [
{
'date': key,
'abs_profit': value["amount"],
'fiat_value': self._fiat_converter.convert_amount(
value['amount'],
stake_currency,
fiat_display_currency
) if self._fiat_converter else 0,
'trade_count': value["trades"],
}
for key, value in profit_weeks.items()
]
return {
'stake_currency': stake_currency,
'fiat_display_currency': fiat_display_currency,
'data': data
}
def _rpc_monthly_profit(
self, timescale: int,
stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]:
first_day_of_month = datetime.now(timezone.utc).date().replace(day=1)
profit_months: Dict[date, Dict] = {}
if not (isinstance(timescale, int) and timescale > 0):
raise RPCException('timescale must be an integer greater than 0')
for month in range(0, timescale):
profitmonth = first_day_of_month - relativedelta(months=month)
trades = Trade.get_trades(trade_filter=[
Trade.is_open.is_(False),
Trade.close_date >= profitmonth,
Trade.close_date < (profitmonth + relativedelta(months=1))
]).order_by(Trade.close_date).all()
curmonthprofit = sum(
trade.close_profit_abs for trade in trades if trade.close_profit_abs is not None)
profit_months[profitmonth] = {
'amount': curmonthprofit,
'trades': len(trades)
}
data = [
{
'date': f"{key.year}-{key.month:02d}",
'abs_profit': value["amount"],
'fiat_value': self._fiat_converter.convert_amount(
value['amount'],
stake_currency,
fiat_display_currency
) if self._fiat_converter else 0,
'trade_count': value["trades"],
}
for key, value in profit_months.items()
] ]
return { return {
'stake_currency': stake_currency, 'stake_currency': stake_currency,
@ -425,6 +365,7 @@ class RPC:
return { return {
"trades": output, "trades": output,
"trades_count": len(output), "trades_count": len(output),
"offset": offset,
"total_trades": Trade.get_trades([Trade.is_open.is_(False)]).count(), "total_trades": Trade.get_trades([Trade.is_open.is_(False)]).count(),
} }
@ -439,7 +380,7 @@ class RPC:
return 'losses' return 'losses'
else: else:
return 'draws' return 'draws'
trades: List[Trade] = Trade.get_trades([Trade.is_open.is_(False)]) trades: List[Trade] = Trade.get_trades([Trade.is_open.is_(False)], include_orders=False)
# Sell reason # Sell reason
exit_reasons = {} exit_reasons = {}
for trade in trades: for trade in trades:
@ -467,7 +408,8 @@ class RPC:
""" Returns cumulative profit statistics """ """ Returns cumulative profit statistics """
trade_filter = ((Trade.is_open.is_(False) & (Trade.close_date >= start_date)) | trade_filter = ((Trade.is_open.is_(False) & (Trade.close_date >= start_date)) |
Trade.is_open.is_(True)) Trade.is_open.is_(True))
trades: List[Trade] = Trade.get_trades(trade_filter).order_by(Trade.id).all() trades: List[Trade] = Trade.get_trades(
trade_filter, include_orders=False).order_by(Trade.id).all()
profit_all_coin = [] profit_all_coin = []
profit_all_ratio = [] profit_all_ratio = []
@ -476,6 +418,8 @@ class RPC:
durations = [] durations = []
winning_trades = 0 winning_trades = 0
losing_trades = 0 losing_trades = 0
winning_profit = 0.0
losing_profit = 0.0
for trade in trades: for trade in trades:
current_rate: float = 0.0 current_rate: float = 0.0
@ -491,8 +435,10 @@ class RPC:
profit_closed_ratio.append(profit_ratio) profit_closed_ratio.append(profit_ratio)
if trade.close_profit >= 0: if trade.close_profit >= 0:
winning_trades += 1 winning_trades += 1
winning_profit += trade.close_profit_abs
else: else:
losing_trades += 1 losing_trades += 1
losing_profit += trade.close_profit_abs
else: else:
# Get current rate # Get current rate
try: try:
@ -508,6 +454,7 @@ class RPC:
profit_all_ratio.append(profit_ratio) profit_all_ratio.append(profit_ratio)
best_pair = Trade.get_best_pair(start_date) best_pair = Trade.get_best_pair(start_date)
trading_volume = Trade.get_trading_volume(start_date)
# Prepare data to display # Prepare data to display
profit_closed_coin_sum = round(sum(profit_closed_coin), 8) profit_closed_coin_sum = round(sum(profit_closed_coin), 8)
@ -531,6 +478,21 @@ class RPC:
profit_closed_ratio_fromstart = profit_closed_coin_sum / starting_balance profit_closed_ratio_fromstart = profit_closed_coin_sum / starting_balance
profit_all_ratio_fromstart = profit_all_coin_sum / starting_balance profit_all_ratio_fromstart = profit_all_coin_sum / starting_balance
profit_factor = winning_profit / abs(losing_profit) if losing_profit else float('inf')
trades_df = DataFrame([{'close_date': trade.close_date.strftime(DATETIME_PRINT_FORMAT),
'profit_abs': trade.close_profit_abs}
for trade in trades if not trade.is_open])
max_drawdown_abs = 0.0
max_drawdown = 0.0
if len(trades_df) > 0:
try:
(max_drawdown_abs, _, _, _, _, max_drawdown) = calculate_max_drawdown(
trades_df, value_col='profit_abs', starting_balance=starting_balance)
except ValueError:
# ValueError if no losing trade.
pass
profit_all_fiat = self._fiat_converter.convert_amount( profit_all_fiat = self._fiat_converter.convert_amount(
profit_all_coin_sum, profit_all_coin_sum,
stake_currency, stake_currency,
@ -569,11 +531,15 @@ class RPC:
'best_pair_profit_ratio': best_pair[1] if best_pair else 0, 'best_pair_profit_ratio': best_pair[1] if best_pair else 0,
'winning_trades': winning_trades, 'winning_trades': winning_trades,
'losing_trades': losing_trades, 'losing_trades': losing_trades,
'profit_factor': profit_factor,
'max_drawdown': max_drawdown,
'max_drawdown_abs': max_drawdown_abs,
'trading_volume': trading_volume,
} }
def _rpc_balance(self, stake_currency: str, fiat_display_currency: str) -> Dict: def _rpc_balance(self, stake_currency: str, fiat_display_currency: str) -> Dict:
""" Returns current account balance per crypto """ """ Returns current account balance per crypto """
currencies = [] currencies: List[Dict] = []
total = 0.0 total = 0.0
try: try:
tickers = self._freqtrade.exchange.get_tickers(cached=True) tickers = self._freqtrade.exchange.get_tickers(cached=True)
@ -608,13 +574,12 @@ class RPC:
except (ExchangeError): except (ExchangeError):
logger.warning(f" Could not get rate for pair {coin}.") logger.warning(f" Could not get rate for pair {coin}.")
continue continue
total = total + (est_stake or 0) total = total + est_stake
currencies.append({ currencies.append({
'currency': coin, 'currency': coin,
# TODO: The below can be simplified if we don't assign None to values. 'free': balance.free,
'free': balance.free if balance.free is not None else 0, 'balance': balance.total,
'balance': balance.total if balance.total is not None else 0, 'used': balance.used,
'used': balance.used if balance.used is not None else 0,
'est_stake': est_stake or 0, 'est_stake': est_stake or 0,
'stake': stake_currency, 'stake': stake_currency,
'side': 'long', 'side': 'long',
@ -644,7 +609,6 @@ class RPC:
total, stake_currency, fiat_display_currency) if self._fiat_converter else 0 total, stake_currency, fiat_display_currency) if self._fiat_converter else 0
trade_count = len(Trade.get_trades_proxy()) trade_count = len(Trade.get_trades_proxy())
starting_capital_ratio = 0.0
starting_capital_ratio = (total / starting_capital) - 1 if starting_capital else 0.0 starting_capital_ratio = (total / starting_capital) - 1 if starting_capital else 0.0
starting_cap_fiat_ratio = (value / starting_cap_fiat) - 1 if starting_cap_fiat else 0.0 starting_cap_fiat_ratio = (value / starting_cap_fiat) - 1 if starting_cap_fiat else 0.0

View File

@ -27,6 +27,12 @@ class RPCManager:
from freqtrade.rpc.telegram import Telegram from freqtrade.rpc.telegram import Telegram
self.registered_modules.append(Telegram(self._rpc, config)) self.registered_modules.append(Telegram(self._rpc, config))
# Enable discord
if config.get('discord', {}).get('enabled', False):
logger.info('Enabling rpc.discord ...')
from freqtrade.rpc.discord import Discord
self.registered_modules.append(Discord(self._rpc, config))
# Enable Webhook # Enable Webhook
if config.get('webhook', {}).get('enabled', False): if config.get('webhook', {}).get('enabled', False):
logger.info('Enabling rpc.webhook ...') logger.info('Enabling rpc.webhook ...')

View File

@ -6,6 +6,7 @@ This module manage Telegram communication
import json import json
import logging import logging
import re import re
from dataclasses import dataclass
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from functools import partial from functools import partial
from html import escape from html import escape
@ -37,6 +38,15 @@ logger.debug('Included module rpc.telegram ...')
MAX_TELEGRAM_MESSAGE_LENGTH = 4096 MAX_TELEGRAM_MESSAGE_LENGTH = 4096
@dataclass
class TimeunitMappings:
header: str
message: str
message2: str
callback: str
default: int
def authorized_only(command_handler: Callable[..., None]) -> Callable[..., Any]: def authorized_only(command_handler: Callable[..., None]) -> Callable[..., Any]:
""" """
Decorator to check if the message comes from the correct chat_id Decorator to check if the message comes from the correct chat_id
@ -225,6 +235,14 @@ class Telegram(RPCHandler):
# This can take up to `timeout` from the call to `start_polling`. # This can take up to `timeout` from the call to `start_polling`.
self._updater.stop() self._updater.stop()
def _exchange_from_msg(self, msg: Dict[str, Any]) -> str:
"""
Extracts the exchange name from the given message.
:param msg: The message to extract the exchange name from.
:return: The exchange name.
"""
return f"{msg['exchange']}{' (dry)' if self._config['dry_run'] else ''}"
def _format_entry_msg(self, msg: Dict[str, Any]) -> str: def _format_entry_msg(self, msg: Dict[str, Any]) -> str:
if self._rpc._fiat_converter: if self._rpc._fiat_converter:
msg['stake_amount_fiat'] = self._rpc._fiat_converter.convert_amount( msg['stake_amount_fiat'] = self._rpc._fiat_converter.convert_amount(
@ -237,7 +255,7 @@ class Telegram(RPCHandler):
entry_side = ({'enter': 'Long', 'entered': 'Longed'} if msg['direction'] == 'Long' entry_side = ({'enter': 'Long', 'entered': 'Longed'} if msg['direction'] == 'Long'
else {'enter': 'Short', 'entered': 'Shorted'}) else {'enter': 'Short', 'entered': 'Shorted'})
message = ( message = (
f"{emoji} *{msg['exchange']}:*" f"{emoji} *{self._exchange_from_msg(msg)}:*"
f" {entry_side['entered'] if is_fill else entry_side['enter']} {msg['pair']}" f" {entry_side['entered'] if is_fill else entry_side['enter']} {msg['pair']}"
f" (#{msg['trade_id']})\n" f" (#{msg['trade_id']})\n"
) )
@ -286,7 +304,7 @@ class Telegram(RPCHandler):
msg['profit_extra'] = '' msg['profit_extra'] = ''
is_fill = msg['type'] == RPCMessageType.EXIT_FILL is_fill = msg['type'] == RPCMessageType.EXIT_FILL
message = ( message = (
f"{msg['emoji']} *{msg['exchange']}:* " f"{msg['emoji']} *{self._exchange_from_msg(msg)}:* "
f"{'Exited' if is_fill else 'Exiting'} {msg['pair']} (#{msg['trade_id']})\n" f"{'Exited' if is_fill else 'Exiting'} {msg['pair']} (#{msg['trade_id']})\n"
f"*{'Profit' if is_fill else 'Unrealized Profit'}:* " f"*{'Profit' if is_fill else 'Unrealized Profit'}:* "
f"`{msg['profit_ratio']:.2%}{msg['profit_extra']}`\n" f"`{msg['profit_ratio']:.2%}{msg['profit_extra']}`\n"
@ -316,33 +334,33 @@ class Telegram(RPCHandler):
elif msg_type in (RPCMessageType.ENTRY_CANCEL, RPCMessageType.EXIT_CANCEL): elif msg_type in (RPCMessageType.ENTRY_CANCEL, RPCMessageType.EXIT_CANCEL):
msg['message_side'] = 'enter' if msg_type in [RPCMessageType.ENTRY_CANCEL] else 'exit' msg['message_side'] = 'enter' if msg_type in [RPCMessageType.ENTRY_CANCEL] else 'exit'
message = ("\N{WARNING SIGN} *{exchange}:* " message = (f"\N{WARNING SIGN} *{self._exchange_from_msg(msg)}:* "
"Cancelling {message_side} Order for {pair} (#{trade_id}). " f"Cancelling {msg['message_side']} Order for {msg['pair']} "
"Reason: {reason}.".format(**msg)) f"(#{msg['trade_id']}). Reason: {msg['reason']}.")
elif msg_type == RPCMessageType.PROTECTION_TRIGGER: elif msg_type == RPCMessageType.PROTECTION_TRIGGER:
message = ( message = (
"*Protection* triggered due to {reason}. " f"*Protection* triggered due to {msg['reason']}. "
"`{pair}` will be locked until `{lock_end_time}`." f"`{msg['pair']}` will be locked until `{msg['lock_end_time']}`."
).format(**msg) )
elif msg_type == RPCMessageType.PROTECTION_TRIGGER_GLOBAL: elif msg_type == RPCMessageType.PROTECTION_TRIGGER_GLOBAL:
message = ( message = (
"*Protection* triggered due to {reason}. " f"*Protection* triggered due to {msg['reason']}. "
"*All pairs* will be locked until `{lock_end_time}`." f"*All pairs* will be locked until `{msg['lock_end_time']}`."
).format(**msg) )
elif msg_type == RPCMessageType.STATUS: elif msg_type == RPCMessageType.STATUS:
message = '*Status:* `{status}`'.format(**msg) message = f"*Status:* `{msg['status']}`"
elif msg_type == RPCMessageType.WARNING: elif msg_type == RPCMessageType.WARNING:
message = '\N{WARNING SIGN} *Warning:* `{status}`'.format(**msg) message = f"\N{WARNING SIGN} *Warning:* `{msg['status']}`"
elif msg_type == RPCMessageType.STARTUP: elif msg_type == RPCMessageType.STARTUP:
message = '{status}'.format(**msg) message = f"{msg['status']}"
else: else:
raise NotImplementedError('Unknown message type: {}'.format(msg_type)) raise NotImplementedError(f"Unknown message type: {msg_type}")
return message return message
def send_msg(self, msg: Dict[str, Any]) -> None: def send_msg(self, msg: Dict[str, Any]) -> None:
@ -396,7 +414,7 @@ class Telegram(RPCHandler):
first_avg = filled_orders[0]["safe_price"] first_avg = filled_orders[0]["safe_price"]
for x, order in enumerate(filled_orders): for x, order in enumerate(filled_orders):
if not order['ft_is_entry']: if not order['ft_is_entry'] or order['is_open'] is True:
continue continue
cur_entry_datetime = arrow.get(order["order_filled_date"]) cur_entry_datetime = arrow.get(order["order_filled_date"])
cur_entry_amount = order["amount"] cur_entry_amount = order["amount"]
@ -563,6 +581,60 @@ class Telegram(RPCHandler):
except RPCException as e: except RPCException as e:
self._send_msg(str(e)) self._send_msg(str(e))
@authorized_only
def _timeunit_stats(self, update: Update, context: CallbackContext, unit: str) -> None:
"""
Handler for /daily <n>
Returns a daily profit (in BTC) over the last n days.
:param bot: telegram bot
:param update: message update
:return: None
"""
vals = {
'days': TimeunitMappings('Day', 'Daily', 'days', 'update_daily', 7),
'weeks': TimeunitMappings('Monday', 'Weekly', 'weeks (starting from Monday)',
'update_weekly', 8),
'months': TimeunitMappings('Month', 'Monthly', 'months', 'update_monthly', 6),
}
val = vals[unit]
stake_cur = self._config['stake_currency']
fiat_disp_cur = self._config.get('fiat_display_currency', '')
try:
timescale = int(context.args[0]) if context.args else val.default
except (TypeError, ValueError, IndexError):
timescale = val.default
try:
stats = self._rpc._rpc_timeunit_profit(
timescale,
stake_cur,
fiat_disp_cur,
unit
)
stats_tab = tabulate(
[[f"{period['date']} ({period['trade_count']})",
f"{round_coin_value(period['abs_profit'], stats['stake_currency'])}",
f"{period['fiat_value']:.2f} {stats['fiat_display_currency']}",
f"{period['rel_profit']:.2%}",
] for period in stats['data']],
headers=[
f"{val.header} (count)",
f'{stake_cur}',
f'{fiat_disp_cur}',
'Profit %',
'Trades',
],
tablefmt='simple')
message = (
f'<b>{val.message} Profit over the last {timescale} {val.message2}</b>:\n'
f'<pre>{stats_tab}</pre>'
)
self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True,
callback_path=val.callback, query=update.callback_query)
except RPCException as e:
self._send_msg(str(e))
@authorized_only @authorized_only
def _daily(self, update: Update, context: CallbackContext) -> None: def _daily(self, update: Update, context: CallbackContext) -> None:
""" """
@ -572,35 +644,7 @@ class Telegram(RPCHandler):
:param update: message update :param update: message update
:return: None :return: None
""" """
stake_cur = self._config['stake_currency'] self._timeunit_stats(update, context, 'days')
fiat_disp_cur = self._config.get('fiat_display_currency', '')
try:
timescale = int(context.args[0]) if context.args else 7
except (TypeError, ValueError, IndexError):
timescale = 7
try:
stats = self._rpc._rpc_daily_profit(
timescale,
stake_cur,
fiat_disp_cur
)
stats_tab = tabulate(
[[day['date'],
f"{round_coin_value(day['abs_profit'], stats['stake_currency'])}",
f"{day['fiat_value']:.3f} {stats['fiat_display_currency']}",
f"{day['trade_count']} trades"] for day in stats['data']],
headers=[
'Day',
f'Profit {stake_cur}',
f'Profit {fiat_disp_cur}',
'Trades',
],
tablefmt='simple')
message = f'<b>Daily Profit over the last {timescale} days</b>:\n<pre>{stats_tab}</pre>'
self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True,
callback_path="update_daily", query=update.callback_query)
except RPCException as e:
self._send_msg(str(e))
@authorized_only @authorized_only
def _weekly(self, update: Update, context: CallbackContext) -> None: def _weekly(self, update: Update, context: CallbackContext) -> None:
@ -611,36 +655,7 @@ class Telegram(RPCHandler):
:param update: message update :param update: message update
:return: None :return: None
""" """
stake_cur = self._config['stake_currency'] self._timeunit_stats(update, context, 'weeks')
fiat_disp_cur = self._config.get('fiat_display_currency', '')
try:
timescale = int(context.args[0]) if context.args else 8
except (TypeError, ValueError, IndexError):
timescale = 8
try:
stats = self._rpc._rpc_weekly_profit(
timescale,
stake_cur,
fiat_disp_cur
)
stats_tab = tabulate(
[[week['date'],
f"{round_coin_value(week['abs_profit'], stats['stake_currency'])}",
f"{week['fiat_value']:.3f} {stats['fiat_display_currency']}",
f"{week['trade_count']} trades"] for week in stats['data']],
headers=[
'Monday',
f'Profit {stake_cur}',
f'Profit {fiat_disp_cur}',
'Trades',
],
tablefmt='simple')
message = f'<b>Weekly Profit over the last {timescale} weeks ' \
f'(starting from Monday)</b>:\n<pre>{stats_tab}</pre> '
self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True,
callback_path="update_weekly", query=update.callback_query)
except RPCException as e:
self._send_msg(str(e))
@authorized_only @authorized_only
def _monthly(self, update: Update, context: CallbackContext) -> None: def _monthly(self, update: Update, context: CallbackContext) -> None:
@ -651,36 +666,7 @@ class Telegram(RPCHandler):
:param update: message update :param update: message update
:return: None :return: None
""" """
stake_cur = self._config['stake_currency'] self._timeunit_stats(update, context, 'months')
fiat_disp_cur = self._config.get('fiat_display_currency', '')
try:
timescale = int(context.args[0]) if context.args else 6
except (TypeError, ValueError, IndexError):
timescale = 6
try:
stats = self._rpc._rpc_monthly_profit(
timescale,
stake_cur,
fiat_disp_cur
)
stats_tab = tabulate(
[[month['date'],
f"{round_coin_value(month['abs_profit'], stats['stake_currency'])}",
f"{month['fiat_value']:.3f} {stats['fiat_display_currency']}",
f"{month['trade_count']} trades"] for month in stats['data']],
headers=[
'Month',
f'Profit {stake_cur}',
f'Profit {fiat_disp_cur}',
'Trades',
],
tablefmt='simple')
message = f'<b>Monthly Profit over the last {timescale} months' \
f'</b>:\n<pre>{stats_tab}</pre> '
self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True,
callback_path="update_monthly", query=update.callback_query)
except RPCException as e:
self._send_msg(str(e))
@authorized_only @authorized_only
def _profit(self, update: Update, context: CallbackContext) -> None: def _profit(self, update: Update, context: CallbackContext) -> None:
@ -744,12 +730,18 @@ class Telegram(RPCHandler):
f"*Total Trade Count:* `{trade_count}`\n" f"*Total Trade Count:* `{trade_count}`\n"
f"*{'First Trade opened' if not timescale else 'Showing Profit since'}:* " f"*{'First Trade opened' if not timescale else 'Showing Profit since'}:* "
f"`{first_trade_date}`\n" f"`{first_trade_date}`\n"
f"*Latest Trade opened:* `{latest_trade_date}\n`" f"*Latest Trade opened:* `{latest_trade_date}`\n"
f"*Win / Loss:* `{stats['winning_trades']} / {stats['losing_trades']}`" f"*Win / Loss:* `{stats['winning_trades']} / {stats['losing_trades']}`"
) )
if stats['closed_trade_count'] > 0: if stats['closed_trade_count'] > 0:
markdown_msg += (f"\n*Avg. Duration:* `{avg_duration}`\n" markdown_msg += (
f"*Best Performing:* `{best_pair}: {best_pair_profit_ratio:.2%}`") f"\n*Avg. Duration:* `{avg_duration}`\n"
f"*Best Performing:* `{best_pair}: {best_pair_profit_ratio:.2%}`\n"
f"*Trading volume:* `{round_coin_value(stats['trading_volume'], stake_cur)}`\n"
f"*Profit factor:* `{stats['profit_factor']:.2f}`\n"
f"*Max Drawdown:* `{stats['max_drawdown']:.2%} "
f"({round_coin_value(stats['max_drawdown_abs'], stake_cur)})`"
)
self._send_msg(markdown_msg, reload_able=True, callback_path="update_profit", self._send_msg(markdown_msg, reload_able=True, callback_path="update_profit",
query=update.callback_query) query=update.callback_query)
@ -785,7 +777,7 @@ class Telegram(RPCHandler):
headers=['Exit Reason', 'Exits', 'Wins', 'Losses'] headers=['Exit Reason', 'Exits', 'Wins', 'Losses']
) )
if len(exit_reasons_tabulate) > 25: if len(exit_reasons_tabulate) > 25:
self._send_msg(exit_reasons_msg, ParseMode.MARKDOWN) self._send_msg(f"```\n{exit_reasons_msg}```", ParseMode.MARKDOWN)
exit_reasons_msg = '' exit_reasons_msg = ''
durations = stats['durations'] durations = stats['durations']
@ -889,7 +881,7 @@ class Telegram(RPCHandler):
:return: None :return: None
""" """
msg = self._rpc._rpc_start() msg = self._rpc._rpc_start()
self._send_msg('Status: `{status}`'.format(**msg)) self._send_msg(f"Status: `{msg['status']}`")
@authorized_only @authorized_only
def _stop(self, update: Update, context: CallbackContext) -> None: def _stop(self, update: Update, context: CallbackContext) -> None:
@ -901,7 +893,7 @@ class Telegram(RPCHandler):
:return: None :return: None
""" """
msg = self._rpc._rpc_stop() msg = self._rpc._rpc_stop()
self._send_msg('Status: `{status}`'.format(**msg)) self._send_msg(f"Status: `{msg['status']}`")
@authorized_only @authorized_only
def _reload_config(self, update: Update, context: CallbackContext) -> None: def _reload_config(self, update: Update, context: CallbackContext) -> None:
@ -913,7 +905,7 @@ class Telegram(RPCHandler):
:return: None :return: None
""" """
msg = self._rpc._rpc_reload_config() msg = self._rpc._rpc_reload_config()
self._send_msg('Status: `{status}`'.format(**msg)) self._send_msg(f"Status: `{msg['status']}`")
@authorized_only @authorized_only
def _stopbuy(self, update: Update, context: CallbackContext) -> None: def _stopbuy(self, update: Update, context: CallbackContext) -> None:
@ -925,7 +917,7 @@ class Telegram(RPCHandler):
:return: None :return: None
""" """
msg = self._rpc._rpc_stopbuy() msg = self._rpc._rpc_stopbuy()
self._send_msg('Status: `{status}`'.format(**msg)) self._send_msg(f"Status: `{msg['status']}`")
@authorized_only @authorized_only
def _force_exit(self, update: Update, context: CallbackContext) -> None: def _force_exit(self, update: Update, context: CallbackContext) -> None:
@ -1087,9 +1079,9 @@ class Telegram(RPCHandler):
trade_id = int(context.args[0]) trade_id = int(context.args[0])
msg = self._rpc._rpc_delete(trade_id) msg = self._rpc._rpc_delete(trade_id)
self._send_msg(( self._send_msg((
'`{result_msg}`\n' f"`{msg['result_msg']}`\n"
'Please make sure to take care of this asset on the exchange manually.' 'Please make sure to take care of this asset on the exchange manually.'
).format(**msg)) ))
except RPCException as e: except RPCException as e:
self._send_msg(str(e)) self._send_msg(str(e))
@ -1417,7 +1409,7 @@ class Telegram(RPCHandler):
"*/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`\n" "*/fx <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,18 @@ 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, Type, Union
from freqtrade.exceptions import OperationalException
from freqtrade.misc import deep_merge_dicts, json_load from freqtrade.misc import deep_merge_dicts, json_load
from freqtrade.optimize.hyperopt_tools import HyperoptTools from freqtrade.optimize.hyperopt_tools import HyperoptTools
from freqtrade.strategy.parameters import BaseParameter
with suppress(ImportError):
from skopt.space import Integer, Real, Categorical
from freqtrade.optimize.space import SKDecimal
from freqtrade.enums import RunMode
from freqtrade.exceptions import OperationalException
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
@ -307,7 +30,10 @@ class HyperStrategyMixin:
self.ft_sell_params: List[BaseParameter] = [] self.ft_sell_params: List[BaseParameter] = []
self.ft_protection_params: List[BaseParameter] = [] self.ft_protection_params: List[BaseParameter] = []
self._load_hyper_params(config.get('runmode') == RunMode.HYPEROPT) params = self.load_params_from_file()
params = params.get('params', {})
self._ft_params_from_file = params
# Init/loading of parameters is done as part of ft_bot_start().
def enumerate_parameters(self, category: str = None) -> Iterator[Tuple[str, BaseParameter]]: def enumerate_parameters(self, category: str = None) -> Iterator[Tuple[str, BaseParameter]]:
""" """
@ -327,28 +53,13 @@ class HyperStrategyMixin:
for par in params: for par in params:
yield par.name, par yield par.name, par
@classmethod
def detect_parameters(cls, category: str) -> Iterator[Tuple[str, BaseParameter]]:
""" Detect all parameters for 'category' """
for attr_name in dir(cls):
if not attr_name.startswith('__'): # Ignore internals, not strictly necessary.
attr = getattr(cls, attr_name)
if issubclass(attr.__class__, BaseParameter):
if (attr_name.startswith(category + '_')
and attr.category is not None and attr.category != category):
raise OperationalException(
f'Inconclusive parameter name {attr_name}, category: {attr.category}.')
if (category == attr.category or
(attr_name.startswith(category + '_') and attr.category is None)):
yield attr_name, attr
@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(detect_parameters(cls, 'buy')),
'sell': list(cls.detect_parameters('sell')), 'sell': list(detect_parameters(cls, 'sell')),
'protection': list(cls.detect_parameters('protection')), 'protection': list(detect_parameters(cls, 'protection')),
} }
params.update({ params.update({
'count': len(params['buy'] + params['sell'] + params['protection']) 'count': len(params['buy'] + params['sell'] + params['protection'])
@ -356,21 +67,49 @@ class HyperStrategyMixin:
return params return params
def _load_hyper_params(self, hyperopt: bool = False) -> None: def ft_load_params_from_file(self) -> None:
"""
Load Parameters from parameter file
Should/must run before config values are loaded in strategy_resolver.
"""
if self._ft_params_from_file:
# Set parameters from Hyperopt results file
params = self._ft_params_from_file
self.minimal_roi = params.get('roi', getattr(self, 'minimal_roi', {}))
self.stoploss = params.get('stoploss', {}).get(
'stoploss', getattr(self, 'stoploss', -0.1))
trailing = params.get('trailing', {})
self.trailing_stop = trailing.get(
'trailing_stop', getattr(self, 'trailing_stop', False))
self.trailing_stop_positive = trailing.get(
'trailing_stop_positive', getattr(self, 'trailing_stop_positive', None))
self.trailing_stop_positive_offset = trailing.get(
'trailing_stop_positive_offset',
getattr(self, 'trailing_stop_positive_offset', 0))
self.trailing_only_offset_is_reached = trailing.get(
'trailing_only_offset_is_reached',
getattr(self, 'trailing_only_offset_is_reached', 0.0))
def ft_load_hyper_params(self, hyperopt: bool = False) -> None:
""" """
Load Hyperoptable parameters Load Hyperoptable parameters
Prevalence:
* Parameters from parameter file
* Parameters defined in parameters objects (buy_params, sell_params, ...)
* Parameter defaults
""" """
params = self.load_params_from_file()
params = params.get('params', {}) buy_params = deep_merge_dicts(self._ft_params_from_file.get('buy', {}),
self._ft_params_from_file = params getattr(self, 'buy_params', {}))
buy_params = deep_merge_dicts(params.get('buy', {}), getattr(self, 'buy_params', {})) sell_params = deep_merge_dicts(self._ft_params_from_file.get('sell', {}),
sell_params = deep_merge_dicts(params.get('sell', {}), getattr(self, 'sell_params', {})) getattr(self, 'sell_params', {}))
protection_params = deep_merge_dicts(params.get('protection', {}), protection_params = deep_merge_dicts(self._ft_params_from_file.get('protection', {}),
getattr(self, 'protection_params', {})) getattr(self, 'protection_params', {}))
self._load_params(buy_params, 'buy', hyperopt) self._ft_load_params(buy_params, 'buy', hyperopt)
self._load_params(sell_params, 'sell', hyperopt) self._ft_load_params(sell_params, 'sell', hyperopt)
self._load_params(protection_params, 'protection', hyperopt) self._ft_load_params(protection_params, 'protection', hyperopt)
def load_params_from_file(self) -> Dict: def load_params_from_file(self) -> Dict:
filename_str = getattr(self, '__file__', '') filename_str = getattr(self, '__file__', '')
@ -393,7 +132,7 @@ class HyperStrategyMixin:
return {} return {}
def _load_params(self, params: Dict, space: str, hyperopt: bool = False) -> None: def _ft_load_params(self, params: Dict, space: str, hyperopt: bool = False) -> None:
""" """
Set optimizable parameter values. Set optimizable parameter values.
:param params: Dictionary with new parameter values. :param params: Dictionary with new parameter values.
@ -402,7 +141,7 @@ class HyperStrategyMixin:
logger.info(f"No params for {space} found, using default values.") logger.info(f"No params for {space} found, using default values.")
param_container: List[BaseParameter] = getattr(self, f"ft_{space}_params") param_container: List[BaseParameter] = getattr(self, f"ft_{space}_params")
for attr_name, attr in self.detect_parameters(space): for attr_name, attr in detect_parameters(self, space):
attr.name = attr_name attr.name = attr_name
attr.in_space = hyperopt and HyperoptTools.has_space(self.config, space) attr.in_space = hyperopt and HyperoptTools.has_space(self.config, space)
if not attr.category: if not attr.category:
@ -424,7 +163,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': {},
@ -433,3 +172,25 @@ class HyperStrategyMixin:
if not p.optimize or not p.in_space: if not p.optimize or not p.in_space:
params[p.category][name] = p.value params[p.category][name] = p.value
return params return params
def detect_parameters(
obj: Union[HyperStrategyMixin, Type[HyperStrategyMixin]],
category: str
) -> Iterator[Tuple[str, BaseParameter]]:
"""
Detect all parameters for 'category' for "obj"
:param obj: Strategy object or class
:param category: category - usually `'buy', 'sell', 'protection',...
"""
for attr_name in dir(obj):
if not attr_name.startswith('__'): # Ignore internals, not strictly necessary.
attr = getattr(obj, attr_name)
if issubclass(attr.__class__, BaseParameter):
if (attr_name.startswith(category + '_')
and attr.category is not None and attr.category != category):
raise OperationalException(
f'Inconclusive parameter name {attr_name}, category: {attr.category}.')
if (category == attr.category or
(attr_name.startswith(category + '_') and attr.category is None)):
yield attr_name, attr

View File

@ -14,9 +14,10 @@ from freqtrade.constants import ListPairsWithTimeframes
from freqtrade.data.dataprovider import DataProvider 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.enums.runmode import RunMode
from freqtrade.exceptions import OperationalException, StrategyError from freqtrade.exceptions import OperationalException, StrategyError
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_next_date, timeframe_to_seconds from freqtrade.exchange import timeframe_to_minutes, timeframe_to_next_date, timeframe_to_seconds
from freqtrade.persistence import LocalTrade, Order, PairLocks, Trade from freqtrade.persistence import Order, PairLocks, Trade
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,
@ -82,7 +83,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
@ -144,6 +145,15 @@ 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)()
self.ft_load_hyper_params(self.config.get('runmode') == RunMode.HYPEROPT)
@abstractmethod @abstractmethod
def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
""" """
@ -277,8 +287,9 @@ class IStrategy(ABC, HyperStrategyMixin):
:param pair: Pair that's about to be bought/shorted. :param pair: Pair that's about to be bought/shorted.
:param order_type: Order type (as configured in order_types). usually limit or market. :param order_type: Order type (as configured in order_types). usually limit or market.
:param amount: Amount in target (quote) currency that's going to be traded. :param amount: Amount in target (base) currency that's going to be traded.
:param rate: Rate that's going to be used when using limit orders :param rate: Rate that's going to be used when using limit orders
or current rate for market orders.
:param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled).
:param current_time: datetime object, containing the current datetime :param current_time: datetime object, containing the current datetime
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal. :param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
@ -304,8 +315,9 @@ class IStrategy(ABC, HyperStrategyMixin):
:param pair: Pair for trade that's about to be exited. :param pair: Pair for trade that's about to be exited.
:param trade: trade object. :param trade: trade object.
:param order_type: Order type (as configured in order_types). usually limit or market. :param order_type: Order type (as configured in order_types). usually limit or market.
:param amount: Amount in quote currency. :param amount: Amount in base currency.
:param rate: Rate that's going to be used when using limit orders :param rate: Rate that's going to be used when using limit orders
or current rate for market orders.
:param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled).
:param exit_reason: Exit reason. :param exit_reason: Exit reason.
Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss', Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss',
@ -429,7 +441,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.
@ -447,8 +459,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.
@ -498,8 +511,8 @@ class IStrategy(ABC, HyperStrategyMixin):
return current_order_rate 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, entry_tag: Optional[str],
**kwargs) -> float: side: str, **kwargs) -> float:
""" """
Customize leverage for each new trade. This method is only called in futures mode. Customize leverage for each new trade. This method is only called in futures mode.
@ -508,6 +521,7 @@ class IStrategy(ABC, HyperStrategyMixin):
:param current_rate: Rate, calculated based on pricing settings in exit_pricing. :param current_rate: Rate, calculated based on pricing settings in exit_pricing.
:param proposed_leverage: A leverage proposed by the bot. :param proposed_leverage: A leverage proposed by the bot.
:param max_leverage: Max leverage allowed on this pair :param max_leverage: Max leverage allowed on this pair
: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 side: 'long' or 'short' - indicating the direction of the proposed trade
:return: A leverage amount, which is between 1.0 and max_leverage. :return: A leverage amount, which is between 1.0 and max_leverage.
""" """
@ -878,16 +892,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)
@ -917,19 +931,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
@ -938,24 +953,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,
@ -1070,7 +1090,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

@ -6,7 +6,7 @@ import numpy as np # noqa
import pandas as pd # noqa import pandas as pd # noqa
from pandas import DataFrame # noqa from pandas import DataFrame # noqa
from datetime import datetime # noqa from datetime import datetime # noqa
from typing import Optional # 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)
@ -64,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.
@ -80,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.
@ -159,8 +159,9 @@ def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: f
:param pair: Pair that's about to be bought/shorted. :param pair: Pair that's about to be bought/shorted.
:param order_type: Order type (as configured in order_types). usually limit or market. :param order_type: Order type (as configured in order_types). usually limit or market.
:param amount: Amount in target (quote) currency that's going to be traded. :param amount: Amount in target (base) currency that's going to be traded.
:param rate: Rate that's going to be used when using limit orders :param rate: Rate that's going to be used when using limit orders
or current rate for market orders.
:param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled).
:param current_time: datetime object, containing the current datetime :param current_time: datetime object, containing the current datetime
:param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal. :param entry_tag: Optional entry_tag (buy_tag) if provided with the buy signal.
@ -175,7 +176,7 @@ def confirm_trade_exit(self, pair: str, trade: 'Trade', order_type: str, amount:
rate: float, time_in_force: str, exit_reason: str, rate: float, time_in_force: str, exit_reason: str,
current_time: 'datetime', **kwargs) -> bool: current_time: 'datetime', **kwargs) -> bool:
""" """
Called right before placing a regular sell order. Called right before placing a regular exit order.
Timing for this function is critical, so avoid doing heavy computations or Timing for this function is critical, so avoid doing heavy computations or
network requests in this method. network requests in this method.
@ -183,18 +184,19 @@ def confirm_trade_exit(self, pair: str, trade: 'Trade', order_type: str, amount:
When not implemented by a strategy, returns True (always confirming). When not implemented by a strategy, returns True (always confirming).
:param pair: Pair that's currently analyzed :param pair: Pair for trade that's about to be exited.
:param trade: trade object. :param trade: trade object.
:param order_type: Order type (as configured in order_types). usually limit or market. :param order_type: Order type (as configured in order_types). usually limit or market.
:param amount: Amount in quote currency. :param amount: Amount in base currency.
:param rate: Rate that's going to be used when using limit orders :param rate: Rate that's going to be used when using limit orders
or current rate for market orders.
:param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled).
:param exit_reason: Exit reason. :param exit_reason: Exit reason.
Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss', Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss',
'exit_signal', 'force_exit', 'emergency_exit'] 'exit_signal', 'force_exit', 'emergency_exit']
:param current_time: datetime object, containing the current datetime :param current_time: datetime object, containing the current datetime
:param **kwargs: Ensure to keep this here so updates to this won't break your strategy. :param **kwargs: Ensure to keep this here so updates to this won't break your strategy.
:return bool: When True is returned, then the exit-order is placed on the exchange. :return bool: When True, then the exit-order is placed on the exchange.
False aborts the process False aborts the process
""" """
return True return True
@ -244,8 +246,8 @@ 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.
This means extra buy orders with additional fees. This means extra buy orders with additional fees.
@ -267,8 +269,8 @@ def adjust_trade_position(self, trade: 'Trade', current_time: 'datetime',
return None return None
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, entry_tag: Optional[str],
**kwargs) -> float: side: str, **kwargs) -> float:
""" """
Customize leverage for each new trade. This method is only called in futures mode. Customize leverage for each new trade. This method is only called in futures mode.
@ -277,6 +279,7 @@ def leverage(self, pair: str, current_time: datetime, current_rate: float,
:param current_rate: Rate, calculated based on pricing settings in exit_pricing. :param current_rate: Rate, calculated based on pricing settings in exit_pricing.
:param proposed_leverage: A leverage proposed by the bot. :param proposed_leverage: A leverage proposed by the bot.
:param max_leverage: Max leverage allowed on this pair :param max_leverage: Max leverage allowed on this pair
: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 side: 'long' or 'short' - indicating the direction of the proposed trade
:return: A leverage amount, which is between 1.0 and max_leverage. :return: A leverage amount, which is between 1.0 and max_leverage.
""" """

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

@ -7,7 +7,7 @@
coveralls==3.3.1 coveralls==3.3.1
flake8==4.0.1 flake8==4.0.1
flake8-tidy-imports==4.8.0 flake8-tidy-imports==4.8.0
mypy==0.950 mypy==0.961
pre-commit==2.19.0 pre-commit==2.19.0
pytest==7.1.2 pytest==7.1.2
pytest-asyncio==0.18.3 pytest-asyncio==0.18.3
@ -22,8 +22,8 @@ time-machine==2.7.0
nbconvert==6.5.0 nbconvert==6.5.0
# mypy types # mypy types
types-cachetools==5.0.1 types-cachetools==5.0.2
types-filelock==3.2.5 types-filelock==3.2.7
types-requests==2.27.25 types-requests==2.27.30
types-tabulate==0.8.9 types-tabulate==0.8.9
types-python-dateutil==2.8.15 types-python-dateutil==2.8.17

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.1.0 scikit-learn==1.1.1
scikit-optimize==0.9.0 scikit-optimize==0.9.0
filelock==3.7.0 filelock==3.7.1
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.8.0 plotly==5.8.2

View File

@ -1,18 +1,18 @@
numpy==1.22.3 numpy==1.23.0
pandas==1.4.2 pandas==1.4.2
pandas-ta==0.3.14b pandas-ta==0.3.14b
ccxt==1.82.61 ccxt==1.88.15
# Pin cryptography for now due to rust build errors with piwheels # Pin cryptography for now due to rust build errors with piwheels
cryptography==37.0.2 cryptography==37.0.2
aiohttp==3.8.1 aiohttp==3.8.1
SQLAlchemy==1.4.36 SQLAlchemy==1.4.37
python-telegram-bot==13.11 python-telegram-bot==13.12
arrow==1.2.2 arrow==1.2.2
cachetools==4.2.2 cachetools==4.2.2
requests==2.27.1 requests==2.28.0
urllib3==1.26.9 urllib3==1.26.9
jsonschema==4.5.1 jsonschema==4.6.0
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
@ -28,7 +28,7 @@ py_find_1st==1.1.5
# Load ticker files 30% faster # Load ticker files 30% faster
python-rapidjson==1.6 python-rapidjson==1.6
# Properly format api responses # Properly format api responses
orjson==3.6.8 orjson==3.7.2
# Notify systemd # Notify systemd
sdnotify==0.3.2 sdnotify==0.3.2
@ -38,10 +38,10 @@ fastapi==0.78.0
uvicorn==0.17.6 uvicorn==0.17.6
pyjwt==2.4.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.5
# Building config files interactively # Building config files interactively
questionary==1.10.0 questionary==1.10.0
prompt-toolkit==3.0.29 prompt-toolkit==3.0.29

View File

@ -261,7 +261,7 @@ class FtRestClient():
} }
return self._post("forcebuy", data=data) return self._post("forcebuy", data=data)
def force_enter(self, pair, side, price=None): def forceenter(self, pair, side, price=None):
"""Force entering a trade """Force entering a trade
:param pair: Pair to buy (ETH/BTC) :param pair: Pair to buy (ETH/BTC)
@ -273,7 +273,7 @@ class FtRestClient():
"side": side, "side": side,
"price": price, "price": price,
} }
return self._post("force_enter", data=data) return self._post("forceenter", data=data)
def forceexit(self, tradeid): def forceexit(self, tradeid):
"""Force-exit a trade. """Force-exit a trade.

View File

@ -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.80.67', '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

@ -87,6 +87,10 @@ function updateenv() {
echo "Failed installing Freqtrade" echo "Failed installing Freqtrade"
exit 1 exit 1
fi fi
echo "Installing freqUI"
freqtrade install-ui
echo "pip install completed" echo "pip install completed"
echo echo
if [[ $dev =~ ^[Yy]$ ]]; then if [[ $dev =~ ^[Yy]$ ]]; then

View File

@ -1495,7 +1495,7 @@ def test_start_convert_db(mocker, fee, tmpdir, caplog):
] ]
assert not db_src_file.is_file() assert not db_src_file.is_file()
init_db(db_from, False) init_db(db_from)
create_mock_trades(fee) create_mock_trades(fee)

View File

@ -78,8 +78,20 @@ def get_args(args):
# Source: https://stackoverflow.com/questions/29881236/how-to-mock-asyncio-coroutines # Source: https://stackoverflow.com/questions/29881236/how-to-mock-asyncio-coroutines
def get_mock_coro(return_value): # TODO: This should be replaced with AsyncMock once support for python 3.7 is dropped.
def get_mock_coro(return_value=None, side_effect=None):
async def mock_coro(*args, **kwargs): async def mock_coro(*args, **kwargs):
if side_effect:
if isinstance(side_effect, list):
effect = side_effect.pop(0)
else:
effect = side_effect
if isinstance(effect, Exception):
raise effect
if callable(effect):
return effect(*args, **kwargs)
return effect
else:
return return_value return return_value
return Mock(wraps=mock_coro) return Mock(wraps=mock_coro)
@ -325,7 +337,7 @@ def create_mock_trades_with_leverage(fee, use_db: bool = True):
Trade.query.session.flush() Trade.query.session.flush()
def create_mock_trades_usdt(fee, use_db: bool = True): def create_mock_trades_usdt(fee, is_short: Optional[bool] = False, use_db: bool = True):
""" """
Create some fake trades ... Create some fake trades ...
""" """
@ -335,26 +347,29 @@ def create_mock_trades_usdt(fee, use_db: bool = True):
else: else:
LocalTrade.add_bt_trade(trade) LocalTrade.add_bt_trade(trade)
is_short1 = is_short if is_short is not None else True
is_short2 = is_short if is_short is not None else False
# Simulate dry_run entries # Simulate dry_run entries
trade = mock_trade_usdt_1(fee) trade = mock_trade_usdt_1(fee, is_short1)
add_trade(trade) add_trade(trade)
trade = mock_trade_usdt_2(fee) trade = mock_trade_usdt_2(fee, is_short1)
add_trade(trade) add_trade(trade)
trade = mock_trade_usdt_3(fee) trade = mock_trade_usdt_3(fee, is_short1)
add_trade(trade) add_trade(trade)
trade = mock_trade_usdt_4(fee) trade = mock_trade_usdt_4(fee, is_short2)
add_trade(trade) add_trade(trade)
trade = mock_trade_usdt_5(fee) trade = mock_trade_usdt_5(fee, is_short2)
add_trade(trade) add_trade(trade)
trade = mock_trade_usdt_6(fee) trade = mock_trade_usdt_6(fee, is_short1)
add_trade(trade) add_trade(trade)
trade = mock_trade_usdt_7(fee) trade = mock_trade_usdt_7(fee, is_short1)
add_trade(trade) add_trade(trade)
if use_db: if use_db:
Trade.commit() Trade.commit()
@ -384,7 +399,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 +1631,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

@ -29,6 +29,7 @@ def mock_order_1(is_short: bool):
'average': 0.123, 'average': 0.123,
'amount': 123.0, 'amount': 123.0,
'filled': 123.0, 'filled': 123.0,
'cost': 15.129,
'remaining': 0.0, 'remaining': 0.0,
} }
@ -65,6 +66,7 @@ def mock_order_2(is_short: bool):
'price': 0.123, 'price': 0.123,
'amount': 123.0, 'amount': 123.0,
'filled': 123.0, 'filled': 123.0,
'cost': 15.129,
'remaining': 0.0, 'remaining': 0.0,
} }
@ -79,6 +81,7 @@ def mock_order_2_sell(is_short: bool):
'price': 0.128, 'price': 0.128,
'amount': 123.0, 'amount': 123.0,
'filled': 123.0, 'filled': 123.0,
'cost': 15.129,
'remaining': 0.0, 'remaining': 0.0,
} }
@ -126,6 +129,7 @@ def mock_order_3(is_short: bool):
'price': 0.05, 'price': 0.05,
'amount': 123.0, 'amount': 123.0,
'filled': 123.0, 'filled': 123.0,
'cost': 15.129,
'remaining': 0.0, 'remaining': 0.0,
} }
@ -141,6 +145,7 @@ def mock_order_3_sell(is_short: bool):
'average': 0.06, 'average': 0.06,
'amount': 123.0, 'amount': 123.0,
'filled': 123.0, 'filled': 123.0,
'cost': 15.129,
'remaining': 0.0, 'remaining': 0.0,
} }
@ -186,6 +191,7 @@ def mock_order_4(is_short: bool):
'price': 0.123, 'price': 0.123,
'amount': 123.0, 'amount': 123.0,
'filled': 0.0, 'filled': 0.0,
'cost': 15.129,
'remaining': 123.0, 'remaining': 123.0,
} }
@ -225,6 +231,7 @@ def mock_order_5(is_short: bool):
'price': 0.123, 'price': 0.123,
'amount': 123.0, 'amount': 123.0,
'filled': 123.0, 'filled': 123.0,
'cost': 15.129,
'remaining': 0.0, 'remaining': 0.0,
} }
@ -239,6 +246,7 @@ def mock_order_5_stoploss(is_short: bool):
'price': 0.123, 'price': 0.123,
'amount': 123.0, 'amount': 123.0,
'filled': 0.0, 'filled': 0.0,
'cost': 0.0,
'remaining': 123.0, 'remaining': 123.0,
} }
@ -281,6 +289,7 @@ def mock_order_6(is_short: bool):
'price': 0.15, 'price': 0.15,
'amount': 2.0, 'amount': 2.0,
'filled': 2.0, 'filled': 2.0,
'cost': 0.3,
'remaining': 0.0, 'remaining': 0.0,
} }
@ -295,6 +304,7 @@ def mock_order_6_sell(is_short: bool):
'price': 0.15 if is_short else 0.20, 'price': 0.15 if is_short else 0.20,
'amount': 2.0, 'amount': 2.0,
'filled': 0.0, 'filled': 0.0,
'cost': 0.0,
'remaining': 2.0, 'remaining': 2.0,
} }
@ -337,6 +347,7 @@ def short_order():
'price': 0.123, 'price': 0.123,
'amount': 123.0, 'amount': 123.0,
'filled': 123.0, 'filled': 123.0,
'cost': 15.129,
'remaining': 0.0, 'remaining': 0.0,
} }
@ -351,6 +362,7 @@ def exit_short_order():
'price': 0.128, 'price': 0.128,
'amount': 123.0, 'amount': 123.0,
'filled': 123.0, 'filled': 123.0,
'cost': 15.744,
'remaining': 0.0, 'remaining': 0.0,
} }
@ -424,6 +436,7 @@ def leverage_order():
'amount': 123.0, 'amount': 123.0,
'filled': 123.0, 'filled': 123.0,
'remaining': 0.0, 'remaining': 0.0,
'cost': 15.129,
'leverage': 5.0 'leverage': 5.0
} }
@ -439,6 +452,7 @@ def leverage_order_sell():
'amount': 123.0, 'amount': 123.0,
'filled': 123.0, 'filled': 123.0,
'remaining': 0.0, 'remaining': 0.0,
'cost': 15.744,
'leverage': 5.0 'leverage': 5.0
} }

View File

@ -6,47 +6,84 @@ from freqtrade.persistence.models import Order, Trade
MOCK_TRADE_COUNT = 6 MOCK_TRADE_COUNT = 6
def mock_order_usdt_1(): def entry_side(is_short: bool):
return "sell" if is_short else "buy"
def exit_side(is_short: bool):
return "buy" if is_short else "sell"
def direc(is_short: bool):
return "short" if is_short else "long"
def mock_order_usdt_1(is_short: bool):
return { return {
'id': '1234', 'id': f'prod_entry_1_{direc(is_short)}',
'symbol': 'ADA/USDT', 'symbol': 'LTC/USDT',
'status': 'closed', 'status': 'closed',
'side': 'buy', 'side': entry_side(is_short),
'type': 'limit', 'type': 'limit',
'price': 2.0, 'price': 10.0,
'amount': 10.0, 'amount': 2.0,
'filled': 10.0, 'filled': 2.0,
'remaining': 0.0, 'remaining': 0.0,
} }
def mock_trade_usdt_1(fee): def mock_order_usdt_1_exit(is_short: bool):
return {
'id': f'prod_exit_1_{direc(is_short)}',
'symbol': 'LTC/USDT',
'status': 'closed',
'side': exit_side(is_short),
'type': 'limit',
'price': 8.0,
'amount': 2.0,
'filled': 2.0,
'remaining': 0.0,
}
def mock_trade_usdt_1(fee, is_short: bool):
"""
Simulate prod entry with open sell order
"""
trade = Trade( trade = Trade(
pair='ADA/USDT', pair='LTC/USDT',
stake_amount=20.0, stake_amount=20.0,
amount=10.0, amount=2.0,
amount_requested=10.0, amount_requested=2.0,
open_date=datetime.now(tz=timezone.utc) - timedelta(days=2, minutes=20),
close_date=datetime.now(tz=timezone.utc) - timedelta(days=2, minutes=5),
fee_open=fee.return_value, fee_open=fee.return_value,
fee_close=fee.return_value, fee_close=fee.return_value,
is_open=True, is_open=False,
open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=17), open_rate=10.0,
open_rate=2.0, close_rate=8.0,
close_profit=-0.2,
close_profit_abs=-4.0,
exchange='binance', exchange='binance',
open_order_id='dry_run_buy_12345', strategy='SampleStrategy',
strategy='StrategyTestV2', open_order_id=f'prod_exit_1_{direc(is_short)}',
timeframe=5, timeframe=5,
is_short=is_short,
) )
o = Order.parse_from_ccxt_object(mock_order_usdt_1(), 'ADA/USDT', 'buy') o = Order.parse_from_ccxt_object(mock_order_usdt_1(is_short), 'LTC/USDT', entry_side(is_short))
trade.orders.append(o)
o = Order.parse_from_ccxt_object(mock_order_usdt_1_exit(is_short),
'LTC/USDT', exit_side(is_short))
trade.orders.append(o) trade.orders.append(o)
return trade return trade
def mock_order_usdt_2(): def mock_order_usdt_2(is_short: bool):
return { return {
'id': '1235', 'id': f'1235_{direc(is_short)}',
'symbol': 'ETC/USDT', 'symbol': 'ETC/USDT',
'status': 'closed', 'status': 'closed',
'side': 'buy', 'side': entry_side(is_short),
'type': 'limit', 'type': 'limit',
'price': 2.0, 'price': 2.0,
'amount': 100.0, 'amount': 100.0,
@ -55,12 +92,12 @@ def mock_order_usdt_2():
} }
def mock_order_usdt_2_sell(): def mock_order_usdt_2_exit(is_short: bool):
return { return {
'id': '12366', 'id': f'12366_{direc(is_short)}',
'symbol': 'ETC/USDT', 'symbol': 'ETC/USDT',
'status': 'closed', 'status': 'closed',
'side': 'sell', 'side': exit_side(is_short),
'type': 'limit', 'type': 'limit',
'price': 2.05, 'price': 2.05,
'amount': 100.0, 'amount': 100.0,
@ -69,7 +106,7 @@ def mock_order_usdt_2_sell():
} }
def mock_trade_usdt_2(fee): def mock_trade_usdt_2(fee, is_short: bool):
""" """
Closed trade... Closed trade...
""" """
@ -82,30 +119,33 @@ def mock_trade_usdt_2(fee):
fee_close=fee.return_value, fee_close=fee.return_value,
open_rate=2.0, open_rate=2.0,
close_rate=2.05, close_rate=2.05,
close_profit=5.0, close_profit=0.05,
close_profit_abs=3.9875, close_profit_abs=3.9875,
exchange='binance', exchange='binance',
is_open=False, is_open=False,
open_order_id='dry_run_sell_12345', open_order_id=f'12366_{direc(is_short)}',
strategy='StrategyTestV2', strategy='StrategyTestV2',
timeframe=5, timeframe=5,
exit_reason='sell_signal', enter_tag='TEST1',
exit_reason='exit_signal',
open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=20), open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=20),
close_date=datetime.now(tz=timezone.utc) - timedelta(minutes=2), close_date=datetime.now(tz=timezone.utc) - timedelta(minutes=2),
is_short=is_short,
) )
o = Order.parse_from_ccxt_object(mock_order_usdt_2(), 'ETC/USDT', 'buy') o = Order.parse_from_ccxt_object(mock_order_usdt_2(is_short), 'ETC/USDT', entry_side(is_short))
trade.orders.append(o) trade.orders.append(o)
o = Order.parse_from_ccxt_object(mock_order_usdt_2_sell(), 'ETC/USDT', 'sell') o = Order.parse_from_ccxt_object(
mock_order_usdt_2_exit(is_short), 'ETC/USDT', exit_side(is_short))
trade.orders.append(o) trade.orders.append(o)
return trade return trade
def mock_order_usdt_3(): def mock_order_usdt_3(is_short: bool):
return { return {
'id': '41231a12a', 'id': f'41231a12a_{direc(is_short)}',
'symbol': 'XRP/USDT', 'symbol': 'XRP/USDT',
'status': 'closed', 'status': 'closed',
'side': 'buy', 'side': entry_side(is_short),
'type': 'limit', 'type': 'limit',
'price': 1.0, 'price': 1.0,
'amount': 30.0, 'amount': 30.0,
@ -114,12 +154,12 @@ def mock_order_usdt_3():
} }
def mock_order_usdt_3_sell(): def mock_order_usdt_3_exit(is_short: bool):
return { return {
'id': '41231a666a', 'id': f'41231a666a_{direc(is_short)}',
'symbol': 'XRP/USDT', 'symbol': 'XRP/USDT',
'status': 'closed', 'status': 'closed',
'side': 'sell', 'side': exit_side(is_short),
'type': 'stop_loss_limit', 'type': 'stop_loss_limit',
'price': 1.1, 'price': 1.1,
'average': 1.1, 'average': 1.1,
@ -129,7 +169,7 @@ def mock_order_usdt_3_sell():
} }
def mock_trade_usdt_3(fee): def mock_trade_usdt_3(fee, is_short: bool):
""" """
Closed trade Closed trade
""" """
@ -142,29 +182,32 @@ def mock_trade_usdt_3(fee):
fee_close=fee.return_value, fee_close=fee.return_value,
open_rate=1.0, open_rate=1.0,
close_rate=1.1, close_rate=1.1,
close_profit=10.0, close_profit=0.1,
close_profit_abs=9.8425, close_profit_abs=9.8425,
exchange='binance', exchange='binance',
is_open=False, is_open=False,
strategy='StrategyTestV2', strategy='StrategyTestV2',
timeframe=5, timeframe=5,
enter_tag='TEST3',
exit_reason='roi', exit_reason='roi',
open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=20), open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=20),
close_date=datetime.now(tz=timezone.utc), close_date=datetime.now(tz=timezone.utc),
is_short=is_short,
) )
o = Order.parse_from_ccxt_object(mock_order_usdt_3(), 'XRP/USDT', 'buy') o = Order.parse_from_ccxt_object(mock_order_usdt_3(is_short), 'XRP/USDT', entry_side(is_short))
trade.orders.append(o) trade.orders.append(o)
o = Order.parse_from_ccxt_object(mock_order_usdt_3_sell(), 'XRP/USDT', 'sell') o = Order.parse_from_ccxt_object(mock_order_usdt_3_exit(is_short),
'XRP/USDT', exit_side(is_short))
trade.orders.append(o) trade.orders.append(o)
return trade return trade
def mock_order_usdt_4(): def mock_order_usdt_4(is_short: bool):
return { return {
'id': 'prod_buy_12345', 'id': f'prod_buy_12345_{direc(is_short)}',
'symbol': 'ETC/USDT', 'symbol': 'ETC/USDT',
'status': 'open', 'status': 'open',
'side': 'buy', 'side': entry_side(is_short),
'type': 'limit', 'type': 'limit',
'price': 2.0, 'price': 2.0,
'amount': 10.0, 'amount': 10.0,
@ -173,7 +216,7 @@ def mock_order_usdt_4():
} }
def mock_trade_usdt_4(fee): def mock_trade_usdt_4(fee, is_short: bool):
""" """
Simulate prod entry Simulate prod entry
""" """
@ -188,21 +231,22 @@ def mock_trade_usdt_4(fee):
is_open=True, is_open=True,
open_rate=2.0, open_rate=2.0,
exchange='binance', exchange='binance',
open_order_id='prod_buy_12345', open_order_id=f'prod_buy_12345_{direc(is_short)}',
strategy='StrategyTestV2', strategy='StrategyTestV2',
timeframe=5, timeframe=5,
is_short=is_short,
) )
o = Order.parse_from_ccxt_object(mock_order_usdt_4(), 'ETC/USDT', 'buy') o = Order.parse_from_ccxt_object(mock_order_usdt_4(is_short), 'ETC/USDT', entry_side(is_short))
trade.orders.append(o) trade.orders.append(o)
return trade return trade
def mock_order_usdt_5(): def mock_order_usdt_5(is_short: bool):
return { return {
'id': 'prod_buy_3455', 'id': f'prod_buy_3455_{direc(is_short)}',
'symbol': 'XRP/USDT', 'symbol': 'XRP/USDT',
'status': 'closed', 'status': 'closed',
'side': 'buy', 'side': entry_side(is_short),
'type': 'limit', 'type': 'limit',
'price': 2.0, 'price': 2.0,
'amount': 10.0, 'amount': 10.0,
@ -211,12 +255,12 @@ def mock_order_usdt_5():
} }
def mock_order_usdt_5_stoploss(): def mock_order_usdt_5_stoploss(is_short: bool):
return { return {
'id': 'prod_stoploss_3455', 'id': f'prod_stoploss_3455_{direc(is_short)}',
'symbol': 'XRP/USDT', 'symbol': 'XRP/USDT',
'status': 'open', 'status': 'open',
'side': 'sell', 'side': exit_side(is_short),
'type': 'stop_loss_limit', 'type': 'stop_loss_limit',
'price': 2.0, 'price': 2.0,
'amount': 10.0, 'amount': 10.0,
@ -225,7 +269,7 @@ def mock_order_usdt_5_stoploss():
} }
def mock_trade_usdt_5(fee): def mock_trade_usdt_5(fee, is_short: bool):
""" """
Simulate prod entry with stoploss Simulate prod entry with stoploss
""" """
@ -241,22 +285,23 @@ def mock_trade_usdt_5(fee):
open_rate=2.0, open_rate=2.0,
exchange='binance', exchange='binance',
strategy='SampleStrategy', strategy='SampleStrategy',
stoploss_order_id='prod_stoploss_3455', stoploss_order_id=f'prod_stoploss_3455_{direc(is_short)}',
timeframe=5, timeframe=5,
is_short=is_short,
) )
o = Order.parse_from_ccxt_object(mock_order_usdt_5(), 'XRP/USDT', 'buy') o = Order.parse_from_ccxt_object(mock_order_usdt_5(is_short), 'XRP/USDT', entry_side(is_short))
trade.orders.append(o) trade.orders.append(o)
o = Order.parse_from_ccxt_object(mock_order_usdt_5_stoploss(), 'XRP/USDT', 'stoploss') o = Order.parse_from_ccxt_object(mock_order_usdt_5_stoploss(is_short), 'XRP/USDT', 'stoploss')
trade.orders.append(o) trade.orders.append(o)
return trade return trade
def mock_order_usdt_6(): def mock_order_usdt_6(is_short: bool):
return { return {
'id': 'prod_buy_6', 'id': f'prod_entry_6_{direc(is_short)}',
'symbol': 'LTC/USDT', 'symbol': 'LTC/USDT',
'status': 'closed', 'status': 'closed',
'side': 'buy', 'side': entry_side(is_short),
'type': 'limit', 'type': 'limit',
'price': 10.0, 'price': 10.0,
'amount': 2.0, 'amount': 2.0,
@ -265,12 +310,12 @@ def mock_order_usdt_6():
} }
def mock_order_usdt_6_sell(): def mock_order_usdt_6_exit(is_short: bool):
return { return {
'id': 'prod_sell_6', 'id': f'prod_exit_6_{direc(is_short)}',
'symbol': 'LTC/USDT', 'symbol': 'LTC/USDT',
'status': 'open', 'status': 'open',
'side': 'sell', 'side': exit_side(is_short),
'type': 'limit', 'type': 'limit',
'price': 12.0, 'price': 12.0,
'amount': 2.0, 'amount': 2.0,
@ -279,7 +324,7 @@ def mock_order_usdt_6_sell():
} }
def mock_trade_usdt_6(fee): def mock_trade_usdt_6(fee, is_short: bool):
""" """
Simulate prod entry with open sell order Simulate prod entry with open sell order
""" """
@ -295,69 +340,49 @@ def mock_trade_usdt_6(fee):
open_rate=10.0, open_rate=10.0,
exchange='binance', exchange='binance',
strategy='SampleStrategy', strategy='SampleStrategy',
open_order_id="prod_sell_6", open_order_id=f'prod_exit_6_{direc(is_short)}',
timeframe=5, timeframe=5,
is_short=is_short,
) )
o = Order.parse_from_ccxt_object(mock_order_usdt_6(), 'LTC/USDT', 'buy') o = Order.parse_from_ccxt_object(mock_order_usdt_6(is_short), 'LTC/USDT', entry_side(is_short))
trade.orders.append(o) trade.orders.append(o)
o = Order.parse_from_ccxt_object(mock_order_usdt_6_sell(), 'LTC/USDT', 'sell') o = Order.parse_from_ccxt_object(mock_order_usdt_6_exit(is_short),
'LTC/USDT', exit_side(is_short))
trade.orders.append(o) trade.orders.append(o)
return trade return trade
def mock_order_usdt_7(): def mock_order_usdt_7(is_short: bool):
return { return {
'id': 'prod_buy_7', 'id': f'1234_{direc(is_short)}',
'symbol': 'LTC/USDT', 'symbol': 'ADA/USDT',
'status': 'closed', 'status': 'closed',
'side': 'buy', 'side': entry_side(is_short),
'type': 'limit', 'type': 'limit',
'price': 10.0, 'price': 2.0,
'amount': 2.0, 'amount': 10.0,
'filled': 2.0, 'filled': 10.0,
'remaining': 0.0, 'remaining': 0.0,
} }
def mock_order_usdt_7_sell(): def mock_trade_usdt_7(fee, is_short: bool):
return {
'id': 'prod_sell_7',
'symbol': 'LTC/USDT',
'status': 'closed',
'side': 'sell',
'type': 'limit',
'price': 8.0,
'amount': 2.0,
'filled': 2.0,
'remaining': 0.0,
}
def mock_trade_usdt_7(fee):
"""
Simulate prod entry with open sell order
"""
trade = Trade( trade = Trade(
pair='LTC/USDT', pair='ADA/USDT',
stake_amount=20.0, stake_amount=20.0,
amount=2.0, amount=10.0,
amount_requested=2.0, amount_requested=10.0,
open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=20),
close_date=datetime.now(tz=timezone.utc) - timedelta(minutes=5),
fee_open=fee.return_value, fee_open=fee.return_value,
fee_close=fee.return_value, fee_close=fee.return_value,
is_open=False, is_open=True,
open_rate=10.0, open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=17),
close_rate=8.0, open_rate=2.0,
close_profit=-0.2,
close_profit_abs=-4.0,
exchange='binance', exchange='binance',
strategy='SampleStrategy', open_order_id=f'1234_{direc(is_short)}',
open_order_id="prod_sell_6", strategy='StrategyTestV2',
timeframe=5, timeframe=5,
is_short=is_short,
) )
o = Order.parse_from_ccxt_object(mock_order_usdt_7(), 'LTC/USDT', 'buy') o = Order.parse_from_ccxt_object(mock_order_usdt_7(is_short), 'ADA/USDT', entry_side(is_short))
trade.orders.append(o)
o = Order.parse_from_ccxt_object(mock_order_usdt_7_sell(), 'LTC/USDT', 'sell')
trade.orders.append(o) trade.orders.append(o)
return trade return trade

View File

@ -85,7 +85,7 @@ def test_load_backtest_data_new_format(testdatadir):
filename = testdatadir / "backtest_results/backtest-result_new.json" filename = testdatadir / "backtest_results/backtest-result_new.json"
bt_data = load_backtest_data(filename) bt_data = load_backtest_data(filename)
assert isinstance(bt_data, DataFrame) assert isinstance(bt_data, DataFrame)
assert set(bt_data.columns) == set(BT_DATA_COLUMNS + ['close_timestamp', 'open_timestamp']) assert set(bt_data.columns) == set(BT_DATA_COLUMNS)
assert len(bt_data) == 179 assert len(bt_data) == 179
# Test loading from string (must yield same result) # Test loading from string (must yield same result)
@ -110,7 +110,7 @@ def test_load_backtest_data_multi(testdatadir):
bt_data = load_backtest_data(filename, strategy=strategy) bt_data = load_backtest_data(filename, strategy=strategy)
assert isinstance(bt_data, DataFrame) assert isinstance(bt_data, DataFrame)
assert set(bt_data.columns) == set( assert set(bt_data.columns) == set(
BT_DATA_COLUMNS + ['close_timestamp', 'open_timestamp']) BT_DATA_COLUMNS)
assert len(bt_data) == 179 assert len(bt_data) == 179
# Test loading from string (must yield same result) # Test loading from string (must yield same result)

View File

@ -0,0 +1,191 @@
import logging
from unittest.mock import MagicMock, PropertyMock
import pandas as pd
import pytest
from freqtrade.commands.analyze_commands import start_analysis_entries_exits
from freqtrade.commands.optimize_commands import start_backtesting
from freqtrade.enums import ExitType
from freqtrade.optimize.backtesting import Backtesting
from tests.conftest import get_args, patch_exchange, patched_configuration_load_config_file
@pytest.fixture(autouse=True)
def entryexitanalysis_cleanup() -> None:
yield None
Backtesting.cleanup()
def test_backtest_analysis_nomock(default_conf, mocker, caplog, testdatadir, tmpdir, capsys):
caplog.set_level(logging.INFO)
default_conf.update({
"use_exit_signal": True,
"exit_profit_only": False,
"exit_profit_offset": 0.0,
"ignore_roi_if_entry_signal": False,
})
patch_exchange(mocker)
result1 = pd.DataFrame({'pair': ['ETH/BTC', 'LTC/BTC', 'ETH/BTC', 'LTC/BTC'],
'profit_ratio': [0.025, 0.05, -0.1, -0.05],
'profit_abs': [0.5, 2.0, -4.0, -2.0],
'open_date': pd.to_datetime(['2018-01-29 18:40:00',
'2018-01-30 03:30:00',
'2018-01-30 08:10:00',
'2018-01-31 13:30:00', ], utc=True
),
'close_date': pd.to_datetime(['2018-01-29 20:45:00',
'2018-01-30 05:35:00',
'2018-01-30 09:10:00',
'2018-01-31 15:00:00', ], utc=True),
'trade_duration': [235, 40, 60, 90],
'is_open': [False, False, False, False],
'stake_amount': [0.01, 0.01, 0.01, 0.01],
'open_rate': [0.104445, 0.10302485, 0.10302485, 0.10302485],
'close_rate': [0.104969, 0.103541, 0.102041, 0.102541],
"is_short": [False, False, False, False],
'enter_tag': ["enter_tag_long_a",
"enter_tag_long_b",
"enter_tag_long_a",
"enter_tag_long_b"],
'exit_reason': [ExitType.ROI,
ExitType.EXIT_SIGNAL,
ExitType.STOP_LOSS,
ExitType.TRAILING_STOP_LOSS]
})
backtestmock = MagicMock(side_effect=[
{
'results': result1,
'config': default_conf,
'locks': [],
'rejected_signals': 20,
'timedout_entry_orders': 0,
'timedout_exit_orders': 0,
'canceled_trade_entries': 0,
'canceled_entry_orders': 0,
'replaced_entry_orders': 0,
'final_balance': 1000,
}
])
mocker.patch('freqtrade.plugins.pairlistmanager.PairListManager.whitelist',
PropertyMock(return_value=['ETH/BTC', 'LTC/BTC', 'DASH/BTC']))
mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest', backtestmock)
patched_configuration_load_config_file(mocker, default_conf)
args = [
'backtesting',
'--config', 'config.json',
'--datadir', str(testdatadir),
'--user-data-dir', str(tmpdir),
'--timeframe', '5m',
'--timerange', '1515560100-1517287800',
'--export', 'signals',
'--cache', 'none',
]
args = get_args(args)
start_backtesting(args)
captured = capsys.readouterr()
assert 'BACKTESTING REPORT' in captured.out
assert 'EXIT REASON STATS' in captured.out
assert 'LEFT OPEN TRADES REPORT' in captured.out
base_args = [
'backtesting-analysis',
'--config', 'config.json',
'--datadir', str(testdatadir),
'--user-data-dir', str(tmpdir),
]
# test group 0 and indicator list
args = get_args(base_args +
['--analysis-groups', "0",
'--indicator-list', "close", "rsi", "profit_abs"]
)
start_analysis_entries_exits(args)
captured = capsys.readouterr()
assert 'LTC/BTC' in captured.out
assert 'ETH/BTC' in captured.out
assert 'enter_tag_long_a' in captured.out
assert 'enter_tag_long_b' in captured.out
assert 'exit_signal' in captured.out
assert 'roi' in captured.out
assert 'stop_loss' in captured.out
assert 'trailing_stop_loss' in captured.out
assert '0.5' in captured.out
assert '-4' in captured.out
assert '-2' in captured.out
assert '-3.5' in captured.out
assert '50' in captured.out
assert '0' in captured.out
assert '0.01616' in captured.out
assert '34.049' in captured.out
assert '0.104104' in captured.out
assert '47.0996' in captured.out
# test group 1
args = get_args(base_args + ['--analysis-groups', "1"])
start_analysis_entries_exits(args)
captured = capsys.readouterr()
assert 'enter_tag_long_a' in captured.out
assert 'enter_tag_long_b' in captured.out
assert 'total_profit_pct' in captured.out
assert '-3.5' in captured.out
assert '-1.75' in captured.out
assert '-7.5' in captured.out
assert '-3.75' in captured.out
assert '0' in captured.out
# test group 2
args = get_args(base_args + ['--analysis-groups', "2"])
start_analysis_entries_exits(args)
captured = capsys.readouterr()
assert 'enter_tag_long_a' in captured.out
assert 'enter_tag_long_b' in captured.out
assert 'exit_signal' in captured.out
assert 'roi' in captured.out
assert 'stop_loss' in captured.out
assert 'trailing_stop_loss' in captured.out
assert 'total_profit_pct' in captured.out
assert '-10' in captured.out
assert '-5' in captured.out
assert '2.5' in captured.out
# test group 3
args = get_args(base_args + ['--analysis-groups', "3"])
start_analysis_entries_exits(args)
captured = capsys.readouterr()
assert 'LTC/BTC' in captured.out
assert 'ETH/BTC' in captured.out
assert 'enter_tag_long_a' in captured.out
assert 'enter_tag_long_b' in captured.out
assert 'total_profit_pct' in captured.out
assert '-7.5' in captured.out
assert '-3.75' in captured.out
assert '-1.75' in captured.out
assert '0' in captured.out
assert '2' in captured.out
# test group 4
args = get_args(base_args + ['--analysis-groups', "4"])
start_analysis_entries_exits(args)
captured = capsys.readouterr()
assert 'LTC/BTC' in captured.out
assert 'ETH/BTC' in captured.out
assert 'enter_tag_long_a' in captured.out
assert 'enter_tag_long_b' in captured.out
assert 'exit_signal' in captured.out
assert 'roi' in captured.out
assert 'stop_loss' in captured.out
assert 'trailing_stop_loss' in captured.out
assert 'total_profit_pct' in captured.out
assert '-10' in captured.out
assert '-5' in captured.out
assert '-4' in captured.out
assert '0.5' in captured.out
assert '1' in captured.out
assert '2.5' in captured.out

View File

@ -154,6 +154,7 @@ def test_stoploss_adjust_binance(mocker, default_conf, sl1, sl2, sl3, side):
order = { order = {
'type': 'stop_loss_limit', 'type': 'stop_loss_limit',
'price': 1500, 'price': 1500,
'stopPrice': 1500,
'info': {'stopPrice': 1500}, 'info': {'stopPrice': 1500},
} }
assert exchange.stoploss_adjust(sl1, order, side=side) assert exchange.stoploss_adjust(sl1, order, side=side)
@ -490,11 +491,11 @@ def test_fill_leverage_tiers_binance_dryrun(default_conf, mocker, leverage_tiers
default_conf['margin_mode'] = MarginMode.ISOLATED default_conf['margin_mode'] = MarginMode.ISOLATED
exchange = get_patched_exchange(mocker, default_conf, api_mock, id="binance") exchange = get_patched_exchange(mocker, default_conf, api_mock, id="binance")
exchange.fill_leverage_tiers() exchange.fill_leverage_tiers()
assert len(exchange._leverage_tiers.keys()) > 100
leverage_tiers = leverage_tiers
for key, value in leverage_tiers.items(): for key, value in leverage_tiers.items():
assert exchange._leverage_tiers[key] == value v = exchange._leverage_tiers[key]
assert isinstance(v, list)
assert len(v) == len(value)
def test__set_leverage_binance(mocker, default_conf): def test__set_leverage_binance(mocker, default_conf):

View File

@ -199,8 +199,13 @@ class TestCCXTExchange():
l2 = exchange.fetch_l2_order_book(pair) l2 = exchange.fetch_l2_order_book(pair)
assert 'asks' in l2 assert 'asks' in l2
assert 'bids' in l2 assert 'bids' in l2
assert len(l2['asks']) >= 1
assert len(l2['bids']) >= 1
l2_limit_range = exchange._ft_has['l2_limit_range'] l2_limit_range = exchange._ft_has['l2_limit_range']
l2_limit_range_required = exchange._ft_has['l2_limit_range_required'] l2_limit_range_required = exchange._ft_has['l2_limit_range_required']
if exchangename == 'gateio':
# TODO: Gateio is unstable here at the moment, ignoring the limit partially.
return
for val in [1, 2, 5, 25, 100]: for val in [1, 2, 5, 25, 100]:
l2 = exchange.fetch_l2_order_book(pair, val) l2 = exchange.fetch_l2_order_book(pair, val)
if not l2_limit_range or val in l2_limit_range: if not l2_limit_range or val in l2_limit_range:

View File

@ -2155,6 +2155,8 @@ async def test__async_get_candle_history(default_conf, mocker, caplog, exchange_
@pytest.mark.asyncio @pytest.mark.asyncio
async def test__async_kucoin_get_candle_history(default_conf, mocker, caplog): async def test__async_kucoin_get_candle_history(default_conf, mocker, caplog):
from freqtrade.exchange.common import _reset_logging_mixin
_reset_logging_mixin()
caplog.set_level(logging.INFO) caplog.set_level(logging.INFO)
api_mock = MagicMock() api_mock = MagicMock()
api_mock.fetch_ohlcv = MagicMock(side_effect=ccxt.DDoSProtection( api_mock.fetch_ohlcv = MagicMock(side_effect=ccxt.DDoSProtection(
@ -2808,6 +2810,7 @@ def test_get_historic_trades_notsupported(default_conf, mocker, caplog, exchange
until=trades_history[-1][0]) until=trades_history[-1][0])
@pytest.mark.usefixtures("init_persistence")
@pytest.mark.parametrize("exchange_name", EXCHANGES) @pytest.mark.parametrize("exchange_name", EXCHANGES)
def test_cancel_order_dry_run(default_conf, mocker, exchange_name): def test_cancel_order_dry_run(default_conf, mocker, exchange_name):
default_conf['dry_run'] = True default_conf['dry_run'] = True
@ -2973,6 +2976,7 @@ def test_cancel_stoploss_order_with_result(default_conf, mocker, exchange_name):
exchange.cancel_stoploss_order_with_result(order_id='_', pair='TKN/BTC', amount=123) exchange.cancel_stoploss_order_with_result(order_id='_', pair='TKN/BTC', amount=123)
@pytest.mark.usefixtures("init_persistence")
@pytest.mark.parametrize("exchange_name", EXCHANGES) @pytest.mark.parametrize("exchange_name", EXCHANGES)
def test_fetch_order(default_conf, mocker, exchange_name, caplog): def test_fetch_order(default_conf, mocker, exchange_name, caplog):
default_conf['dry_run'] = True default_conf['dry_run'] = True
@ -3025,6 +3029,7 @@ def test_fetch_order(default_conf, mocker, exchange_name, caplog):
order_id='_', pair='TKN/BTC') order_id='_', pair='TKN/BTC')
@pytest.mark.usefixtures("init_persistence")
@pytest.mark.parametrize("exchange_name", EXCHANGES) @pytest.mark.parametrize("exchange_name", EXCHANGES)
def test_fetch_stoploss_order(default_conf, mocker, exchange_name): def test_fetch_stoploss_order(default_conf, mocker, exchange_name):
# Don't test FTX here - that needs a separate test # Don't test FTX here - that needs a separate test
@ -3814,6 +3819,7 @@ def test_validate_trading_mode_and_margin_mode(
("bibox", "spot", {"has": {"fetchCurrencies": False}}), ("bibox", "spot", {"has": {"fetchCurrencies": False}}),
("bibox", "margin", {"has": {"fetchCurrencies": False}, "options": {"defaultType": "margin"}}), ("bibox", "margin", {"has": {"fetchCurrencies": False}, "options": {"defaultType": "margin"}}),
("bibox", "futures", {"has": {"fetchCurrencies": False}, "options": {"defaultType": "swap"}}), ("bibox", "futures", {"has": {"fetchCurrencies": False}, "options": {"defaultType": "swap"}}),
("bybit", "spot", {"options": {"defaultType": "spot"}}),
("bybit", "futures", {"options": {"defaultType": "linear"}}), ("bybit", "futures", {"options": {"defaultType": "linear"}}),
("ftx", "futures", {"options": {"defaultType": "swap"}}), ("ftx", "futures", {"options": {"defaultType": "swap"}}),
("gateio", "futures", {"options": {"defaultType": "swap"}}), ("gateio", "futures", {"options": {"defaultType": "swap"}}),
@ -3912,6 +3918,70 @@ def test_calculate_funding_fees(
) == kraken_fee ) == kraken_fee
@pytest.mark.parametrize(
'mark_price,funding_rate,futures_funding_rate', [
(1000, 0.001, None),
(1000, 0.001, 0.01),
(1000, 0.001, 0.0),
(1000, 0.001, -0.01),
])
def test_combine_funding_and_mark(
default_conf,
mocker,
funding_rate,
mark_price,
futures_funding_rate,
):
exchange = get_patched_exchange(mocker, default_conf)
prior2_date = timeframe_to_prev_date('1h', datetime.now(timezone.utc) - timedelta(hours=2))
prior_date = timeframe_to_prev_date('1h', datetime.now(timezone.utc) - timedelta(hours=1))
trade_date = timeframe_to_prev_date('1h', datetime.now(timezone.utc))
funding_rates = DataFrame([
{'date': prior2_date, 'open': funding_rate},
{'date': prior_date, 'open': funding_rate},
{'date': trade_date, 'open': funding_rate},
])
mark_rates = DataFrame([
{'date': prior2_date, 'open': mark_price},
{'date': prior_date, 'open': mark_price},
{'date': trade_date, 'open': mark_price},
])
df = exchange.combine_funding_and_mark(funding_rates, mark_rates, futures_funding_rate)
assert 'open_mark' in df.columns
assert 'open_fund' in df.columns
assert len(df) == 3
funding_rates = DataFrame([
{'date': trade_date, 'open': funding_rate},
])
mark_rates = DataFrame([
{'date': prior2_date, 'open': mark_price},
{'date': prior_date, 'open': mark_price},
{'date': trade_date, 'open': mark_price},
])
df = exchange.combine_funding_and_mark(funding_rates, mark_rates, futures_funding_rate)
if futures_funding_rate is not None:
assert len(df) == 3
assert df.iloc[0]['open_fund'] == futures_funding_rate
assert df.iloc[1]['open_fund'] == futures_funding_rate
assert df.iloc[2]['open_fund'] == funding_rate
else:
assert len(df) == 1
# Empty funding rates
funding_rates = DataFrame([], columns=['date', 'open'])
df = exchange.combine_funding_and_mark(funding_rates, mark_rates, futures_funding_rate)
if futures_funding_rate is not None:
assert len(df) == 3
assert df.iloc[0]['open_fund'] == futures_funding_rate
assert df.iloc[1]['open_fund'] == futures_funding_rate
assert df.iloc[2]['open_fund'] == futures_funding_rate
else:
assert len(df) == 0
def test_get_or_calculate_liquidation_price(mocker, default_conf): def test_get_or_calculate_liquidation_price(mocker, default_conf):
api_mock = MagicMock() api_mock = MagicMock()

View File

@ -174,6 +174,7 @@ def test_stoploss_adjust_ftx(mocker, default_conf, sl1, sl2, sl3, side):
assert not exchange.stoploss_adjust(sl3, order, side=side) assert not exchange.stoploss_adjust(sl3, order, side=side)
@pytest.mark.usefixtures("init_persistence")
def test_fetch_stoploss_order_ftx(default_conf, mocker, limit_sell_order, limit_buy_order): def test_fetch_stoploss_order_ftx(default_conf, mocker, limit_sell_order, limit_buy_order):
default_conf['dry_run'] = True default_conf['dry_run'] = True
order = MagicMock() order = MagicMock()

View File

@ -33,7 +33,14 @@ def test_validate_order_types_gateio(default_conf, mocker):
match=r'Exchange .* does not support market orders.'): match=r'Exchange .* does not support market orders.'):
ExchangeResolver.load_exchange('gateio', default_conf, True) ExchangeResolver.load_exchange('gateio', default_conf, True)
# market-orders supported on futures markets.
default_conf['trading_mode'] = 'futures'
default_conf['margin_mode'] = 'isolated'
ex = ExchangeResolver.load_exchange('gateio', default_conf, True)
assert ex
@pytest.mark.usefixtures("init_persistence")
def test_fetch_stoploss_order_gateio(default_conf, mocker): def test_fetch_stoploss_order_gateio(default_conf, mocker):
exchange = get_patched_exchange(mocker, default_conf, id='gateio') exchange = get_patched_exchange(mocker, default_conf, id='gateio')

View File

@ -123,5 +123,5 @@ def test_stoploss_adjust_kucoin(mocker, default_conf):
assert exchange.stoploss_adjust(1501, order, 'sell') assert exchange.stoploss_adjust(1501, order, 'sell')
assert not exchange.stoploss_adjust(1499, order, 'sell') assert not exchange.stoploss_adjust(1499, order, 'sell')
# Test with invalid order case # Test with invalid order case
order['info']['stop'] = None order['stopPrice'] = None
assert not exchange.stoploss_adjust(1501, order, 'sell') assert exchange.stoploss_adjust(1501, order, 'sell')

View File

@ -6,7 +6,7 @@ import pytest
from freqtrade.enums import MarginMode, TradingMode from freqtrade.enums import MarginMode, TradingMode
from freqtrade.enums.candletype import CandleType from freqtrade.enums.candletype import CandleType
from freqtrade.exchange.exchange import timeframe_to_minutes from freqtrade.exchange.exchange import timeframe_to_minutes
from tests.conftest import get_patched_exchange from tests.conftest import get_mock_coro, get_patched_exchange
from tests.exchange.test_exchange import ccxt_exceptionhandlers from tests.exchange.test_exchange import ccxt_exceptionhandlers
@ -273,7 +273,7 @@ def test_load_leverage_tiers_okx(default_conf, mocker, markets):
'fetchLeverageTiers': False, 'fetchLeverageTiers': False,
'fetchMarketLeverageTiers': True, 'fetchMarketLeverageTiers': True,
}) })
api_mock.fetch_market_leverage_tiers = MagicMock(side_effect=[ api_mock.fetch_market_leverage_tiers = get_mock_coro(side_effect=[
[ [
{ {
'tier': 1, 'tier': 1,

View File

@ -7,6 +7,7 @@ import pytest
from freqtrade.data.history import get_timerange from freqtrade.data.history import get_timerange
from freqtrade.enums import ExitType from freqtrade.enums import ExitType
from freqtrade.optimize.backtesting import Backtesting from freqtrade.optimize.backtesting import Backtesting
from freqtrade.persistence.trade_model import LocalTrade
from tests.conftest import patch_exchange from tests.conftest import patch_exchange
from tests.optimize import (BTContainer, BTrade, _build_backtest_dataframe, from tests.optimize import (BTContainer, BTrade, _build_backtest_dataframe,
_get_frame_time_from_offset, tests_timeframe) _get_frame_time_from_offset, tests_timeframe)
@ -964,5 +965,7 @@ def test_backtest_results(default_conf, fee, mocker, caplog, data: BTContainer)
assert res.open_date == _get_frame_time_from_offset(trade.open_tick) assert res.open_date == _get_frame_time_from_offset(trade.open_tick)
assert res.close_date == _get_frame_time_from_offset(trade.close_tick) assert res.close_date == _get_frame_time_from_offset(trade.close_tick)
assert res.is_short == trade.is_short assert res.is_short == trade.is_short
assert len(LocalTrade.trades) == len(data.trades)
assert len(LocalTrade.trades_open) == 0
backtesting.cleanup() backtesting.cleanup()
del backtesting del backtesting

View File

@ -795,10 +795,27 @@ def test_backtest_one(default_conf, fee, mocker, testdatadir) -> None:
'is_open': [False, False], 'is_open': [False, False],
'enter_tag': [None, None], 'enter_tag': [None, None],
"is_short": [False, False], "is_short": [False, False],
'open_timestamp': [1517251200000, 1517283000000],
'close_timestamp': [1517265300000, 1517285400000],
'orders': [
[
{'amount': 0.00957442, 'safe_price': 0.104445, 'ft_order_side': 'buy',
'order_filled_timestamp': 1517251200000, 'ft_is_entry': True},
{'amount': 0.00957442, 'safe_price': 0.10496853383458644, 'ft_order_side': 'sell',
'order_filled_timestamp': 1517265300000, 'ft_is_entry': False}
], [
{'amount': 0.0097064, 'safe_price': 0.10302485, 'ft_order_side': 'buy',
'order_filled_timestamp': 1517283000000, 'ft_is_entry': True},
{'amount': 0.0097064, 'safe_price': 0.10354126528822055, 'ft_order_side': 'sell',
'order_filled_timestamp': 1517285400000, 'ft_is_entry': False}
]
]
}) })
pd.testing.assert_frame_equal(results, expected) pd.testing.assert_frame_equal(results, expected)
assert 'orders' in results.columns
data_pair = processed[pair] data_pair = processed[pair]
for _, t in results.iterrows(): for _, t in results.iterrows():
assert len(t['orders']) == 2
ln = data_pair.loc[data_pair["date"] == t["open_date"]] ln = data_pair.loc[data_pair["date"] == t["open_date"]]
# Check open trade rate alignes to open rate # Check open trade rate alignes to open rate
assert ln is not None assert ln is not None

View File

@ -22,7 +22,7 @@ def test_backtest_position_adjustment(default_conf, fee, mocker, testdatadir) ->
default_conf.update({ default_conf.update({
"stake_amount": 100.0, "stake_amount": 100.0,
"dry_run_wallet": 1000.0, "dry_run_wallet": 1000.0,
"strategy": "StrategyTestV2" "strategy": "StrategyTestV3"
}) })
backtesting = Backtesting(default_conf) backtesting = Backtesting(default_conf)
backtesting._set_strategy(backtesting.strategylist[0]) backtesting._set_strategy(backtesting.strategylist[0])
@ -70,9 +70,14 @@ def test_backtest_position_adjustment(default_conf, fee, mocker, testdatadir) ->
'is_open': [False, False], 'is_open': [False, False],
'enter_tag': [None, None], 'enter_tag': [None, None],
'is_short': [False, False], 'is_short': [False, False],
'open_timestamp': [1517251200000, 1517283000000],
'close_timestamp': [1517265300000, 1517285400000],
}) })
pd.testing.assert_frame_equal(results, expected) pd.testing.assert_frame_equal(results.drop(columns=['orders']), expected)
data_pair = processed[pair] data_pair = processed[pair]
assert len(results.iloc[0]['orders']) == 6
assert len(results.iloc[1]['orders']) == 2
for _, t in results.iterrows(): for _, t in results.iterrows():
ln = data_pair.loc[data_pair["date"] == t["open_date"]] ln = data_pair.loc[data_pair["date"] == t["open_date"]]
# Check open trade rate alignes to open rate # Check open trade rate alignes to open rate

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