diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91d53044d..195370339 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -272,6 +272,16 @@ jobs: pip install pyaml python build_helpers/pre_commit_update.py + pre-commit: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-python@v4 + with: + python-version: "3.10" + - uses: pre-commit/action@v3.0.0 + docs_check: runs-on: ubuntu-20.04 steps: @@ -302,7 +312,7 @@ jobs: # Notify only once - when CI completes (and after deploy) in case it's successfull 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, pre-commit ] runs-on: ubuntu-20.04 # Discord notification can't handle schedule events if: (github.event_name != 'schedule') @@ -327,7 +337,7 @@ jobs: webhookUrl: ${{ secrets.DISCORD_WEBHOOK }} deploy: - needs: [ build_linux, build_macos, build_windows, docs_check, mypy_version_check ] + needs: [ build_linux, build_macos, build_windows, docs_check, mypy_version_check, pre-commit ] runs-on: ubuntu-20.04 if: (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'release') && github.repository == 'freqtrade/freqtrade' @@ -397,15 +407,6 @@ jobs: run: | build_helpers/publish_docker_multi.sh - - name: Discord notification - uses: rjstone/discord-webhook-notify@v1 - if: always() && ( github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) && (github.event_name != 'schedule') - with: - severity: info - details: Deploy Succeeded! - webhookUrl: ${{ secrets.DISCORD_WEBHOOK }} - - deploy_arm: needs: [ deploy ] # Only run on 64bit machines @@ -433,3 +434,11 @@ jobs: BRANCH_NAME: ${{ steps.extract_branch.outputs.branch }} run: | build_helpers/publish_docker_arm64.sh + + - name: Discord notification + uses: rjstone/discord-webhook-notify@v1 + if: always() && ( github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) && (github.event_name != 'schedule') + with: + severity: info + details: Deploy Succeeded! + webhookUrl: ${{ secrets.DISCORD_WEBHOOK }} \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6f2b3ba3c..2cad0a7d3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: additional_dependencies: - types-cachetools==5.2.1 - types-filelock==3.2.7 - - types-requests==2.28.10 + - types-requests==2.28.11 - types-tabulate==0.8.11 - types-python-dateutil==2.8.19 # stages: [push] @@ -34,7 +34,9 @@ repos: exclude: | (?x)^( tests/.*| - .*\.svg + .*\.svg| + .*\.yml| + .*\.json )$ - id: mixed-line-ending - id: debug-statements diff --git a/build_helpers/TA_Lib-0.4.24-cp310-cp310-win_amd64.whl b/build_helpers/TA_Lib-0.4.24-cp310-cp310-win_amd64.whl deleted file mode 100644 index 9a96b7894..000000000 Binary files a/build_helpers/TA_Lib-0.4.24-cp310-cp310-win_amd64.whl and /dev/null differ diff --git a/build_helpers/TA_Lib-0.4.24-cp38-cp38-win_amd64.whl b/build_helpers/TA_Lib-0.4.24-cp38-cp38-win_amd64.whl deleted file mode 100644 index f6c66375b..000000000 Binary files a/build_helpers/TA_Lib-0.4.24-cp38-cp38-win_amd64.whl and /dev/null differ diff --git a/build_helpers/TA_Lib-0.4.24-cp39-cp39-win_amd64.whl b/build_helpers/TA_Lib-0.4.24-cp39-cp39-win_amd64.whl deleted file mode 100644 index 84d3e60ab..000000000 Binary files a/build_helpers/TA_Lib-0.4.24-cp39-cp39-win_amd64.whl and /dev/null differ diff --git a/build_helpers/TA_Lib-0.4.25-cp310-cp310-win_amd64.whl b/build_helpers/TA_Lib-0.4.25-cp310-cp310-win_amd64.whl new file mode 100644 index 000000000..c6435da0d Binary files /dev/null and b/build_helpers/TA_Lib-0.4.25-cp310-cp310-win_amd64.whl differ diff --git a/build_helpers/TA_Lib-0.4.25-cp38-cp38-win_amd64.whl b/build_helpers/TA_Lib-0.4.25-cp38-cp38-win_amd64.whl new file mode 100644 index 000000000..f2806db80 Binary files /dev/null and b/build_helpers/TA_Lib-0.4.25-cp38-cp38-win_amd64.whl differ diff --git a/build_helpers/TA_Lib-0.4.25-cp39-cp39-win_amd64.whl b/build_helpers/TA_Lib-0.4.25-cp39-cp39-win_amd64.whl new file mode 100644 index 000000000..0d4ceb3b4 Binary files /dev/null and b/build_helpers/TA_Lib-0.4.25-cp39-cp39-win_amd64.whl differ diff --git a/build_helpers/install_windows.ps1 b/build_helpers/install_windows.ps1 index 4caefa340..461726a03 100644 --- a/build_helpers/install_windows.ps1 +++ b/build_helpers/install_windows.ps1 @@ -6,13 +6,13 @@ python -m pip install --upgrade pip wheel $pyv = python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" if ($pyv -eq '3.8') { - pip install build_helpers\TA_Lib-0.4.24-cp38-cp38-win_amd64.whl + pip install build_helpers\TA_Lib-0.4.25-cp38-cp38-win_amd64.whl } if ($pyv -eq '3.9') { - pip install build_helpers\TA_Lib-0.4.24-cp39-cp39-win_amd64.whl + pip install build_helpers\TA_Lib-0.4.25-cp39-cp39-win_amd64.whl } if ($pyv -eq '3.10') { - pip install build_helpers\TA_Lib-0.4.24-cp310-cp310-win_amd64.whl + pip install build_helpers\TA_Lib-0.4.25-cp310-cp310-win_amd64.whl } pip install -r requirements-dev.txt pip install -e . diff --git a/config_examples/config_freqai.example.json b/config_examples/config_freqai.example.json index 3a8a3b273..db8ae7181 100644 --- a/config_examples/config_freqai.example.json +++ b/config_examples/config_freqai.example.json @@ -78,7 +78,7 @@ 10, 20 ], - "plot_feature_importance": false + "plot_feature_importances": 0 }, "data_split_parameters": { "test_size": 0.33, @@ -94,4 +94,4 @@ "internals": { "process_throttle_secs": 5 } -} \ No newline at end of file +} diff --git a/config_examples/config_full.example.json b/config_examples/config_full.example.json index 8155cb145..5a5096f81 100644 --- a/config_examples/config_full.example.json +++ b/config_examples/config_full.example.json @@ -172,7 +172,24 @@ "jwt_secret_key": "somethingrandom", "CORS_origins": [], "username": "freqtrader", - "password": "SuperSecurePassword" + "password": "SuperSecurePassword", + "ws_token": "secret_ws_t0ken." + }, + "external_message_consumer": { + "enabled": false, + "producers": [ + { + "name": "default", + "host": "127.0.0.2", + "port": 8080, + "ws_token": "secret_ws_t0ken." + } + ], + "wait_timeout": 300, + "ping_timeout": 10, + "sleep_time": 10, + "remove_entry_exit_signals": false, + "message_size_limit": 8 }, "bot_name": "freqtrade", "db_url": "sqlite:///tradesv3.sqlite", diff --git a/docker/Dockerfile.freqai b/docker/Dockerfile.freqai index af9da4c25..e9f04f3d6 100644 --- a/docker/Dockerfile.freqai +++ b/docker/Dockerfile.freqai @@ -1,58 +1,8 @@ -FROM python:3.10.6-slim-bullseye as base - -# Setup env -ENV LANG C.UTF-8 -ENV LC_ALL C.UTF-8 -ENV PYTHONDONTWRITEBYTECODE 1 -ENV PYTHONFAULTHANDLER 1 -ENV PATH=/home/ftuser/.local/bin:$PATH -ENV FT_APP_ENV="docker" - -# Prepare environment -RUN mkdir /freqtrade \ - && apt-get update \ - && apt-get -y install sudo libatlas3-base curl sqlite3 libhdf5-serial-dev \ - && apt-get clean \ - && useradd -u 1000 -G sudo -U -m -s /bin/bash ftuser \ - && chown ftuser:ftuser /freqtrade \ - # Allow sudoers - && echo "ftuser ALL=(ALL) NOPASSWD: /bin/chown" >> /etc/sudoers - -WORKDIR /freqtrade +ARG sourceimage=freqtradeorg/freqtrade +ARG sourcetag=develop +FROM ${sourceimage}:${sourcetag} # Install dependencies -FROM base as python-deps -RUN apt-get update \ - && apt-get -y install build-essential libssl-dev git libffi-dev libgfortran5 pkg-config cmake gcc \ - && apt-get clean \ - && pip install --upgrade pip +COPY requirements-freqai.txt /freqtrade/ -# Install TA-lib -COPY build_helpers/* /tmp/ -RUN cd /tmp && /tmp/install_ta-lib.sh && rm -r /tmp/*ta-lib* -ENV LD_LIBRARY_PATH /usr/local/lib - -# Install dependencies -COPY --chown=ftuser:ftuser requirements.txt requirements-hyperopt.txt requirements-freqai.txt /freqtrade/ -USER ftuser -RUN pip install --user --no-cache-dir numpy \ - && pip install --user --no-cache-dir -r requirements-freqai.txt - -# Copy dependencies to runtime-image -FROM base as runtime-image -COPY --from=python-deps /usr/local/lib /usr/local/lib -ENV LD_LIBRARY_PATH /usr/local/lib - -COPY --from=python-deps --chown=ftuser:ftuser /home/ftuser/.local /home/ftuser/.local - -USER ftuser -# Install and execute -COPY --chown=ftuser:ftuser . /freqtrade/ - -RUN pip install -e . --user --no-cache-dir --no-build-isolation \ - && mkdir /freqtrade/user_data/ \ - && freqtrade install-ui - -ENTRYPOINT ["freqtrade"] -# Default to trade mode -CMD [ "trade" ] +RUN pip install -r requirements-freqai.txt --user --no-cache-dir diff --git a/docker/Dockerfile.jupyter b/docker/Dockerfile.jupyter index 7d603c667..d86980bdf 100644 --- a/docker/Dockerfile.jupyter +++ b/docker/Dockerfile.jupyter @@ -1,7 +1,8 @@ FROM freqtradeorg/freqtrade:develop_plot -RUN pip install jupyterlab --user --no-cache-dir +# Pin jupyter-client to avoid tornado version conflict +RUN pip install jupyterlab jupyter-client==7.3.4 --user --no-cache-dir # Empty the ENTRYPOINT to allow all commands ENTRYPOINT [] diff --git a/docker/docker-compose-jupyter.yml b/docker/docker-compose-jupyter.yml index 11a01705c..3df82365f 100644 --- a/docker/docker-compose-jupyter.yml +++ b/docker/docker-compose-jupyter.yml @@ -10,7 +10,7 @@ services: ports: - "127.0.0.1:8888:8888" volumes: - - "./user_data:/freqtrade/user_data" + - "../user_data:/freqtrade/user_data" # Default command used when running `docker compose up` command: > jupyter lab --port=8888 --ip 0.0.0.0 --allow-root diff --git a/docs/assets/binance_futures_settings.png b/docs/assets/binance_futures_settings.png new file mode 100644 index 000000000..a3f7a2c70 Binary files /dev/null and b/docs/assets/binance_futures_settings.png differ diff --git a/docs/assets/freqai_algorithm-diagram.jpg b/docs/assets/freqai_algorithm-diagram.jpg new file mode 100644 index 000000000..4126aee65 Binary files /dev/null and b/docs/assets/freqai_algorithm-diagram.jpg differ diff --git a/docs/assets/freqai_inlier-metric.jpg b/docs/assets/freqai_inlier-metric.jpg new file mode 100644 index 000000000..d9cbbe8a1 Binary files /dev/null and b/docs/assets/freqai_inlier-metric.jpg differ diff --git a/docs/configuration.md b/docs/configuration.md index bb4f5ce41..556414e21 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -225,14 +225,16 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `webhook.webhookexitcancel` | Payload to send on exit order cancel. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details.
**Datatype:** String | `webhook.webhookexitfill` | Payload to send on exit order filled. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details.
**Datatype:** String | `webhook.webhookstatus` | Payload to send on status calls. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details.
**Datatype:** String -| | **Rest API / FreqUI** +| | **Rest API / FreqUI / Producer-Consumer** | `api_server.enabled` | Enable usage of API Server. See the [API Server documentation](rest-api.md) for more details.
**Datatype:** Boolean | `api_server.listen_ip_address` | Bind IP address. See the [API Server documentation](rest-api.md) for more details.
**Datatype:** IPv4 | `api_server.listen_port` | Bind Port. See the [API Server documentation](rest-api.md) for more details.
**Datatype:** Integer between 1024 and 65535 | `api_server.verbosity` | Logging verbosity. `info` will print all RPC Calls, while "error" will only display errors.
**Datatype:** Enum, either `info` or `error`. Defaults to `info`. | `api_server.username` | Username for API server. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `api_server.password` | Password for API server. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String +| `api_server.ws_token` | API token for the Message WebSocket. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `bot_name` | Name of the bot. Passed via API to a client - can be shown to distinguish / name bots.
*Defaults to `freqtrade`*
**Datatype:** String +| `external_message_consumer` | Enable [Producer/Consumer mode](producer-consumer.md) for more details.
**Datatype:** Dict | | **Other** | `initial_state` | Defines the initial application state. If set to stopped, then the bot has to be explicitly started via `/start` RPC command.
*Defaults to `stopped`.*
**Datatype:** Enum, either `stopped` or `running` | `force_entry_enable` | Enables the RPC Commands to force a Trade entry. More information below.
**Datatype:** Boolean diff --git a/docs/data-download.md b/docs/data-download.md index 2b76d4f74..700ca04f4 100644 --- a/docs/data-download.md +++ b/docs/data-download.md @@ -26,7 +26,7 @@ usage: freqtrade download-data [-h] [-v] [--logfile FILE] [-V] [-c PATH] [--timerange TIMERANGE] [--dl-trades] [--exchange EXCHANGE] [-t TIMEFRAMES [TIMEFRAMES ...]] [--erase] - [--data-format-ohlcv {json,jsongz,hdf5}] + [--data-format-ohlcv {json,jsongz,hdf5,feather,parquet}] [--data-format-trades {json,jsongz,hdf5}] [--trading-mode {spot,margin,futures}] [--prepend] @@ -55,7 +55,7 @@ optional arguments: list. Default: `1m 5m`. --erase Clean all existing data for the selected exchange/pairs/timeframes. - --data-format-ohlcv {json,jsongz,hdf5} + --data-format-ohlcv {json,jsongz,hdf5,feather,parquet} Storage format for downloaded candle (OHLCV) data. (default: `json`). --data-format-trades {json,jsongz,hdf5} @@ -76,7 +76,7 @@ Common arguments: `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 + -d PATH, --datadir PATH, --data-dir PATH Path to directory with historical backtesting data. --userdir PATH, --user-data-dir PATH Path to userdata directory. @@ -179,9 +179,11 @@ freqtrade download-data --exchange binance --pairs ETH/USDT XRP/USDT BTC/USDT -- Freqtrade currently supports 3 data-formats for both OHLCV and trades data: -* `json` (plain "text" json files) -* `jsongz` (a gzip-zipped version of json files) -* `hdf5` (a high performance datastore) +* `json` - plain "text" json files +* `jsongz` - a gzip-zipped version of json files +* `hdf5` - a high performance datastore +* `feather` - a dataformat based on Apache Arrow +* `parquet` - columnar datastore By default, OHLCV data is stored as `json` data, while trades data is stored as `jsongz` data. @@ -200,38 +202,74 @@ If the default data-format has been changed during download, then the keys `data !!! Note You can convert between data-formats using the [convert-data](#sub-command-convert-data) and [convert-trade-data](#sub-command-convert-trade-data) methods. +#### Dataformat comparison + +The following comparisons have been made with the following data, and by using the linux `time` command. + +``` +Found 6 pair / timeframe combinations. ++----------+-------------+--------+---------------------+---------------------+ +| Pair | Timeframe | Type | From | To | +|----------+-------------+--------+---------------------+---------------------| +| BTC/USDT | 5m | spot | 2017-08-17 04:00:00 | 2022-09-13 19:25:00 | +| ETH/USDT | 1m | spot | 2017-08-17 04:00:00 | 2022-09-13 19:26:00 | +| BTC/USDT | 1m | spot | 2017-08-17 04:00:00 | 2022-09-13 19:30:00 | +| XRP/USDT | 5m | spot | 2018-05-04 08:10:00 | 2022-09-13 19:15:00 | +| XRP/USDT | 1m | spot | 2018-05-04 08:11:00 | 2022-09-13 19:22:00 | +| ETH/USDT | 5m | spot | 2017-08-17 04:00:00 | 2022-09-13 19:20:00 | ++----------+-------------+--------+---------------------+---------------------+ +``` + +Timings have been taken in a not very scientific way with the following command, which forces reading the data into memory. + +``` bash +time freqtrade list-data --show-timerange --data-format-ohlcv +``` + +| Format | Size | timing | +|------------|-------------|-------------| +| `json` | 149Mb | 25.6s | +| `jsongz` | 39Mb | 27s | +| `hdf5` | 145Mb | 3.9s | +| `feather` | 72Mb | 3.5s | +| `parquet` | 83Mb | 3.8s | + +Size has been taken from the BTC/USDT 1m spot combination for the timerange specified above. + +To have a best performance/size mix, we recommend the use of either feather or parquet. + #### Sub-command convert data ``` usage: freqtrade convert-data [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] [--userdir PATH] [-p PAIRS [PAIRS ...]] --format-from - {json,jsongz,hdf5} --format-to - {json,jsongz,hdf5} [--erase] - [-t {1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} [{1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} ...]] + {json,jsongz,hdf5,feather,parquet} --format-to + {json,jsongz,hdf5,feather,parquet} [--erase] [--exchange EXCHANGE] + [-t TIMEFRAMES [TIMEFRAMES ...]] [--trading-mode {spot,margin,futures}] - [--candle-types {spot,,futures,mark,index,premiumIndex,funding_rate} [{spot,,futures,mark,index,premiumIndex,funding_rate} ...]] + [--candle-types {spot,futures,mark,index,premiumIndex,funding_rate} [{spot,futures,mark,index,premiumIndex,funding_rate} ...]] optional arguments: -h, --help show this help message and exit -p PAIRS [PAIRS ...], --pairs PAIRS [PAIRS ...] Limit command to these pairs. Pairs are space- separated. - --format-from {json,jsongz,hdf5} + --format-from {json,jsongz,hdf5,feather,parquet} Source format for data conversion. - --format-to {json,jsongz,hdf5} + --format-to {json,jsongz,hdf5,feather,parquet} Destination format for data conversion. --erase Clean all existing data for the selected exchange/pairs/timeframes. - -t {1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} [{1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} ...], --timeframes {1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} [{1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} ...] - Specify which tickers to download. Space-separated - list. Default: `1m 5m`. --exchange EXCHANGE Exchange name (default: `bittrex`). Only valid if no config is provided. - --trading-mode {spot,margin,futures} + -t TIMEFRAMES [TIMEFRAMES ...], --timeframes TIMEFRAMES [TIMEFRAMES ...] + Specify which tickers to download. Space-separated + list. Default: `1m 5m`. + --trading-mode {spot,margin,futures}, --tradingmode {spot,margin,futures} Select Trading mode - --candle-types {spot,,futures,mark,index,premiumIndex,funding_rate} [{spot,,futures,mark,index,premiumIndex,funding_rate} ...] + --candle-types {spot,futures,mark,index,premiumIndex,funding_rate} [{spot,futures,mark,index,premiumIndex,funding_rate} ...] Select candle type to use Common arguments: @@ -245,7 +283,7 @@ Common arguments: `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 + -d PATH, --datadir PATH, --data-dir PATH Path to directory with historical backtesting data. --userdir PATH, --user-data-dir PATH Path to userdata directory. @@ -267,20 +305,24 @@ freqtrade convert-data --format-from json --format-to jsongz --datadir ~/.freqtr usage: freqtrade convert-trade-data [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] [--userdir PATH] [-p PAIRS [PAIRS ...]] --format-from - {json,jsongz,hdf5} --format-to - {json,jsongz,hdf5} [--erase] + {json,jsongz,hdf5,feather,parquet} + --format-to + {json,jsongz,hdf5,feather,parquet} + [--erase] [--exchange EXCHANGE] optional arguments: -h, --help show this help message and exit -p PAIRS [PAIRS ...], --pairs PAIRS [PAIRS ...] - Show profits for only these pairs. Pairs are space- + Limit command to these pairs. Pairs are space- separated. - --format-from {json,jsongz,hdf5} + --format-from {json,jsongz,hdf5,feather,parquet} Source format for data conversion. - --format-to {json,jsongz,hdf5} + --format-to {json,jsongz,hdf5,feather,parquet} Destination format for data conversion. --erase Clean all existing data for the selected exchange/pairs/timeframes. + --exchange EXCHANGE Exchange name (default: `bittrex`). Only valid if no + config is provided. Common arguments: -v, --verbose Verbose mode (-vv for more, -vvv to get all messages). @@ -293,7 +335,7 @@ Common arguments: `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 + -d PATH, --datadir PATH, --data-dir PATH Path to directory with historical backtesting data. --userdir PATH, --user-data-dir PATH Path to userdata directory. @@ -318,9 +360,9 @@ This command will allow you to repeat this last step for additional timeframes w usage: freqtrade trades-to-ohlcv [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] [--userdir PATH] [-p PAIRS [PAIRS ...]] - [-t {1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} [{1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} ...]] + [-t TIMEFRAMES [TIMEFRAMES ...]] [--exchange EXCHANGE] - [--data-format-ohlcv {json,jsongz,hdf5}] + [--data-format-ohlcv {json,jsongz,hdf5,feather,parquet}] [--data-format-trades {json,jsongz,hdf5}] optional arguments: @@ -328,12 +370,12 @@ optional arguments: -p PAIRS [PAIRS ...], --pairs PAIRS [PAIRS ...] Limit command to these pairs. Pairs are space- separated. - -t {1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} [{1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} ...], --timeframes {1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} [{1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w,2w,1M,1y} ...] + -t TIMEFRAMES [TIMEFRAMES ...], --timeframes TIMEFRAMES [TIMEFRAMES ...] Specify which tickers to download. Space-separated list. Default: `1m 5m`. --exchange EXCHANGE Exchange name (default: `bittrex`). Only valid if no config is provided. - --data-format-ohlcv {json,jsongz,hdf5} + --data-format-ohlcv {json,jsongz,hdf5,feather,parquet} Storage format for downloaded candle (OHLCV) data. (default: `json`). --data-format-trades {json,jsongz,hdf5} @@ -351,7 +393,7 @@ Common arguments: `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 + -d PATH, --datadir PATH, --data-dir PATH Path to directory with historical backtesting data. --userdir PATH, --user-data-dir PATH Path to userdata directory. @@ -371,7 +413,7 @@ You can get a list of downloaded data using the `list-data` sub-command. ``` usage: freqtrade list-data [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] [--userdir PATH] [--exchange EXCHANGE] - [--data-format-ohlcv {json,jsongz,hdf5}] + [--data-format-ohlcv {json,jsongz,hdf5,feather,parquet}] [-p PAIRS [PAIRS ...]] [--trading-mode {spot,margin,futures}] [--show-timerange] @@ -380,13 +422,13 @@ optional arguments: -h, --help show this help message and exit --exchange EXCHANGE Exchange name (default: `bittrex`). Only valid if no config is provided. - --data-format-ohlcv {json,jsongz,hdf5} + --data-format-ohlcv {json,jsongz,hdf5,feather,parquet} Storage format for downloaded candle (OHLCV) data. (default: `json`). -p PAIRS [PAIRS ...], --pairs PAIRS [PAIRS ...] Limit command to these pairs. Pairs are space- separated. - --trading-mode {spot,margin,futures} + --trading-mode {spot,margin,futures}, --tradingmode {spot,margin,futures} Select Trading mode --show-timerange Show timerange available for available data. (May take a while to calculate). @@ -402,7 +444,7 @@ Common arguments: `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 + -d PATH, --datadir PATH, --data-dir PATH Path to directory with historical backtesting data. --userdir PATH, --user-data-dir PATH Path to userdata directory. diff --git a/docs/exchanges.md b/docs/exchanges.md index a9ba16c64..980d102b2 100644 --- a/docs/exchanges.md +++ b/docs/exchanges.md @@ -60,11 +60,18 @@ Binance supports [time_in_force](configuration.md#understand-order_time_in_force Binance supports `stoploss_on_exchange` and uses `stop-loss-limit` orders. It provides great advantages, so we recommend to benefit from it by enabling stoploss on exchange. On futures, Binance supports both `stop-limit` as well as `stop-market` orders. You can use either `"limit"` or `"market"` in the `order_types.stoploss` configuration setting to decide which type to use. -### Binance Blacklist +### Binance Blacklist recommendation For Binance, it is suggested to add `"BNB/"` to your blacklist to avoid issues, unless you are willing to maintain enough extra `BNB` on the account or unless you're willing to disable using `BNB` for fees. Binance accounts may use `BNB` for fees, and if a trade happens to be on `BNB`, further trades may consume this position and make the initial BNB trade unsellable as the expected amount is not there anymore. +### Binance sites + +Binance has been split into 2, and users must use the correct ccxt exchange ID for their exchange, otherwise API keys are not recognized. + +* [binance.com](https://www.binance.com/) - International users. Use exchange id: `binance`. +* [binance.us](https://www.binance.us/) - US based users. Use exchange id: `binanceus`. + ### Binance Futures Binance has specific (unfortunately complex) [Futures Trading Quantitative Rules](https://www.binance.com/en/support/faq/4f462ebe6ff445d4a170be7d9e897272) which need to be followed, and which prohibit a too low stake-amount (among others) for too many orders. @@ -87,12 +94,14 @@ When trading on Binance Futures market, orderbook must be used because there is }, ``` -### Binance sites +#### Binance futures settings -Binance has been split into 2, and users must use the correct ccxt exchange ID for their exchange, otherwise API keys are not recognized. +Users will also have to have the futures-setting "Position Mode" set to "One-way Mode", and "Asset Mode" set to "Single-Asset Mode". +These settings will be checked on startup, and freqtrade will show an error if this setting is wrong. -* [binance.com](https://www.binance.com/) - International users. Use exchange id: `binance`. -* [binance.us](https://www.binance.us/) - US based users. Use exchange id: `binanceus`. +![Binance futures settings](assets/binance_futures_settings.png) + +Freqtrade will not attempt to change these settings. ## Kraken diff --git a/docs/freqai-configuration.md b/docs/freqai-configuration.md new file mode 100644 index 000000000..50e75b658 --- /dev/null +++ b/docs/freqai-configuration.md @@ -0,0 +1,217 @@ +# Configuration + +`FreqAI` is configured through the typical [Freqtrade config file](configuration.md) and the standard [Freqtrade strategy](strategy-customization.md). Examples of `FreqAI` config and strategy files can be found in `config_examples/config_freqai.example.json` and `freqtrade/templates/FreqaiExampleStrategy.py`, respectively. + +## Setting up the configuration file + + Although there are plenty of additional parameters to choose from, as highlighted in the [parameter table](freqai-parameter-table.md#parameter-table), a `FreqAI` config must at minimum include the following parameters (the parameter values are only examples): + +```json + "freqai": { + "enabled": true, + "purge_old_models": true, + "train_period_days": 30, + "backtest_period_days": 7, + "identifier" : "unique-id", + "feature_parameters" : { + "include_timeframes": ["5m","15m","4h"], + "include_corr_pairlist": [ + "ETH/USD", + "LINK/USD", + "BNB/USD" + ], + "label_period_candles": 24, + "include_shifted_candles": 2, + "indicator_periods_candles": [10, 20] + }, + "data_split_parameters" : { + "test_size": 0.25 + }, + "model_training_parameters" : { + "n_estimators": 100 + }, + } +``` + +A full example config is available in `config_examples/config_freqai.example.json`. + +## Building a `FreqAI` strategy + +The `FreqAI` strategy requires including the following lines of code in the standard [Freqtrade strategy](strategy-customization.md): + +```python + # user should define the maximum startup candle count (the largest number of candles + # passed to any single indicator) + startup_candle_count: int = 20 + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + + # the model will return all labels created by user in `populate_any_indicators` + # (& appended targets), an indication of whether or not the prediction should be accepted, + # the target mean/std values for each of the labels created by user in + # `populate_any_indicators()` for each training period. + + dataframe = self.freqai.start(dataframe, metadata, self) + + return dataframe + + def populate_any_indicators( + self, pair, df, tf, informative=None, set_generalized_indicators=False + ): + """ + Function designed to automatically generate, name and merge features + from user indicated timeframes in the configuration file. User controls the indicators + passed to the training/prediction by prepending indicators with `'%-' + coin ` + (see convention below). I.e. user should not prepend any supporting metrics + (e.g. bb_lowerband below) with % unless they explicitly want to pass that metric to the + model. + :param pair: pair to be used as informative + :param df: strategy dataframe which will receive merges from informatives + :param tf: timeframe of the dataframe which will modify the feature names + :param informative: the dataframe associated with the informative pair + :param coin: the name of the coin which will modify the feature names. + """ + + coin = pair.split('/')[0] + + if informative is None: + informative = self.dp.get_pair_dataframe(pair, tf) + + # first loop is automatically duplicating indicators for time periods + for t in self.freqai_info["feature_parameters"]["indicator_periods_candles"]: + t = int(t) + informative[f"%-{coin}rsi-period_{t}"] = ta.RSI(informative, timeperiod=t) + informative[f"%-{coin}mfi-period_{t}"] = ta.MFI(informative, timeperiod=t) + informative[f"%-{coin}adx-period_{t}"] = ta.ADX(informative, window=t) + + indicators = [col for col in informative if col.startswith("%")] + # This loop duplicates and shifts all indicators to add a sense of recency to data + for n in range(self.freqai_info["feature_parameters"]["include_shifted_candles"] + 1): + if n == 0: + continue + informative_shift = informative[indicators].shift(n) + informative_shift = informative_shift.add_suffix("_shift-" + str(n)) + informative = pd.concat((informative, informative_shift), axis=1) + + df = merge_informative_pair(df, informative, self.config["timeframe"], tf, ffill=True) + skip_columns = [ + (s + "_" + tf) for s in ["date", "open", "high", "low", "close", "volume"] + ] + df = df.drop(columns=skip_columns) + + # Add generalized indicators here (because in live, it will call this + # function to populate indicators during training). Notice how we ensure not to + # add them multiple times + if set_generalized_indicators: + + # user adds targets here by prepending them with &- (see convention below) + # If user wishes to use multiple targets, a multioutput prediction model + # needs to be used such as templates/CatboostPredictionMultiModel.py + df["&-s_close"] = ( + df["close"] + .shift(-self.freqai_info["feature_parameters"]["label_period_candles"]) + .rolling(self.freqai_info["feature_parameters"]["label_period_candles"]) + .mean() + / df["close"] + - 1 + ) + + return df + + +``` + +Notice how the `populate_any_indicators()` is where [features](freqai-feature-engineering.md#feature-engineering) and labels/targets are added. A full example strategy is available in `templates/FreqaiExampleStrategy.py`. + +Notice also the location of the labels under `if set_generalized_indicators:` at the bottom of the example. This is where single features and labels/targets should be added to the feature set to avoid duplication of them from various configuration parameters that multiply the feature set, such as `include_timeframes`. + +!!! Note + The `self.freqai.start()` function cannot be called outside the `populate_indicators()`. + +!!! Note + Features **must** be defined in `populate_any_indicators()`. Defining `FreqAI` features in `populate_indicators()` + will cause the algorithm to fail in live/dry mode. In order to add generalized features that are not associated with a specific pair or timeframe, the following structure inside `populate_any_indicators()` should be used + (as exemplified in `freqtrade/templates/FreqaiExampleStrategy.py`): + + ```python + def populate_any_indicators(self, metadata, pair, df, tf, informative=None, coin="", set_generalized_indicators=False): + + ... + + # Add generalized indicators here (because in live, it will call only this function to populate + # indicators for retraining). Notice how we ensure not to add them multiple times by associating + # these generalized indicators to the basepair/timeframe + if set_generalized_indicators: + df['%-day_of_week'] = (df["date"].dt.dayofweek + 1) / 7 + df['%-hour_of_day'] = (df['date'].dt.hour + 1) / 25 + + # user adds targets here by prepending them with &- (see convention below) + # If user wishes to use multiple targets, a multioutput prediction model + # needs to be used such as templates/CatboostPredictionMultiModel.py + df["&-s_close"] = ( + df["close"] + .shift(-self.freqai_info["feature_parameters"]["label_period_candles"]) + .rolling(self.freqai_info["feature_parameters"]["label_period_candles"]) + .mean() + / df["close"] + - 1 + ) + ``` + + Please see the example script located in `freqtrade/templates/FreqaiExampleStrategy.py` for a full example of `populate_any_indicators()`. + +## Important dataframe key patterns + +Below are the values you can expect to include/use inside a typical strategy dataframe (`df[]`): + +| DataFrame Key | Description | +|------------|-------------| +| `df['&*']` | Any dataframe column prepended with `&` in `populate_any_indicators()` is treated as a training target (label) inside `FreqAI` (typically following the naming convention `&-s*`). The names of these dataframe columns are fed back as the predictions. For example, to predict the price change in the next 40 candles (similar to `templates/FreqaiExampleStrategy.py`), you would set `df['&-s_close']`. `FreqAI` makes the predictions and gives them back under the same key (`df['&-s_close']`) to be used in `populate_entry/exit_trend()`.
**Datatype:** Depends on the output of the model. +| `df['&*_std/mean']` | Standard deviation and mean values of the defined labels during training (or live tracking with `fit_live_predictions_candles`). Commonly used to understand the rarity of a prediction (use the z-score as shown in `templates/FreqaiExampleStrategy.py` and explained [here](#creating-a-dynamic-target-threshold) to evaluate how often a particular prediction was observed during training or historically with `fit_live_predictions_candles`).
**Datatype:** Float. +| `df['do_predict']` | Indication of an outlier data point. The return value is integer between -1 and 2, which lets you know if the prediction is trustworthy or not. `do_predict==1` means that the prediction is trustworthy. If the Dissimilarity Index (DI, see details [here](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di)) of the input data point is above the threshold defined in the config, `FreqAI` will subtract 1 from `do_predict`, resulting in `do_predict==0`. If `use_SVM_to_remove_outliers()` is active, the Support Vector Machine (SVM, see details [here](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm)) may also detect outliers in training and prediction data. In this case, the SVM will also subtract 1 from `do_predict`. If the input data point was considered an outlier by the SVM but not by the DI, or vice versa, the result will be `do_predict==0`. If both the DI and the SVM considers the input data point to be an outlier, the result will be `do_predict==-1`. A particular case is when `do_predict == 2`, which means that the model has expired due to exceeding `expired_hours`.
**Datatype:** Integer between -1 and 2. +| `df['DI_values']` | Dissimilarity Index (DI) values are proxies for the level of confidence `FreqAI` has in the prediction. A lower DI means the prediction is close to the training data, i.e., higher prediction confidence. See details about the DI [here](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di).
**Datatype:** Float. +| `df['%*']` | Any dataframe column prepended with `%` in `populate_any_indicators()` is treated as a training feature. For example, you can include the RSI in the training feature set (similar to in `templates/FreqaiExampleStrategy.py`) by setting `df['%-rsi']`. See more details on how this is done [here](freqai-feature-engineering.md).
**Note:** Since the number of features prepended with `%` can multiply very quickly (10s of thousands of features is easily engineered using the multiplictative functionality described in the `feature_parameters` table shown above), these features are removed from the dataframe upon return from `FreqAI`. To keep a particular type of feature for plotting purposes, you would prepend it with `%%`.
**Datatype:** Depends on the output of the model. + +## Setting the `startup_candle_count` + +The `startup_candle_count` in the `FreqAI` strategy needs to be set up in the same way as in the standard Freqtrade strategy (see details [here](strategy-customization.md#strategy-startup-period)). This value is used by Freqtrade to ensure that a sufficient amount of data is provided when calling the `dataprovider`, to avoid any NaNs at the beginning of the first training. You can easily set this value by identifying the longest period (in candle units) which is passed to the indicator creation functions (e.g., Ta-Lib functions). In the presented example, `startup_candle_count` is 20 since this is the maximum value in `indicators_periods_candles`. + +!!! Note + There are instances where the Ta-Lib functions actually require more data than just the passed `period` or else the feature dataset gets populated with NaNs. Anecdotally, multiplying the `startup_candle_count` by 2 always leads to a fully NaN free training dataset. Hence, it is typically safest to multiply the expected `startup_candle_count` by 2. Look out for this log message to confirm that the data is clean: + + ``` + 2022-08-31 15:14:04 - freqtrade.freqai.data_kitchen - INFO - dropped 0 training points due to NaNs in populated dataset 4319. + ``` + +## Creating a dynamic target threshold + +Deciding when to enter or exit a trade can be done in a dynamic way to reflect current market conditions. `FreqAI` allows you to return additional information from the training of a model (more info [here](freqai-feature-engineering.md#returning-additional-info-from-training)). For example, the `&*_std/mean` return values describe the statistical distribution of the target/label *during the most recent training*. Comparing a given prediction to these values allows you to know the rarity of the prediction. In `templates/FreqaiExampleStrategy.py`, the `target_roi` and `sell_roi` are defined to be 1.25 z-scores away from the mean which causes predictions that are closer to the mean to be filtered out. + +```python +dataframe["target_roi"] = dataframe["&-s_close_mean"] + dataframe["&-s_close_std"] * 1.25 +dataframe["sell_roi"] = dataframe["&-s_close_mean"] - dataframe["&-s_close_std"] * 1.25 +``` + +To consider the population of *historical predictions* for creating the dynamic target instead of information from the training as discussed above, you would set `fit_live_prediction_candles` in the config to the number of historical prediction candles you wish to use to generate target statistics. + +```json + "freqai": { + "fit_live_prediction_candles": 300, + } +``` + +If this value is set, `FreqAI` will initially use the predictions from the training data and subsequently begin introducing real prediction data as it is generated. `FreqAI` will save this historical data to be reloaded if you stop and restart a model with the same `identifier`. + +## Using different prediction models + +`FreqAI` has multiple example prediction model libraries that are ready to be used as is via the flag `--freqaimodel`. These libraries include `Catboost`, `LightGBM`, and `XGBoost` regression, classification, and multi-target models, and can be found in `freqai/prediction_models/`. However, it is possible to customize and create your own prediction models using the `IFreqaiModel` class. You are encouraged to inherit `fit()`, `train()`, and `predict()` to let these customize various aspects of the training procedures. + +### Setting classifier targets + +`FreqAI` includes a variety of classifiers, such as the `CatboostClassifier` via the flag `--freqaimodel CatboostClassifier`. If you elects to use a classifier, the classes need to be set using strings. For example: + +```python +df['&s-up_or_down'] = np.where( df["close"].shift(-100) > df["close"], 'up', 'down') +``` + +Additionally, the example classifier models do not accommodate multiple labels, but they do allow multi-class classification within a single label column. diff --git a/docs/freqai-developers.md b/docs/freqai-developers.md new file mode 100644 index 000000000..4bff46f2f --- /dev/null +++ b/docs/freqai-developers.md @@ -0,0 +1,78 @@ +# Development + +## Project architecture + +The architecture and functions of `FreqAI` are generalized to encourages development of unique features, functions, models, etc. + +The class structure and a detailed algorithmic overview is depicted in the following diagram: + +![image](assets/freqai_algorithm-diagram.jpg) + +As shown, there are three distinct objects comprising `FreqAI`: + +* **IFreqaiModel** - A singular persistent object containing all the necessary logic to collect, store, and process data, engineer features, run training, and inference models. +* **FreqaiDataKitchen** - A non-persistent object which is created uniquely for each unique asset/model. Beyond metadata, it also contains a variety of data processing tools. +* **FreqaiDataDrawer** - A singular persistent object containing all the historical predictions, models, and save/load methods. + +There are a variety of built-in [prediction models](freqai-configuration.md#using-different-prediction-models) which inherit directly from `IFreqaiModel`. Each of these models have full access to all methods in `IFreqaiModel` and can therefore override any of those functions at will. However, advanced users will likely stick to overriding `fit()`, `train()`, `predict()`, and `data_cleaning_train/predict()`. + +## Data handling + +`FreqAI` aims to organize model files, prediction data, and meta data in a way that simplifies post-processing and enhances crash resilience by automatic data reloading. The data is saved in a file structure,`user_data_dir/models/`, which contains all the data associated with the trainings and backtests. The `FreqaiDataKitchen()` relies heavily on the file structure for proper training and inferencing and should therefore not be manually modified. + +### File structure + +The file structure is automatically generated based on the model `identifier` set in the [config](freqai-configuration.md#setting-up-the-configuration-file). The following structure shows where the data is stored for post processing: + +| Structure | Description | +|-----------|-------------| +| `config_*.json` | A copy of the model specific configuration file. | +| `historic_predictions.pkl` | A file containing all historic predictions generated during the lifetime of the `identifier` model during live deployment. `historic_predictions.pkl` is used to reload the model after a crash or a config change. A backup file is always held incase of corruption on the main file. **`FreqAI` automatically detects corruption and replaces the corrupted file with the backup**. | +| `pair_dictionary.json` | A file containing the training queue as well as the on disk location of the most recently trained model. | +| `sub-train-*_TIMESTAMP` | A folder containing all the files associated with a single model, such as:
+|| `*_metadata.json` - Metadata for the model, such as normalization max/mins, expected training feature list, etc.
+|| `*_model.*` - The model file saved to disk for reloading from a crash. Can be `joblib` (typical boosting libs), `zip` (stable_baselines), `hd5` (keras type), etc.
+|| `*_pca_object.pkl` - The [Principal component analysis (PCA)](freqai-feature-engineering.md#data-dimensionality-reduction-with-principal-component-analysis) transform (if `principal_component_analysis: true` is set in the config) which will be used to transform unseen prediction features.
+|| `*_svm_model.pkl` - The [Support Vector Machine (SVM)](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm) model which is used to detect outliers in unseen prediction features.
+|| `*_trained_df.pkl` - The dataframe containing all the training features used to train the `identifier` model. This is used for computing the [Dissimilarity Index (DI)](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di) and can also be used for post-processing.
+|| `*_trained_dates.df.pkl` - The dates associated with the `trained_df.pkl`, which is useful for post-processing. | + +The example file structure would look like this: + +``` +├── models +│   └── unique-id +│   ├── config_freqai.example.json +│   ├── historic_predictions.backup.pkl +│   ├── historic_predictions.pkl +│   ├── pair_dictionary.json +│   ├── sub-train-1INCH_1662821319 +│   │   ├── cb_1inch_1662821319_metadata.json +│   │   ├── cb_1inch_1662821319_model.joblib +│   │   ├── cb_1inch_1662821319_pca_object.pkl +│   │   ├── cb_1inch_1662821319_svm_model.joblib +│   │   ├── cb_1inch_1662821319_trained_dates_df.pkl +│   │   └── cb_1inch_1662821319_trained_df.pkl +│   ├── sub-train-1INCH_1662821371 +│   │   ├── cb_1inch_1662821371_metadata.json +│   │   ├── cb_1inch_1662821371_model.joblib +│   │   ├── cb_1inch_1662821371_pca_object.pkl +│   │   ├── cb_1inch_1662821371_svm_model.joblib +│   │   ├── cb_1inch_1662821371_trained_dates_df.pkl +│   │   └── cb_1inch_1662821371_trained_df.pkl +│   ├── sub-train-ADA_1662821344 +│   │   ├── cb_ada_1662821344_metadata.json +│   │   ├── cb_ada_1662821344_model.joblib +│   │   ├── cb_ada_1662821344_pca_object.pkl +│   │   ├── cb_ada_1662821344_svm_model.joblib +│   │   ├── cb_ada_1662821344_trained_dates_df.pkl +│   │   └── cb_ada_1662821344_trained_df.pkl +│   └── sub-train-ADA_1662821399 +│   ├── cb_ada_1662821399_metadata.json +│   ├── cb_ada_1662821399_model.joblib +│   ├── cb_ada_1662821399_pca_object.pkl +│   ├── cb_ada_1662821399_svm_model.joblib +│   ├── cb_ada_1662821399_trained_dates_df.pkl +│   └── cb_ada_1662821399_trained_df.pkl + +``` diff --git a/docs/freqai-feature-engineering.md b/docs/freqai-feature-engineering.md new file mode 100644 index 000000000..8f061b9fd --- /dev/null +++ b/docs/freqai-feature-engineering.md @@ -0,0 +1,268 @@ +# Feature engineering + +## Defining the features + +Low level feature engineering is performed in the user strategy within a function called `populate_any_indicators()`. That function sets the `base features` such as, `RSI`, `MFI`, `EMA`, `SMA`, time of day, volume, etc. The `base features` can be custom indicators or they can be imported from any technical-analysis library that you can find. One important syntax rule is that all `base features` string names are prepended with `%`, while labels/targets are prepended with `&`. + +Meanwhile, high level feature engineering is handled within `"feature_parameters":{}` in the `FreqAI` config. Within this file, it is possible to decide large scale feature expansions on top of the `base_features` such as "including correlated pairs" or "including informative timeframes" or even "including recent candles." + +It is advisable to start from the template `populate_any_indicators()` in the source provided example strategy (found in `templates/FreqaiExampleStrategy.py`) to ensure that the feature definitions are following the correct conventions. Here is an example of how to set the indicators and labels in the strategy: + +```python + def populate_any_indicators( + self, pair, df, tf, informative=None, set_generalized_indicators=False + ): + """ + Function designed to automatically generate, name, and merge features + from user-indicated timeframes in the configuration file. The user controls the indicators + passed to the training/prediction by prepending indicators with `'%-' + coin ` + (see convention below). I.e., the user should not prepend any supporting metrics + (e.g., bb_lowerband below) with % unless they explicitly want to pass that metric to the + model. + :param pair: pair to be used as informative + :param df: strategy dataframe which will receive merges from informatives + :param tf: timeframe of the dataframe which will modify the feature names + :param informative: the dataframe associated with the informative pair + :param coin: the name of the coin which will modify the feature names. + """ + + coin = pair.split('/')[0] + + if informative is None: + informative = self.dp.get_pair_dataframe(pair, tf) + + # first loop is automatically duplicating indicators for time periods + for t in self.freqai_info["feature_parameters"]["indicator_periods_candles"]: + t = int(t) + informative[f"%-{coin}rsi-period_{t}"] = ta.RSI(informative, timeperiod=t) + informative[f"%-{coin}mfi-period_{t}"] = ta.MFI(informative, timeperiod=t) + informative[f"%-{coin}adx-period_{t}"] = ta.ADX(informative, window=t) + + bollinger = qtpylib.bollinger_bands( + qtpylib.typical_price(informative), window=t, stds=2.2 + ) + informative[f"{coin}bb_lowerband-period_{t}"] = bollinger["lower"] + informative[f"{coin}bb_middleband-period_{t}"] = bollinger["mid"] + informative[f"{coin}bb_upperband-period_{t}"] = bollinger["upper"] + + informative[f"%-{coin}bb_width-period_{t}"] = ( + informative[f"{coin}bb_upperband-period_{t}"] + - informative[f"{coin}bb_lowerband-period_{t}"] + ) / informative[f"{coin}bb_middleband-period_{t}"] + informative[f"%-{coin}close-bb_lower-period_{t}"] = ( + informative["close"] / informative[f"{coin}bb_lowerband-period_{t}"] + ) + + informative[f"%-{coin}relative_volume-period_{t}"] = ( + informative["volume"] / informative["volume"].rolling(t).mean() + ) + + indicators = [col for col in informative if col.startswith("%")] + # This loop duplicates and shifts all indicators to add a sense of recency to data + for n in range(self.freqai_info["feature_parameters"]["include_shifted_candles"] + 1): + if n == 0: + continue + informative_shift = informative[indicators].shift(n) + informative_shift = informative_shift.add_suffix("_shift-" + str(n)) + informative = pd.concat((informative, informative_shift), axis=1) + + df = merge_informative_pair(df, informative, self.config["timeframe"], tf, ffill=True) + skip_columns = [ + (s + "_" + tf) for s in ["date", "open", "high", "low", "close", "volume"] + ] + df = df.drop(columns=skip_columns) + + # Add generalized indicators here (because in live, it will call this + # function to populate indicators during training). Notice how we ensure not to + # add them multiple times + if set_generalized_indicators: + df["%-day_of_week"] = (df["date"].dt.dayofweek + 1) / 7 + df["%-hour_of_day"] = (df["date"].dt.hour + 1) / 25 + + # user adds targets here by prepending them with &- (see convention below) + # If user wishes to use multiple targets, a multioutput prediction model + # needs to be used such as templates/CatboostPredictionMultiModel.py + df["&-s_close"] = ( + df["close"] + .shift(-self.freqai_info["feature_parameters"]["label_period_candles"]) + .rolling(self.freqai_info["feature_parameters"]["label_period_candles"]) + .mean() + / df["close"] + - 1 + ) + + return df +``` + +In the presented example, the user does not wish to pass the `bb_lowerband` as a feature to the model, +and has therefore not prepended it with `%`. The user does, however, wish to pass `bb_width` to the +model for training/prediction and has therefore prepended it with `%`. + +After having defined the `base features`, the next step is to expand upon them using the powerful `feature_parameters` in the configuration file: + +```json + "freqai": { + //... + "feature_parameters" : { + "include_timeframes": ["5m","15m","4h"], + "include_corr_pairlist": [ + "ETH/USD", + "LINK/USD", + "BNB/USD" + ], + "label_period_candles": 24, + "include_shifted_candles": 2, + "indicator_periods_candles": [10, 20] + }, + //... + } +``` + +The `include_timeframes` in the config above are the timeframes (`tf`) of each call to `populate_any_indicators()` in the strategy. In the presented case, the user is asking for the `5m`, `15m`, and `4h` timeframes of the `rsi`, `mfi`, `roc`, and `bb_width` to be included in the feature set. + +You can ask for each of the defined features to be included also for informative pairs using the `include_corr_pairlist`. This means that the feature set will include all the features from `populate_any_indicators` on all the `include_timeframes` for each of the correlated pairs defined in the config (`ETH/USD`, `LINK/USD`, and `BNB/USD` in the presented example). + +`include_shifted_candles` indicates the number of previous candles to include in the feature set. For example, `include_shifted_candles: 2` tells `FreqAI` to include the past 2 candles for each of the features in the feature set. + +In total, the number of features the user of the presented example strat has created is: length of `include_timeframes` * no. features in `populate_any_indicators()` * length of `include_corr_pairlist` * no. `include_shifted_candles` * length of `indicator_periods_candles` + $= 3 * 3 * 3 * 2 * 2 = 108$. + +### Returning additional info from training + +Important metrics can be returned to the strategy at the end of each model training by assigning them to `dk.data['extra_returns_per_train']['my_new_value'] = XYZ` inside the custom prediction model class. + +`FreqAI` takes the `my_new_value` assigned in this dictionary and expands it to fit the dataframe that is returned to the strategy. You can then use the returned metrics in your strategy through `dataframe['my_new_value']`. An example of how return values can be used in `FreqAI` are the `&*_mean` and `&*_std` values that are used to [created a dynamic target threshold](freqai-configuration.md#creating-a-dynamic-target-threshold). + +Another example, where the user wants to use live metrics from the trade database, is shown below: + +```json + "freqai": { + "extra_returns_per_train": {"total_profit": 4} + } +``` + +You need to set the standard dictionary in the config so that `FreqAI` can return proper dataframe shapes. These values will likely be overridden by the prediction model, but in the case where the model has yet to set them, or needs a default initial value, the preset values are what will be returned. + +## Feature normalization + +`FreqAI` is strict when it comes to data normalization. The train features, $X^{train}$, are always normalized to [-1, 1] using a shifted min-max normalization: + +$$X^{train}_{norm} = 2 * \frac{X^{train} - X^{train}.min()}{X^{train}.max() - X^{train}.min()} - 1$$ + +All other data (test data and unseen prediction data in dry/live/backtest) is always automatically normalized to the training feature space according to industry standards. `FreqAI` stores all the metadata required to ensure that test and prediction features will be properly normalized and that predictions are properly denormalized. For this reason, it is not recommended to eschew industry standards and modify `FreqAI` internals - however - advanced users can do so by inheriting `train()` in their custom `IFreqaiModel` and using their own normalization functions. + +## Data dimensionality reduction with Principal Component Analysis + +You can reduce the dimensionality of your features by activating the `principal_component_analysis` in the config: + +```json + "freqai": { + "feature_parameters" : { + "principal_component_analysis": true + } + } +``` + +This will perform PCA on the features and reduce their dimensionality so that the explained variance of the data set is >= 0.999. Reducing data dimensionality makes training the model faster and hence allows for more up-to-date models. + +## Inlier metric + +The `inlier_metric` is a metric aimed at quantifying how similar a the features of a data point are to the most recent historic data points. + +You define the lookback window by setting `inlier_metric_window` and `FreqAI` computes the distance between the present time point and each of the previous `inlier_metric_window` lookback points. A Weibull function is fit to each of the lookback distributions and its cumulative distribution function (CDF) is used to produce a quantile for each lookback point. The `inlier_metric` is then computed for each time point as the average of the corresponding lookback quantiles. The figure below explains the concept for an `inlier_metric_window` of 5. + +![inlier-metric](assets/freqai_inlier-metric.jpg) + +`FreqAI` adds the `inlier_metric` to the training features and hence gives the model access to a novel type of temporal information. + +This function does **not** remove outliers from the data set. + +## Weighting features for temporal importance + +`FreqAI` allows you to set a `weight_factor` to weight recent data more strongly than past data via an exponential function: + +$$ W_i = \exp(\frac{-i}{\alpha*n}) $$ + +where $W_i$ is the weight of data point $i$ in a total set of $n$ data points. Below is a figure showing the effect of different weight factors on the data points in a feature set. + +![weight-factor](assets/freqai_weight-factor.jpg) + +## Outlier detection + +Equity and crypto markets suffer from a high level of non-patterned noise in the form of outlier data points. `FreqAI` implements a variety of methods to identify such outliers and hence mitigate risk. + +### Identifying outliers with the Dissimilarity Index (DI) + + The Dissimilarity Index (DI) aims to quantify the uncertainty associated with each prediction made by the model. + +You can tell `FreqAI` to remove outlier data points from the training/test data sets using the DI by including the following statement in the config: + +```json + "freqai": { + "feature_parameters" : { + "DI_threshold": 1 + } + } +``` + + The DI allows predictions which are outliers (not existent in the model feature space) to be thrown out due to low levels of certainty. To do so, `FreqAI` measures the distance between each training data point (feature vector), $X_{a}$, and all other training data points: + +$$ d_{ab} = \sqrt{\sum_{j=1}^p(X_{a,j}-X_{b,j})^2} $$ + +where $d_{ab}$ is the distance between the normalized points $a$ and $b$, and $p$ is the number of features, i.e., the length of the vector $X$. The characteristic distance, $\overline{d}$, for a set of training data points is simply the mean of the average distances: + +$$ \overline{d} = \sum_{a=1}^n(\sum_{b=1}^n(d_{ab}/n)/n) $$ + +$\overline{d}$ quantifies the spread of the training data, which is compared to the distance between a new prediction feature vectors, $X_k$ and all the training data: + +$$ d_k = \arg \min d_{k,i} $$ + +This enables the estimation of the Dissimilarity Index as: + +$$ DI_k = d_k/\overline{d} $$ + +You can tweak the DI through the `DI_threshold` to increase or decrease the extrapolation of the trained model. A higher `DI_threshold` means that the DI is more lenient and allows predictions further away from the training data to be used whilst a lower `DI_threshold` has the opposite effect and hence discards more predictions. + +Below is a figure that describes the DI for a 3D data set. + +![DI](assets/freqai_DI.jpg) + +### Identifying outliers using a Support Vector Machine (SVM) + +You can tell `FreqAI` to remove outlier data points from the training/test data sets using a Support Vector Machine (SVM) by including the following statement in the config: + +```json + "freqai": { + "feature_parameters" : { + "use_SVM_to_remove_outliers": true + } + } +``` + +The SVM will be trained on the training data and any data point that the SVM deems to be beyond the feature space will be removed. + +`FreqAI` uses `sklearn.linear_model.SGDOneClassSVM` (details are available on scikit-learn's webpage [here](https://scikit-learn.org/stable/modules/generated/sklearn.linear_model.SGDOneClassSVM.html) (external website)) and you can elect to provide additional parameters for the SVM, such as `shuffle`, and `nu`. + +The parameter `shuffle` is by default set to `False` to ensure consistent results. If it is set to `True`, running the SVM multiple times on the same data set might result in different outcomes due to `max_iter` being to low for the algorithm to reach the demanded `tol`. Increasing `max_iter` solves this issue but causes the procedure to take longer time. + +The parameter `nu`, *very* broadly, is the amount of data points that should be considered outliers and should be between 0 and 1. + +### Identifying outliers with DBSCAN + +You can configure `FreqAI` to use DBSCAN to cluster and remove outliers from the training/test data set or incoming outliers from predictions, by activating `use_DBSCAN_to_remove_outliers` in the config: + +```json + "freqai": { + "feature_parameters" : { + "use_DBSCAN_to_remove_outliers": true + } + } +``` + +DBSCAN is an unsupervised machine learning algorithm that clusters data without needing to know how many clusters there should be. + +Given a number of data points $N$, and a distance $\varepsilon$, DBSCAN clusters the data set by setting all data points that have $N-1$ other data points within a distance of $\varepsilon$ as *core points*. A data point that is within a distance of $\varepsilon$ from a *core point* but that does not have $N-1$ other data points within a distance of $\varepsilon$ from itself is considered an *edge point*. A cluster is then the collection of *core points* and *edge points*. Data points that have no other data points at a distance $<\varepsilon$ are considered outliers. The figure below shows a cluster with $N = 3$. + +![dbscan](assets/freqai_dbscan.jpg) + +`FreqAI` uses `sklearn.cluster.DBSCAN` (details are available on scikit-learn's webpage [here](https://scikit-learn.org/stable/modules/generated/sklearn.cluster.DBSCAN.html) (external website)) with `min_samples` ($N$) taken as 1/4 of the no. of time points in the feature set. `eps` ($\varepsilon$) is computed automatically as the elbow point in the *k-distance graph* computed from the nearest neighbors in the pairwise distances of all data points in the feature set. diff --git a/docs/freqai-parameter-table.md b/docs/freqai-parameter-table.md new file mode 100644 index 000000000..8e19226ba --- /dev/null +++ b/docs/freqai-parameter-table.md @@ -0,0 +1,51 @@ +# Parameter table + +The table below will list all configuration parameters available for `FreqAI`. Some of the parameters are exemplified in `config_examples/config_freqai.example.json`. + +Mandatory parameters are marked as **Required** and have to be set in one of the suggested ways. + +| Parameter | Description | +|------------|-------------| +| | **General configuration parameters** +| `freqai` | **Required.**
The parent dictionary containing all the parameters for controlling `FreqAI`.
**Datatype:** Dictionary. +| `train_period_days` | **Required.**
Number of days to use for the training data (width of the sliding window).
**Datatype:** Positive integer. +| `backtest_period_days` | **Required.**
Number of days to inference from the trained model before sliding the `train_period_days` window defined above, and retraining the model during backtesting (more info [here](freqai-running.md#backtesting)). This can be fractional days, but beware that the provided `timerange` will be divided by this number to yield the number of trainings necessary to complete the backtest.
**Datatype:** Float. +| `identifier` | **Required.**
A unique ID for the current model. If models are saved to disk, the `identifier` allows for reloading specific pre-trained models/data.
**Datatype:** String. +| `live_retrain_hours` | Frequency of retraining during dry/live runs.
**Datatype:** Float > 0.
Default: 0 (models retrain as often as possible). +| `expiration_hours` | Avoid making predictions if a model is more than `expiration_hours` old.
**Datatype:** Positive integer.
Default: 0 (models never expire). +| `purge_old_models` | Delete obsolete models.
**Datatype:** Boolean.
Default: `False` (all historic models remain on disk). +| `save_backtest_models` | Save models to disk when running backtesting. Backtesting operates most efficiently by saving the prediction data and reusing them directly for subsequent runs (when you wish to tune entry/exit parameters). Saving backtesting models to disk also allows to use the same model files for starting a dry/live instance with the same model `identifier`.
**Datatype:** Boolean.
Default: `False` (no models are saved). +| `fit_live_predictions_candles` | Number of historical candles to use for computing target (label) statistics from prediction data, instead of from the training dataset (more information can be found [here](freqai-configuration.md#creating-a-dynamic-target-threshold)).
**Datatype:** Positive integer. +| `follow_mode` | Use a `follower` that will look for models associated with a specific `identifier` and load those for inferencing. A `follower` will **not** train new models.
**Datatype:** Boolean.
Default: `False`. +| `continual_learning` | Use the final state of the most recently trained model as starting point for the new model, allowing for incremental learning (more information can be found [here](freqai-running.md#continual-learning)).
**Datatype:** Boolean.
Default: `False`. +| | **Feature parameters** +| `feature_parameters` | A dictionary containing the parameters used to engineer the feature set. Details and examples are shown [here](freqai-feature-engineering.md).
**Datatype:** Dictionary. +| `include_timeframes` | A list of timeframes that all indicators in `populate_any_indicators` will be created for. The list is added as features to the base indicators dataset.
**Datatype:** List of timeframes (strings). +| `include_corr_pairlist` | A list of correlated coins that `FreqAI` will add as additional features to all `pair_whitelist` coins. All indicators set in `populate_any_indicators` during feature engineering (see details [here](freqai-feature-engineering.md)) will be created for each correlated coin. The correlated coins features are added to the base indicators dataset.
**Datatype:** List of assets (strings). +| `label_period_candles` | Number of candles into the future that the labels are created for. This is used in `populate_any_indicators` (see `templates/FreqaiExampleStrategy.py` for detailed usage). You can create custom labels and choose whether to make use of this parameter or not.
**Datatype:** Positive integer. +| `include_shifted_candles` | Add features from previous candles to subsequent candles with the intent of adding historical information. If used, `FreqAI` will duplicate and shift all features from the `include_shifted_candles` previous candles so that the information is available for the subsequent candle.
**Datatype:** Positive integer. +| `weight_factor` | Weight training data points according to their recency (see details [here](freqai-feature-engineering.md#weighting-features-for-temporal-importance)).
**Datatype:** Positive float (typically < 1). +| `indicator_max_period_candles` | **No longer used (#7325)**. Replaced by `startup_candle_count` which is set in the [strategy](freqai-configuration.md#building-a-freqai-strategy). `startup_candle_count` is timeframe independent and defines the maximum *period* used in `populate_any_indicators()` for indicator creation. `FreqAI` uses this parameter together with the maximum timeframe in `include_time_frames` to calculate how many data points to download such that the first data point does not include a NaN
**Datatype:** Positive integer. +| `indicator_periods_candles` | Time periods to calculate indicators for. The indicators are added to the base indicator dataset.
**Datatype:** List of positive integers. +| `principal_component_analysis` | Automatically reduce the dimensionality of the data set using Principal Component Analysis. See details about how it works [here](#reducing-data-dimensionality-with-principal-component-analysis)
**Datatype:** Boolean. defaults to `False`. +| `plot_feature_importances` | Create a feature importance plot for each model for the top/bottom `plot_feature_importances` number of features.
**Datatype:** Integer, defaults to `0`. +| `DI_threshold` | Activates the use of the Dissimilarity Index for outlier detection when set to > 0. See details about how it works [here](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di).
**Datatype:** Positive float (typically < 1). +| `use_SVM_to_remove_outliers` | Train a support vector machine to detect and remove outliers from the training dataset, as well as from incoming data points. See details about how it works [here](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm).
**Datatype:** Boolean. +| `svm_params` | All parameters available in Sklearn's `SGDOneClassSVM()`. See details about some select parameters [here](freqai-feature-engineering.md#identifying-outliers-using-a-support-vector-machine-svm).
**Datatype:** Dictionary. +| `use_DBSCAN_to_remove_outliers` | Cluster data using the DBSCAN algorithm to identify and remove outliers from training and prediction data. See details about how it works [here](freqai-feature-engineering.md#identifying-outliers-with-dbscan).
**Datatype:** Boolean. +| `inlier_metric_window` | If set, `FreqAI` adds an `inlier_metric` to the training feature set and set the lookback to be the `inlier_metric_window`, i.e., the number of previous time points to compare the current candle to. Details of how the `inlier_metric` is computed can be found [here](freqai-feature-engineering.md#inlier-metric).
**Datatype:** Integer.
Default: 0. +| `noise_standard_deviation` | If set, `FreqAI` adds noise to the training features with the aim of preventing overfitting. `FreqAI` generates random deviates from a gaussian distribution with a standard deviation of `noise_standard_deviation` and adds them to all data points. `noise_standard_deviation` should be kept relative to the normalized space, i.e., between -1 and 1. In other words, since data in `FreqAI` is always normalized to be between -1 and 1, `noise_standard_deviation: 0.05` would result in 32% of the data being randomly increased/decreased by more than 2.5% (i.e., the percent of data falling within the first standard deviation).
**Datatype:** Integer.
Default: 0. +| `outlier_protection_percentage` | Enable to prevent outlier detection methods from discarding too much data. If more than `outlier_protection_percentage` % of points are detected as outliers by the SVM or DBSCAN, `FreqAI` will log a warning message and ignore outlier detection, i.e., the original dataset will be kept intact. If the outlier protection is triggered, no predictions will be made based on the training dataset.
**Datatype:** Float.
Default: `30`. +| `reverse_train_test_order` | Split the feature dataset (see below) and use the latest data split for training and test on historical split of the data. This allows the model to be trained up to the most recent data point, while avoiding overfitting. However, you should be careful to understand the unorthodox nature of this parameter before employing it.
**Datatype:** Boolean.
Default: `False` (no reversal). +| | **Data split parameters** +| `data_split_parameters` | Include any additional parameters available from Scikit-learn `test_train_split()`, which are shown [here](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.train_test_split.html) (external website).
**Datatype:** Dictionary. +| `test_size` | The fraction of data that should be used for testing instead of training.
**Datatype:** Positive float < 1. +| `shuffle` | Shuffle the training data points during training. Typically, to not remove the chronological order of data in time-series forecasting, this is set to `False`.
**Datatype:** Boolean.
Defaut: `False`. +| | **Model training parameters** +| `model_training_parameters` | A flexible dictionary that includes all parameters available by the selected model library. For example, if you use `LightGBMRegressor`, this dictionary can contain any parameter available by the `LightGBMRegressor` [here](https://lightgbm.readthedocs.io/en/latest/pythonapi/lightgbm.LGBMRegressor.html) (external website). If you select a different model, this dictionary can contain any parameter from that model.
**Datatype:** Dictionary. +| `n_estimators` | The number of boosted trees to fit in regression.
**Datatype:** Integer. +| `learning_rate` | Boosting learning rate during regression.
**Datatype:** Float. +| `n_jobs`, `thread_count`, `task_type` | Set the number of threads for parallel processing and the `task_type` (`gpu` or `cpu`). Different model libraries use different parameter names.
**Datatype:** Float. +| | **Extraneous parameters** +| `keras` | If the selected model makes use of Keras (typical for Tensorflow-based prediction models), this flag needs to be activated so that the model save/loading follows Keras standards.
**Datatype:** Boolean.
Default: `False`. +| `conv_width` | The width of a convolutional neural network input tensor. This replaces the need for shifting candles (`include_shifted_candles`) by feeding in historical data points as the second dimension of the tensor. Technically, this parameter can also be used for regressors, but it only adds computational overhead and does not change the model training/prediction.
**Datatype:** Integer.
Default: 2. diff --git a/docs/freqai-running.md b/docs/freqai-running.md new file mode 100644 index 000000000..bfefe88c2 --- /dev/null +++ b/docs/freqai-running.md @@ -0,0 +1,156 @@ +# Running FreqAI + +There are two ways to train and deploy an adaptive machine learning model - live deployment and historical backtesting. In both cases, `FreqAI` runs/simulates periodic retraining of models as shown in the following figure: + +![freqai-window](assets/freqai_moving-window.jpg) + +## Live deployments + +FreqAI can be run dry/live using the following command: + +```bash +freqtrade trade --strategy FreqaiExampleStrategy --config config_freqai.example.json --freqaimodel LightGBMRegressor +``` + +When launched, FreqAI will start training a new model, with a new `identifier`, based on the config settings. Following training, the model will be used to make predictions on incoming candles until a new model is available. New models are typically generated as often as possible, with FreqAI managing an internal queue of the coin pairs to try to keep all models equally up to date. FreqAI will always use the most recently trained model to make predictions on incoming live data. If you do not want FreqAI to retrain new models as often as possible, you can set `live_retrain_hours` to tell FreqAI to wait at least that number of hours before training a new model. Additionally, you can set `expired_hours` to tell FreqAI to avoid making predictions on models that are older than that number of hours. + +Trained models are by default saved to disk to allow for reuse during backtesting or after a crash. You can opt to [purge old models](#purging-old-model-data) to save disk space by setting `"purge_old_models": true` in the config. + +To start a dry/live run from a saved backtest model (or from a previously crashed dry/live session), you only need to specify the `identifier` of the specific model: + +```json + "freqai": { + "identifier": "example", + "live_retrain_hours": 0.5 + } +``` + +In this case, although FreqAI will initiate with a pre-trained model, it will still check to see how much time has elapsed since the model was trained. If a full `live_retrain_hours` has elapsed since the end of the loaded model, FreqAI will start training a new model. + +### Automatic data download + +FreqAI automatically downloads the proper amount of data needed to ensure training of a model through the defined `train_period_days` and `startup_candle_count` (see the [parameter table](freqai-parameter-table.md) for detailed descriptions of these parameters). + +### Saving prediction data + +All predictions made during the lifetime of a specific `identifier` model are stored in `historical_predictions.pkl` to allow for reloading after a crash or changes made to the config. + +### Purging old model data + +FreqAI stores new model files after each successful training. These files become obsolete as new models are generated to adapt to new market conditions. If you are planning to leave FreqAI running for extended periods of time with high frequency retraining, you should enable `purge_old_models` in the config: + +```json + "freqai": { + "purge_old_models": true, + } +``` + +This will automatically purge all models older than the two most recently trained ones to save disk space. + +## Backtesting + +The FreqAI backtesting module can be executed with the following command: + +```bash +freqtrade backtesting --strategy FreqaiExampleStrategy --strategy-path freqtrade/templates --config config_examples/config_freqai.example.json --freqaimodel LightGBMRegressor --timerange 20210501-20210701 +``` + +If this command has never been executed with the existing config file, FreqAI will train a new model +for each pair, for each backtesting window within the expanded `--timerange`. + +Backtesting mode requires [downloading the necessary data](#downloading-data-to-cover-the-full-backtest-period) before deployment (unlike in dry/live mode where FreqAI handles the data downloading automatically). You should be careful to consider that the time range of the downloaded data is more than the backtesting time range. This is because FreqAI needs data prior to the desired backtesting time range in order to train a model to be ready to make predictions on the first candle of the set backtesting time range. More details on how to calculate the data to download can be found [here](#deciding-the-size-of-the-sliding-training-window-and-backtesting-duration). + +!!! Note "Model reuse" + Once the training is completed, you can execute the backtesting again with the same config file and + FreqAI will find the trained models and load them instead of spending time training. This is useful + if you want to tweak (or even hyperopt) buy and sell criteria inside the strategy. If you + *want* to retrain a new model with the same config file, you should simply change the `identifier`. + This way, you can return to using any model you wish by simply specifying the `identifier`. + +--- + +### Saving prediction data + +To allow for tweaking your strategy (**not** the features!), FreqAI will automatically save the predictions during backtesting so that they can be reused for future backtests and live runs using the same `identifier` model. This provides a performance enhancement geared towards enabling **high-level hyperopting** of entry/exit criteria. + +An additional directory called `predictions`, which contains all the predictions stored in `hdf` format, will be created in the `unique-id` folder. + +To change your **features**, you **must** set a new `identifier` in the config to signal to `FreqAI` to train new models. + +To save the models generated during a particular backtest so that you can start a live deployment from one of them instead of training a new model, you must set `save_backtest_models` to `True` in the config. + +### Downloading data to cover the full backtest period + +For live/dry deployments, FreqAI will download the necessary data automatically. However, to use backtesting functionality, you need to download the necessary data using `download-data` (details [here](data-download.md#data-downloading)). You need to pay careful attention to understanding how much *additional* data needs to be downloaded to ensure that there is a sufficient amount of training data *before* the start of the backtesting timerange. The amount of additional data can be roughly estimated by moving the start date of the timerange backwards by `train_period_days` and the `startup_candle_count` (see the [parameter table](freqai-parameter-table.md) for detailed descriptions of these parameters) from the beginning of the desired backtesting timerange. + +As an example, to backtest the `--timerange 20210501-20210701` using the [example config](freqai-configuration.md#setting-up-the-configuration-file) which sets `train_period_days` to 30, together with `startup_candle_count: 40` on a maximum `include_timeframes` of 1h, the start date for the downloaded data needs to be `20210501` - 30 days - 40 * 1h / 24 hours = 20210330 (31.7 days earlier than the start of the desired training timerange). + +### Deciding the size of the sliding training window and backtesting duration + +The backtesting timerange is defined with the typical `--timerange` parameter in the configuration file. The duration of the sliding training window is set by `train_period_days`, whilst `backtest_period_days` is the sliding backtesting window, both in number of days (`backtest_period_days` can be +a float to indicate sub-daily retraining in live/dry mode). In the presented [example config](freqai-configuration.md#setting-up-the-configuration-file) (found in `config_examples/config_freqai.example.json`), the user is asking FreqAI to use a training period of 30 days and backtest on the subsequent 7 days. After the training of the model, FreqAI will backtest the subsequent 7 days. The "sliding window" then moves one week forward (emulating FreqAI retraining once per week in live mode) and the new model uses the previous 30 days (including the 7 days used for backtesting by the previous model) to train. This is repeated until the end of `--timerange`. This means that if you set `--timerange 20210501-20210701`, FreqAI will have trained 8 separate models at the end of `--timerange` (because the full range comprises 8 weeks). + +!!! Note + Although fractional `backtest_period_days` is allowed, you should be aware that the `--timerange` is divided by this value to determine the number of models that FreqAI will need to train in order to backtest the full range. For example, by setting a `--timerange` of 10 days, and a `backtest_period_days` of 0.1, FreqAI will need to train 100 models per pair to complete the full backtest. Because of this, a true backtest of FreqAI adaptive training would take a *very* long time. The best way to fully test a model is to run it dry and let it train constantly. In this case, backtesting would take the exact same amount of time as a dry run. + +## Defining model expirations + +During dry/live mode, FreqAI trains each coin pair sequentially (on separate threads/GPU from the main Freqtrade bot). This means that there is always an age discrepancy between models. If you are training on 50 pairs, and each pair requires 5 minutes to train, the oldest model will be over 4 hours old. This may be undesirable if the characteristic time scale (the trade duration target) for a strategy is less than 4 hours. You can decide to only make trade entries if the model is less than a certain number of hours old by setting the `expiration_hours` in the config file: + +```json + "freqai": { + "expiration_hours": 0.5, + } +``` + +In the presented example config, the user will only allow predictions on models that are less than 1/2 hours old. + +## Controlling the model learning process + +Model training parameters are unique to the selected machine learning library. FreqAI allows you to set any parameter for any library using the `model_training_parameters` dictionary in the config. The example config (found in `config_examples/config_freqai.example.json`) shows some of the example parameters associated with `Catboost` and `LightGBM`, but you can add any parameters available in those libraries or any other machine learning library you choose to implement. + +Data split parameters are defined in `data_split_parameters` which can be any parameters associated with Scikit-learn's `train_test_split()` function. `train_test_split()` has a parameters called `shuffle` which allows to shuffle the data or keep it unshuffled. This is particularly useful to avoid biasing training with temporally auto-correlated data. More details about these parameters can be found the [Scikit-learn website](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.train_test_split.html) (external website). + +The FreqAI specific parameter `label_period_candles` defines the offset (number of candles into the future) used for the `labels`. In the presented [example config](freqai-configuration.md#setting-up-the-configuration-file), the user is asking for `labels` that are 24 candles in the future. + +## Continual learning + +You can choose to adopt a continual learning scheme by setting `"continual_learning": true` in the config. By enabling `continual_learning`, after training an initial model from scratch, subsequent trainings will start from the final model state of the preceding training. This gives the new model a "memory" of the previous state. By default, this is set to `false` which means that all new models are trained from scratch, without input from previous models. + +## Hyperopt + +You can hyperopt using the same command as for [typical Freqtrade hyperopt](hyperopt.md): + +```bash +freqtrade hyperopt --hyperopt-loss SharpeHyperOptLoss --strategy FreqaiExampleStrategy --freqaimodel LightGBMRegressor --strategy-path freqtrade/templates --config config_examples/config_freqai.example.json --timerange 20220428-20220507 +``` + +`hyperopt` requires you to have the data pre-downloaded in the same fashion as if you were doing [backtesting](#backtesting). In addition, you must consider some restrictions when trying to hyperopt FreqAI strategies: + +- The `--analyze-per-epoch` hyperopt parameter is not compatible with FreqAI. +- It's not possible to hyperopt indicators in the `populate_any_indicators()` function. This means that you cannot optimize model parameters using hyperopt. Apart from this exception, it is possible to optimize all other [spaces](hyperopt.md#running-hyperopt-with-smaller-search-space). +- The backtesting instructions also apply to hyperopt. + +The best method for combining hyperopt and FreqAI is to focus on hyperopting entry/exit thresholds/criteria. You need to focus on hyperopting parameters that are not used in your features. For example, you should not try to hyperopt rolling window lengths in the feature creation, or any part of the FreqAI config which changes predictions. In order to efficiently hyperopt the FreqAI strategy, FreqAI stores predictions as dataframes and reuses them. Hence the requirement to hyperopt entry/exit thresholds/criteria only. + +A good example of a hyperoptable parameter in FreqAI is a threshold for the [Dissimilarity Index (DI)](freqai-feature-engineering.md#identifying-outliers-with-the-dissimilarity-index-di) `DI_values` beyond which we consider data points as outliers: + +```python +di_max = IntParameter(low=1, high=20, default=10, space='buy', optimize=True, load=True) +dataframe['outlier'] = np.where(dataframe['DI_values'] > self.di_max.value/10, 1, 0) +``` + +This specific hyperopt would help you understand the appropriate `DI_values` for your particular parameter space. + +## Setting up a follower + +You can indicate to the bot that it should not train models, but instead should look for models trained by a leader with a specific `identifier` by defining: + +```json + "freqai": { + "follow_mode": true, + "identifier": "example" + } +``` + +In this example, the user has a leader bot with the `"identifier": "example"`. The leader bot is already running or is launched simultaneously with the follower. The follower will load models created by the leader and inference them to obtain predictions instead of training its own models. diff --git a/docs/freqai.md b/docs/freqai.md index 20562aadc..59a3d9878 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -1,75 +1,72 @@ ![freqai-logo](assets/freqai_doc_logo.svg) -# FreqAI +# `FreqAI` -FreqAI is a module designed to automate a variety of tasks associated with training a predictive machine learning model to generate market forecasts given a set of input features. +## Introduction + +`FreqAI` is a software designed to automate a variety of tasks associated with training a predictive machine learning model to generate market forecasts given a set of input features. Features include: -* **Self-adaptive retraining**: retrain models during [live deployments](#running-the-model-live) to self-adapt to the market in an unsupervised manner. -* **Rapid feature engineering**: create large rich [feature sets](#feature-engineering) (10k+ features) based on simple user-created strategies. -* **High performance**: adaptive retraining occurs on a separate thread (or on GPU if available) from inferencing and bot trade operations. Newest models and data are kept in memory for rapid inferencing. -* **Realistic backtesting**: emulate self-adaptive retraining with a [backtesting module](#backtesting) that automates past retraining. -* **Modifiability**: use the generalized and robust architecture for incorporating any [machine learning library/method](#building-a-custom-prediction-model) available in Python. Eight examples are currently available, including classifiers, regressors, and a convolutional neural network. -* **Smart outlier removal**: remove outliers from training and prediction data sets using a variety of [outlier detection techniques](#outlier-removal). -* **Crash resilience**: store model to disk to make reloading from a crash fast and easy, and [purge obsolete files](#purging-old-model-data) for sustained dry/live runs. -* **Automatic data normalization**: [normalize the data](#feature-normalization) in a smart and statistically safe way. -* **Automatic data download**: compute the data download timerange and update historic data (in live deployments). -* **Cleaning of incoming data**: handle NaNs safely before training and prediction. -* **Dimensionality reduction**: reduce the size of the training data via [Principal Component Analysis](#reducing-data-dimensionality-with-principal-component-analysis). -* **Deploying bot fleets**: set one bot to train models while a fleet of [follower bots](#setting-up-a-follower) inference the models and handle trades. +* **Self-adaptive retraining** - Retrain models during [live deployments](freqai-running.md#live-deployments) to self-adapt to the market in a supervised manner +* **Rapid feature engineering** - Create large rich [feature sets](freqai-feature-engineering.md#feature-engineering) (10k+ features) based on simple user-created strategies +* **High performance** - Threading allows for adaptive model retraining on a separate thread (or on GPU if available) from model inferencing (prediction) and bot trade operations. Newest models and data are kept in RAM for rapid inferencing +* **Realistic backtesting** - Emulate self-adaptive training on historic data with a [backtesting module](freqai-running.md#backtesting) that automates retraining +* **Extensibility** - The generalized and robust architecture allows for incorporating any [machine learning library/method](freqai-configuration.md#using-different-prediction-models) available in Python. Eight examples are currently available, including classifiers, regressors, and a convolutional neural network +* **Smart outlier removal** - Remove outliers from training and prediction data sets using a variety of [outlier detection techniques](freqai-feature-engineering.md#outlier-detection) +* **Crash resilience** - Store trained models to disk to make reloading from a crash fast and easy, and [purge obsolete files](freqai-running.md#purging-old-model-data) for sustained dry/live runs +* **Automatic data normalization** - [Normalize the data](freqai-feature-engineering.md#feature-normalization) in a smart and statistically safe way +* **Automatic data download** - Compute timeranges for data downloads and update historic data (in live deployments) +* **Cleaning of incoming data** - Handle NaNs safely before training and model inferencing +* **Dimensionality reduction** - Reduce the size of the training data via [Principal Component Analysis](freqai-feature-engineering.md#data-dimensionality-reduction-with-principal-component-analysis) +* **Deploying bot fleets** - Set one bot to train models while a fleet of [follower bots](freqai-running.md#setting-up-a-follower) inference the models and handle trades ## Quick start -The easiest way to quickly test FreqAI is to run it in dry mode with the following command +The easiest way to quickly test `FreqAI` is to run it in dry mode with the following command: ```bash freqtrade trade --config config_examples/config_freqai.example.json --strategy FreqaiExampleStrategy --freqaimodel LightGBMRegressor --strategy-path freqtrade/templates ``` -The user will see the boot-up process of automatic data downloading, followed by simultaneous training and trading. +You will see the boot-up process of automatic data downloading, followed by simultaneous training and trading. -The example strategy, example prediction model, and example config can be found in +An example strategy, prediction model, and config to use as a starting points can be found in `freqtrade/templates/FreqaiExampleStrategy.py`, `freqtrade/freqai/prediction_models/LightGBMRegressor.py`, and `config_examples/config_freqai.example.json`, respectively. ## General approach -The user provides FreqAI with a set of custom *base* indicators (the same way as in a typical Freqtrade strategy) as well as target values (*labels*). -FreqAI trains a model to predict the target values based on the input of custom indicators, for each pair in the whitelist. These models are consistently retrained to adapt to market conditions. FreqAI offers the ability to both backtest strategies (emulating reality with periodic retraining) and deploy dry/live runs. In dry/live conditions, FreqAI can be set to constant retraining in a background thread in an effort to keep models as up to date as possible. +You provide `FreqAI` with a set of custom *base indicators* (the same way as in a [typical Freqtrade strategy](strategy-customization.md)) as well as target values (*labels*). For each pair in the whitelist, `FreqAI` trains a model to predict the target values based on the input of custom indicators. The models are then consistently retrained, with a predetermined frequency, to adapt to market conditions. `FreqAI` offers the ability to both backtest strategies (emulating reality with periodic retraining on historic data) and deploy dry/live runs. In dry/live conditions, `FreqAI` can be set to constant retraining in a background thread to keep models as up to date as possible. -An overview of the algorithm is shown below, explaining the data processing pipeline and the model usage. +An overview of the algorithm, explaining the data processing pipeline and model usage, is shown below. ![freqai-algo](assets/freqai_algo.jpg) ### Important machine learning vocabulary -**Features** - the quantities with which a model is trained. All features for a single candle is stored as a vector. In FreqAI, the user -builds the feature sets from anything they can construct in the strategy. +**Features** - the parameters, based on historic data, on which a model is trained. All features for a single candle is stored as a vector. In `FreqAI`, you build a feature data sets from anything you can construct in the strategy. -**Labels** - the target values that a model is trained -toward. Each set of features is associated with a single label that is -defined by the user within the strategy. These labels intentionally look into the -future, and are not available to the model during dry/live/backtesting. +**Labels** - the target values that a model is trained toward. Each feature vector is associated with a single label that is defined by you within the strategy. These labels intentionally look into the future, and are not available to the model during dry/live/backtesting. -**Training** - the process of feeding individual feature sets, composed of historic data, with associated labels into the -model with the goal of matching input feature sets to associated labels. +**Training** - the process of "teaching" the model to match the feature sets to the associated labels. Different types of models "learn" in different ways. More information about the different models can be found [here](freqai-configuration.md#using-different-prediction-models). -**Train data** - a subset of the historic data that is fed to the model during -training. This data directly influences weight connections in the model. +**Train data** - a subset of the feature data set that is fed to the model during training. This data directly influences weight connections in the model. -**Test data** - a subset of the historic data that is used to evaluate the performance of the model after training. This data does not influence nodal weights within the model. +**Test data** - a subset of the feature data set that is used to evaluate the performance of the model after training. This data does not influence nodal weights within the model. + +**Inferencing** - the process of feeding a trained model new data on which it will make a prediction. ## Install prerequisites -The normal Freqtrade install process will ask the user if they wish to install FreqAI dependencies. The user should reply "yes" to this question if they wish to use FreqAI. If the user did not reply yes, they can manually install these dependencies after the install with: +The normal Freqtrade install process will ask if you wish to install `FreqAI` dependencies. You should reply "yes" to this question if you wish to use `FreqAI`. If you did not reply yes, you can manually install these dependencies after the install with: ``` bash pip install -r requirements-freqai.txt ``` !!! Note - Catboost will not be installed on arm devices (raspberry, Mac M1, ARM based VPS, ...), since Catboost does not provide wheels for this platform. + Catboost will not be installed on arm devices (raspberry, Mac M1, ARM based VPS, ...), since it does not provide wheels for this platform. ### Usage with docker @@ -81,857 +78,29 @@ This image contains the regular freqAI dependencies. Similar to native installs, ### Parameter table -The table below will list all configuration parameters available for FreqAI, presented in the same order as `config_examples/config_freqai.example.json`. +## Common pitfalls -Mandatory parameters are marked as **Required**, which means that they are required to be set in one of the possible ways. - -| Parameter | Description | -|------------|-------------| -| | **General configuration parameters** -| `freqai` | **Required.**
The parent dictionary containing all the parameters for controlling FreqAI.
**Datatype:** Dictionary. -| `purge_old_models` | Delete obsolete models (otherwise, all historic models will remain on disk).
**Datatype:** Boolean. Default: `False`. -| `train_period_days` | **Required.**
Number of days to use for the training data (width of the sliding window).
**Datatype:** Positive integer. -| `backtest_period_days` | **Required.**
Number of days to inference from the trained model before sliding the window defined above, and retraining the model. This can be fractional days, but beware that the user-provided `timerange` will be divided by this number to yield the number of trainings necessary to complete the backtest.
**Datatype:** Float. -| `save_backtest_models` | Backtesting operates most efficiently by saving the prediction data and reusing them directly for subsequent runs (when users wish to tune entry/exit parameters). If a user wishes to save models to disk when running backtesting, they should activate `save_backtest_models`. A user may wish to do this if they plan to use the same model files for starting a dry/live instance with the same `identifier`.
**Datatype:** Boolean. Default: `False`. -| `identifier` | **Required.**
A unique name for the current model. This can be reused to reload pre-trained models/data.
**Datatype:** String. -| `live_retrain_hours` | Frequency of retraining during dry/live runs.
Default set to 0, which means the model will retrain as often as possible.
**Datatype:** Float > 0. -| `expiration_hours` | Avoid making predictions if a model is more than `expiration_hours` old.
Defaults set to 0, which means models never expire.
**Datatype:** Positive integer. -| `fit_live_predictions_candles` | Number of historical candles to use for computing target (label) statistics from prediction data, instead of from the training data set.
**Datatype:** Positive integer. -| `follow_mode` | If true, this instance of FreqAI will look for models associated with `identifier` and load those for inferencing. A `follower` will **not** train new models.
**Datatype:** Boolean. Default: `False`. -| `continual_learning` | If true, FreqAI will start training new models from the final state of the most recently trained model.
**Datatype:** Boolean. Default: `False`. -| | **Feature parameters** -| `feature_parameters` | A dictionary containing the parameters used to engineer the feature set. Details and examples are shown [here](#feature-engineering).
**Datatype:** Dictionary. -| `include_timeframes` | A list of timeframes that all indicators in `populate_any_indicators` will be created for. The list is added as features to the base asset feature set.
**Datatype:** List of timeframes (strings). -| `include_corr_pairlist` | A list of correlated coins that FreqAI will add as additional features to all `pair_whitelist` coins. All indicators set in `populate_any_indicators` during feature engineering (see details [here](#feature-engineering)) will be created for each coin in this list, and that set of features is added to the base asset feature set.
**Datatype:** List of assets (strings). -| `label_period_candles` | Number of candles into the future that the labels are created for. This is used in `populate_any_indicators` (see `templates/FreqaiExampleStrategy.py` for detailed usage). The user can create custom labels, making use of this parameter or not.
**Datatype:** Positive integer. -| `include_shifted_candles` | Add features from previous candles to subsequent candles to add historical information. FreqAI takes all features from the `include_shifted_candles` previous candles, duplicates and shifts them so that the information is available for the subsequent candle.
**Datatype:** Positive integer. -| `weight_factor` | Used to set weights for training data points according to their recency. See details about how it works [here](#controlling-the-model-learning-process).
**Datatype:** Positive float (typically < 1). -| `indicator_max_period_candles` | **No longer used**. User must use the strategy set `startup_candle_count` which defines the maximum *period* used in `populate_any_indicators()` for indicator creation (timeframe independent). FreqAI uses this information in combination with the maximum timeframe to calculate how many data points it should download so that the first data point does not have a NaN
**Datatype:** positive integer. -| `indicator_periods_candles` | Calculate indicators for `indicator_periods_candles` time periods and add them to the feature set.
**Datatype:** List of positive integers. -| `stratify_training_data` | This value is used to indicate the grouping of the data. For example, 2 would set every 2nd data point into a separate dataset to be pulled from during training/testing. See details about how it works [here](#stratifying-the-data-for-training-and-testing-the-model)
**Datatype:** Positive integer. -| `principal_component_analysis` | Automatically reduce the dimensionality of the data set using Principal Component Analysis. See details about how it works [here](#reducing-data-dimensionality-with-principal-component-analysis) -| `plot_feature_importance` | Create an interactive feature importance plot for each model.
**Datatype:** Boolean.
**Datatype:** Boolean, defaults to `False` -| `DI_threshold` | Activates the Dissimilarity Index for outlier detection when > 0. See details about how it works [here](#removing-outliers-with-the-dissimilarity-index).
**Datatype:** Positive float (typically < 1). -| `use_SVM_to_remove_outliers` | Train a support vector machine to detect and remove outliers from the training data set, as well as from incoming data points. See details about how it works [here](#removing-outliers-using-a-support-vector-machine-svm).
**Datatype:** Boolean. -| `svm_params` | All parameters available in Sklearn's `SGDOneClassSVM()`. See details about some select parameters [here](#removing-outliers-using-a-support-vector-machine-svm).
**Datatype:** Dictionary. -| `use_DBSCAN_to_remove_outliers` | Cluster data using DBSCAN to identify and remove outliers from training and prediction data. See details about how it works [here](#removing-outliers-with-dbscan).
**Datatype:** Boolean. -| `inlier_metric_window` | If set, FreqAI will add the `inlier_metric` to the training feature set and set the lookback to be the `inlier_metric_window`. Details of how the `inlier_metric` is computed can be found [here](#using-the-inliermetric)
**Datatype:** int. Default: 0 -| `noise_standard_deviation` | If > 0, FreqAI adds noise to the training features. FreqAI generates random deviates from a gaussian distribution with a standard deviation of `noise_standard_deviation` and adds them to all data points. Value should be kept relative to the normalized space between -1 and 1). In other words, since data is always normalized between -1 and 1 in FreqAI, the user can expect a `noise_standard_deviation: 0.05` to see 32% of data randomly increased/decreased by more than 2.5% (i.e. the percent of data falling within the first standard deviation). Good for preventing overfitting.
**Datatype:** int. Default: 0 -| `outlier_protection_percentage` | If more than `outlier_protection_percentage` % of points are detected as outliers by the SVM or DBSCAN, FreqAI will log a warning message and ignore outlier detection while keeping the original dataset intact. If the outlier protection is triggered, no predictions will be made based on the training data.
**Datatype:** Float. Default: `30` -| `reverse_train_test_order` | If true, FreqAI will train on the latest data split and test on historical split of the data. This allows the model to be trained up to the most recent data point, while avoiding overfitting. However, users should be careful to understand unorthodox nature of this parameter before employing it.
**Datatype:** Boolean. Default: False -| | **Data split parameters** -| `data_split_parameters` | Include any additional parameters available from Scikit-learn `test_train_split()`, which are shown [here](https://scikit-learn.org/stable/modules/generated/sklearn.model_selection.train_test_split.html) (external website).
**Datatype:** Dictionary. -| `test_size` | Fraction of data that should be used for testing instead of training.
**Datatype:** Positive float < 1. -| `shuffle` | Shuffle the training data points during training. Typically, for time-series forecasting, this is set to `False`.
**Datatype:** Boolean. -| | **Model training parameters** -| `model_training_parameters` | A flexible dictionary that includes all parameters available by the user selected model library. For example, if the user uses `LightGBMRegressor`, this dictionary can contain any parameter available by the `LightGBMRegressor` [here](https://lightgbm.readthedocs.io/en/latest/pythonapi/lightgbm.LGBMRegressor.html) (external website). If the user selects a different model, such as `PPO` from stable_baselines3, this dictionary can contain any parameter from that model.
**Datatype:** Dictionary. -| `n_estimators` | The number of boosted trees to fit in regression.
**Datatype:** Integer. -| `learning_rate` | Boosting learning rate during regression.
**Datatype:** Float. -| `n_jobs`, `thread_count`, `task_type` | Set the number of threads for parallel processing and the `task_type` (`gpu` or `cpu`). Different model libraries use different parameter names.
**Datatype:** Float. -| | *Reinforcement Learning Parameters** -| `rl_config` | A dictionary containing the control parameters for a Reinforcement Learning model.
**Datatype:** Dictionary. -| `train_cycles` | Training time steps will be set based on the `train_cycles * number of training data points.
**Datatype:** Integer. -| `cpu_count` | Number of processors to dedicate to the Reinforcement Learning training process.
**Datatype:** int. -| `max_trade_duration_candles`| Guides the agent training to keep trades below desired length. Example usage shown in `prediction_models/ReinforcementLearner.py` within the user customizable `calculate_reward()`
**Datatype:** int. -| `model_type` | Model string from stable_baselines3 or SBcontrib. Available strings include: `'TRPO', 'ARS', 'RecurrentPPO', 'MaskablePPO', 'PPO', 'A2C', 'DQN'`. User should ensure that `model_training_parameters` match those available to the corresponding stable_baselines3 model by visiting their documentaiton. [PPO doc](https://stable-baselines3.readthedocs.io/en/master/modules/ppo.html) (external website)
**Datatype:** string. -| `policy_type` | One of the available policy types from stable_baselines3
**Datatype:** string. -| `continual_learning` | If true, the agent will start new trainings from the model selected during the previous training. If false, a new agent is trained from scratch for each training.
**Datatype:** Bool. -| `thread_count` | Number of threads to dedicate to the Reinforcement Learning training process.
**Datatype:** int. -| `model_reward_parameters` | Parameters used inside the user customizable `calculate_reward()` function in `ReinforcementLearner.py`
**Datatype:** int. -| | **Extraneous parameters** -| `keras` | If your model makes use of keras (typical of Tensorflow based prediction models), activate this flag so that the model save/loading follows keras standards. Default value `false`
**Datatype:** boolean. -| `conv_width` | The width of a convolutional neural network input tensor or the `ReinforcementLearningModel` `window_size`. This replaces the need for `shift` by feeding in historical data points as the second dimension of the tensor. Technically, this parameter can also be used for regressors, but it only adds computational overhead and does not change the model training/prediction. Default value, 2
**Datatype:** integer. - -### Important dataframe key patterns - -Below are the values the user can expect to include/use inside a typical strategy dataframe (`df[]`): - -| DataFrame Key | Description | -|------------|-------------| -| `df['&*']` | Any dataframe column prepended with `&` in `populate_any_indicators()` is treated as a training target (label) inside FreqAI (typically following the naming convention `&-s*`). The names of these dataframe columns are fed back to the user as the predictions. For example, if the user wishes to predict the price change in the next 40 candles (similar to `templates/FreqaiExampleStrategy.py`), they set `df['&-s_close']`. FreqAI makes the predictions and gives them back under the same key (`df['&-s_close']`) to be used in `populate_entry/exit_trend()`.
**Datatype:** Depends on the output of the model. -| `df['&*_std/mean']` | Standard deviation and mean values of the user-defined labels during training (or live tracking with `fit_live_predictions_candles`). Commonly used to understand the rarity of a prediction (use the z-score as shown in `templates/FreqaiExampleStrategy.py` to evaluate how often a particular prediction was observed during training or historically with `fit_live_predictions_candles`).
**Datatype:** Float. -| `df['do_predict']` | Indication of an outlier data point. The return value is integer between -1 and 2, which lets the user know if the prediction is trustworthy or not. `do_predict==1` means the prediction is trustworthy. If the Dissimilarity Index (DI, see details [here](#removing-outliers-with-the-dissimilarity-index)) of the input data point is above the user-defined threshold, FreqAI will subtract 1 from `do_predict`, resulting in `do_predict==0`. If `use_SVM_to_remove_outliers()` is active, the Support Vector Machine (SVM) may also detect outliers in training and prediction data. In this case, the SVM will also subtract 1 from `do_predict`. If the input data point was considered an outlier by the SVM but not by the DI, the result will be `do_predict==0`. If both the DI and the SVM considers the input data point to be an outlier, the result will be `do_predict==-1`. A particular case is when `do_predict == 2`, which means that the model has expired due to exceeding `expired_hours`.
**Datatype:** Integer between -1 and 2. -| `df['DI_values']` | Dissimilarity Index values are proxies to the level of confidence FreqAI has in the prediction. A lower DI means the prediction is close to the training data, i.e., higher prediction confidence.
**Datatype:** Float. -| `df['%*']` | Any dataframe column prepended with `%` in `populate_any_indicators()` is treated as a training feature. For example, the user can include the RSI in the training feature set (similar to in `templates/FreqaiExampleStrategy.py`) by setting `df['%-rsi']`. See more details on how this is done [here](#feature-engineering).
**Note**: Since the number of features prepended with `%` can multiply very quickly (10s of thousands of features is easily engineered using the multiplictative functionality described in the `feature_parameters` table shown above), these features are removed from the dataframe upon return from FreqAI. If the user wishes to keep a particular type of feature for plotting purposes, they can prepend it with `%%`.
**Datatype:** Depends on the output of the model. - -### File structure - -`user_data_dir/models/` contains all the data associated with the trainings and backtests. -This file structure is heavily controlled and inferenced by the `FreqaiDataKitchen()` -and should therefore not be modified. - -### Example config file - -The user interface is isolated to the typical Freqtrade config file. A FreqAI config should include: - -```json - "freqai": { - "enabled": true, - "startup_candles": 10000, - "purge_old_models": true, - "train_period_days": 30, - "backtest_period_days": 7, - "identifier" : "unique-id", - "feature_parameters" : { - "include_timeframes": ["5m","15m","4h"], - "include_corr_pairlist": [ - "ETH/USD", - "LINK/USD", - "BNB/USD" - ], - "label_period_candles": 24, - "include_shifted_candles": 2, - "indicator_periods_candles": [10, 20] - }, - "data_split_parameters" : { - "test_size": 0.25 - }, - "model_training_parameters" : { - "n_estimators": 100 - }, - } -``` - -## Building a FreqAI strategy - -The FreqAI strategy requires the user to include the following lines of code in the standard Freqtrade strategy: - -```python - # user should define the maximum startup candle count (the largest number of candles - # passed to any single indicator) - startup_candle_count: int = 20 - - def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: - - # the model will return all labels created by user in `populate_any_indicators` - # (& appended targets), an indication of whether or not the prediction should be accepted, - # the target mean/std values for each of the labels created by user in - # `populate_any_indicators()` for each training period. - - dataframe = self.freqai.start(dataframe, metadata, self) - - return dataframe - - def populate_any_indicators( - self, pair, df, tf, informative=None, set_generalized_indicators=False - ): - """ - Function designed to automatically generate, name and merge features - from user indicated timeframes in the configuration file. User controls the indicators - passed to the training/prediction by prepending indicators with `'%-' + coin ` - (see convention below). I.e. user should not prepend any supporting metrics - (e.g. bb_lowerband below) with % unless they explicitly want to pass that metric to the - model. - :param pair: pair to be used as informative - :param df: strategy dataframe which will receive merges from informatives - :param tf: timeframe of the dataframe which will modify the feature names - :param informative: the dataframe associated with the informative pair - :param coin: the name of the coin which will modify the feature names. - """ - - coin = pair.split('/')[0] - - if informative is None: - informative = self.dp.get_pair_dataframe(pair, tf) - - # first loop is automatically duplicating indicators for time periods - for t in self.freqai_info["feature_parameters"]["indicator_periods_candles"]: - t = int(t) - informative[f"%-{coin}rsi-period_{t}"] = ta.RSI(informative, timeperiod=t) - informative[f"%-{coin}mfi-period_{t}"] = ta.MFI(informative, timeperiod=t) - informative[f"%-{coin}adx-period_{t}"] = ta.ADX(informative, window=t) - - indicators = [col for col in informative if col.startswith("%")] - # This loop duplicates and shifts all indicators to add a sense of recency to data - for n in range(self.freqai_info["feature_parameters"]["include_shifted_candles"] + 1): - if n == 0: - continue - informative_shift = informative[indicators].shift(n) - informative_shift = informative_shift.add_suffix("_shift-" + str(n)) - informative = pd.concat((informative, informative_shift), axis=1) - - df = merge_informative_pair(df, informative, self.config["timeframe"], tf, ffill=True) - skip_columns = [ - (s + "_" + tf) for s in ["date", "open", "high", "low", "close", "volume"] - ] - df = df.drop(columns=skip_columns) - - # Add generalized indicators here (because in live, it will call this - # function to populate indicators during training). Notice how we ensure not to - # add them multiple times - if set_generalized_indicators: - - # user adds targets here by prepending them with &- (see convention below) - # If user wishes to use multiple targets, a multioutput prediction model - # needs to be used such as templates/CatboostPredictionMultiModel.py - df["&-s_close"] = ( - df["close"] - .shift(-self.freqai_info["feature_parameters"]["label_period_candles"]) - .rolling(self.freqai_info["feature_parameters"]["label_period_candles"]) - .mean() - / df["close"] - - 1 - ) - - return df - - -``` - -Notice how the `populate_any_indicators()` is where the user adds their own features ([more information](#feature-engineering)) and labels ([more information](#setting-classifier-targets)). See a full example at `templates/FreqaiExampleStrategy.py`. - -*Important*: The `self.freqai.start()` function cannot be called outside the `populate_indicators()`. - -### Setting the `startup_candle_count` -Users need to take care to set the `startup_candle_count` in their strategy the same way they would for any normal Freqtrade strategy (see details [here](strategy-customization.md#strategy-startup-period)). This value is used by Freqtrade to ensure that a sufficient amount of data is provided when calling on the `dataprovider` to avoid any NaNs at the beginning of the first training. Users can easily set this value by identifying the longest period (in candle units) that they pass to their indicator creation functions (e.g. talib functions). In the present example, the user would pass 20 to as this value (since it is the maximum value in their `indicators_periods_candles`). - -!!! Note - Typically it is best for users to be safe and multiply their expected `startup_candle_count` by 2. There are instances where the talib functions actually require more data than just the passed `period`. Anecdotally, multiplying the `startup_candle_count` by 2 always leads to a fully NaN free training dataset. Look out for this log message to confirm that your data is clean: - - ``` - 2022-08-31 15:14:04 - freqtrade.freqai.data_kitchen - INFO - dropped 0 training points due to NaNs in populated dataset 4319. - ``` - - -## Creating a dynamic target - -The `&*_std/mean` return values describe the statistical fit of the user defined label *during the most recent training*. This value allows the user to know the rarity of a given prediction. For example, `templates/FreqaiExampleStrategy.py`, creates a `target_roi` which is based on filtering out predictions that are below a given z-score of 1.25. - -```python -dataframe["target_roi"] = dataframe["&-s_close_mean"] + dataframe["&-s_close_std"] * 1.25 -dataframe["sell_roi"] = dataframe["&-s_close_mean"] - dataframe["&-s_close_std"] * 1.25 -``` - -If the user wishes to consider the population -of *historical predictions* for creating the dynamic target instead of the trained labels, (as discussed above) the user -can do so by setting `fit_live_prediction_candles` in the config to the number of historical prediction candles -the user wishes to use to generate target statistics. - -```json - "freqai": { - "fit_live_prediction_candles": 300, - } -``` - -If the user sets this value, FreqAI will initially use the predictions from the training data -and subsequently begin introducing real prediction data as it is generated. FreqAI will save -this historical data to be reloaded if the user stops and restarts a model with the same `identifier`. - -## Building a custom prediction model - -FreqAI has multiple example prediction model libraries, such as `Catboost` regression (`freqai/prediction_models/CatboostRegressor.py`) and `LightGBM` regression. -However, the user can customize and create their own prediction models using the `IFreqaiModel` class. -The user is encouraged to inherit `train()` and `predict()` to let them customize various aspects of their training procedures. - -## Feature engineering - -Features are added by the user inside the `populate_any_indicators()` method of the strategy -by prepending indicators with `%`, and labels with `&`. - -There are some important components/structures that the user *must* include when building their feature set; the use of these is shown below: - -```python - def populate_any_indicators( - self, pair, df, tf, informative=None, set_generalized_indicators=False - ): - """ - Function designed to automatically generate, name, and merge features - from user-indicated timeframes in the configuration file. The user controls the indicators - passed to the training/prediction by prepending indicators with `'%-' + coin ` - (see convention below). I.e., the user should not prepend any supporting metrics - (e.g., bb_lowerband below) with % unless they explicitly want to pass that metric to the - model. - :param pair: pair to be used as informative - :param df: strategy dataframe which will receive merges from informatives - :param tf: timeframe of the dataframe which will modify the feature names - :param informative: the dataframe associated with the informative pair - :param coin: the name of the coin which will modify the feature names. - """ - - coin = pair.split('/')[0] - - if informative is None: - informative = self.dp.get_pair_dataframe(pair, tf) - - # first loop is automatically duplicating indicators for time periods - for t in self.freqai_info["feature_parameters"]["indicator_periods_candles"]: - t = int(t) - informative[f"%-{coin}rsi-period_{t}"] = ta.RSI(informative, timeperiod=t) - informative[f"%-{coin}mfi-period_{t}"] = ta.MFI(informative, timeperiod=t) - informative[f"%-{coin}adx-period_{t}"] = ta.ADX(informative, window=t) - - bollinger = qtpylib.bollinger_bands( - qtpylib.typical_price(informative), window=t, stds=2.2 - ) - informative[f"{coin}bb_lowerband-period_{t}"] = bollinger["lower"] - informative[f"{coin}bb_middleband-period_{t}"] = bollinger["mid"] - informative[f"{coin}bb_upperband-period_{t}"] = bollinger["upper"] - - informative[f"%-{coin}bb_width-period_{t}"] = ( - informative[f"{coin}bb_upperband-period_{t}"] - - informative[f"{coin}bb_lowerband-period_{t}"] - ) / informative[f"{coin}bb_middleband-period_{t}"] - informative[f"%-{coin}close-bb_lower-period_{t}"] = ( - informative["close"] / informative[f"{coin}bb_lowerband-period_{t}"] - ) - - informative[f"%-{coin}relative_volume-period_{t}"] = ( - informative["volume"] / informative["volume"].rolling(t).mean() - ) - - indicators = [col for col in informative if col.startswith("%")] - # This loop duplicates and shifts all indicators to add a sense of recency to data - for n in range(self.freqai_info["feature_parameters"]["include_shifted_candles"] + 1): - if n == 0: - continue - informative_shift = informative[indicators].shift(n) - informative_shift = informative_shift.add_suffix("_shift-" + str(n)) - informative = pd.concat((informative, informative_shift), axis=1) - - df = merge_informative_pair(df, informative, self.config["timeframe"], tf, ffill=True) - skip_columns = [ - (s + "_" + tf) for s in ["date", "open", "high", "low", "close", "volume"] - ] - df = df.drop(columns=skip_columns) - - # Add generalized indicators here (because in live, it will call this - # function to populate indicators during training). Notice how we ensure not to - # add them multiple times - if set_generalized_indicators: - df["%-day_of_week"] = (df["date"].dt.dayofweek + 1) / 7 - df["%-hour_of_day"] = (df["date"].dt.hour + 1) / 25 - - # user adds targets here by prepending them with &- (see convention below) - # If user wishes to use multiple targets, a multioutput prediction model - # needs to be used such as templates/CatboostPredictionMultiModel.py - df["&-s_close"] = ( - df["close"] - .shift(-self.freqai_info["feature_parameters"]["label_period_candles"]) - .rolling(self.freqai_info["feature_parameters"]["label_period_candles"]) - .mean() - / df["close"] - - 1 - ) - - return df -``` - -In the presented example strategy, the user does not wish to pass the `bb_lowerband` as a feature to the model, -and has therefore not prepended it with `%`. The user does, however, wish to pass `bb_width` to the -model for training/prediction and has therefore prepended it with `%`. - -The `include_timeframes` in the example config above are the timeframes (`tf`) of each call to `populate_any_indicators()` in the strategy. In the present case, the user is asking for the -`5m`, `15m`, and `4h` timeframes of the `rsi`, `mfi`, `roc`, and `bb_width` to be included in the feature set. - -The user can ask for each of the defined features to be included also from -informative pairs using the `include_corr_pairlist`. This means that the feature -set will include all the features from `populate_any_indicators` on all the `include_timeframes` for each of the correlated pairs defined in the config (`ETH/USD`, `LINK/USD`, and `BNB/USD`). - -`include_shifted_candles` indicates the number of previous -candles to include in the feature set. For example, `include_shifted_candles: 2` tells -FreqAI to include the past 2 candles for each of the features in the feature set. - -In total, the number of features the user of the presented example strat has created is: -length of `include_timeframes` * no. features in `populate_any_indicators()` * length of `include_corr_pairlist` * no. `include_shifted_candles` * length of `indicator_periods_candles` - $= 3 * 3 * 3 * 2 * 2 = 108$. - -Another structure to consider is the location of the labels at the bottom of the example function (below `if set_generalized_indicators:`). -This is where the user will add single features and labels to their feature set to avoid duplication of them from -various configuration parameters that multiply the feature set, such as `include_timeframes`. - -!!! Note - Features **must** be defined in `populate_any_indicators()`. Definining FreqAI features in `populate_indicators()` - will cause the algorithm to fail in live/dry mode. If the user wishes to add generalized features that are not associated with - a specific pair or timeframe, they should use the following structure inside `populate_any_indicators()` - (as exemplified in `freqtrade/templates/FreqaiExampleStrategy.py`): - - ```python - def populate_any_indicators(self, metadata, pair, df, tf, informative=None, coin="", set_generalized_indicators=False): - - ... - - # Add generalized indicators here (because in live, it will call only this function to populate - # indicators for retraining). Notice how we ensure not to add them multiple times by associating - # these generalized indicators to the basepair/timeframe - if set_generalized_indicators: - df['%-day_of_week'] = (df["date"].dt.dayofweek + 1) / 7 - df['%-hour_of_day'] = (df['date'].dt.hour + 1) / 25 - - # user adds targets here by prepending them with &- (see convention below) - # If user wishes to use multiple targets, a multioutput prediction model - # needs to be used such as templates/CatboostPredictionMultiModel.py - df["&-s_close"] = ( - df["close"] - .shift(-self.freqai_info["feature_parameters"]["label_period_candles"]) - .rolling(self.freqai_info["feature_parameters"]["label_period_candles"]) - .mean() - / df["close"] - - 1 - ) - ``` - - (Please see the example script located in `freqtrade/templates/FreqaiExampleStrategy.py` for a full example of `populate_any_indicators()`.) - -## Setting classifier targets - -FreqAI includes the `CatboostClassifier` via the flag `--freqaimodel CatboostClassifier`. The user should take care to set the classes using strings: - -```python -df['&s-up_or_down'] = np.where( df["close"].shift(-100) > df["close"], 'up', 'down') -``` - -Additionally, the example classifier models do not accommodate multiple labels, but they do allow multi-class classification within a single label column. - -## Running FreqAI - -There are two ways to train and deploy an adaptive machine learning model. FreqAI enables live deployment as well as backtesting analyses. In both cases, a model is trained periodically, as shown in the following figure. - -![freqai-window](assets/freqai_moving-window.jpg) - -### Running the model live - -FreqAI can be run dry/live using the following command: - -```bash -freqtrade trade --strategy FreqaiExampleStrategy --config config_freqai.example.json --freqaimodel LightGBMRegressor -``` - -By default, FreqAI will not find any existing models and will start by training a new one -based on the user's configuration settings. Following training, the model will be used to make predictions on incoming candles until a new model is available. New models are typically generated as often as possible, with FreqAI managing an internal queue of the coin pairs to try to keep all models equally up to date. FreqAI will always use the most recently trained model to make predictions on incoming live data. If the user does not want FreqAI to retrain new models as often as possible, they can set `live_retrain_hours` to tell FreqAI to wait at least that number of hours before training a new model. Additionally, the user can set `expired_hours` to tell FreqAI to avoid making predictions on models that are older than that number of hours. - -If the user wishes to start a dry/live run from a saved backtest model (or from a previously crashed dry/live session), the user only needs to reuse -the same `identifier` parameter: - -```json - "freqai": { - "identifier": "example", - "live_retrain_hours": 0.5 - } -``` - -In this case, although FreqAI will initiate with a -pre-trained model, it will still check to see how much time has elapsed since the model was trained, -and if a full `live_retrain_hours` has elapsed since the end of the loaded model, FreqAI will retrain. - -### Backtesting - -The FreqAI backtesting module can be executed with the following command: - -```bash -freqtrade backtesting --strategy FreqaiExampleStrategy --strategy-path freqtrade/templates --config config_examples/config_freqai.example.json --freqaimodel LightGBMRegressor --timerange 20210501-20210701 -``` - -Backtesting mode requires the user to have the data [pre-downloaded](#downloading-data-for-backtesting) (unlike in dry/live mode where FreqAI automatically downloads the necessary data). The user should be careful to consider that the time range of the downloaded data is more than the backtesting time range. This is because FreqAI needs data prior to the desired backtesting time range in order to train a model to be ready to make predictions on the first candle of the user-set backtesting time range. More details on how to calculate the data to download can be found [here](#deciding-the-sliding-training-window-and-backtesting-duration). - -If this command has never been executed with the existing config file, it will train a new model -for each pair, for each backtesting window within the expanded `--timerange`. - -!!! Note "Model reuse" - Once the training is completed, the user can execute the backtesting again with the same config file and - FreqAI will find the trained models and load them instead of spending time training. This is useful - if the user wants to tweak (or even hyperopt) buy and sell criteria inside the strategy. If the user - *wants* to retrain a new model with the same config file, then they should simply change the `identifier`. - This way, the user can return to using any model they wish by simply specifying the `identifier`. - ---- - -### Hyperopt - -Users can hyperopt using the same command as typical [hyperopt](hyperopt.md): - -```bash -freqtrade hyperopt --hyperopt-loss SharpeHyperOptLoss --strategy FreqaiExampleStrategy --freqaimodel LightGBMRegressor --strategy-path freqtrade/templates --config config_examples/config_freqai.example.json --timerange 20220428-20220507 -``` - -Users need to have the data pre-downloaded in the same fashion as if they were doing a FreqAI [backtest](#backtesting). In addition, users must consider some restrictions when trying to [Hyperopt](hyperopt.md) FreqAI strategies: - -- The `--analyze-per-epoch` hyperopt parameter is not compatible with FreqAI. -- It's not possible to hyperopt indicators in `populate_any_indicators()` function. This means that the user cannot optimize model parameters using hyperopt. Apart from this exception, it is possible to optimize all other [spaces](hyperopt.md#running-hyperopt-with-smaller-search-space). -- The [Backtesting](#backtesting) instructions also apply to Hyperopt. - -The best method for combining hyperopt and FreqAI is to focus on hyperopting entry/exit thresholds/criteria. Users need to focus on hyperopting parameters that are not used in their FreqAI features. For example, users should not try to hyperopt rolling window lengths in their feature creation, or any of their FreqAI config which changes predictions. In order to efficiently hyperopt the FreqAI strategy, FreqAI stores predictions as dataframes and reuses them. Hence the requirement to hyperopt entry/exit thresholds/criteria only. - -A good example of a hyperoptable parameter in FreqAI is a value for `DI_values` beyond which we consider outliers and below which we consider inliers: - -```python -di_max = IntParameter(low=1, high=20, default=10, space='buy', optimize=True, load=True) -dataframe['outlier'] = np.where(dataframe['DI_values'] > self.di_max.value/10, 1, 0) -``` - -Which would help the user understand the appropriate Dissimilarity Index values for their particular parameter space. - -### Deciding the size of the sliding training window and backtesting duration - -The user defines the backtesting timerange with the typical `--timerange` parameter in the -configuration file. The duration of the sliding training window is set by `train_period_days`, whilst -`backtest_period_days` is the sliding backtesting window, both in number of days (`backtest_period_days` can be -a float to indicate sub-daily retraining in live/dry mode). In the presented example config, -the user is asking FreqAI to use a training period of 30 days and backtest on the subsequent 7 days. -This means that if the user sets `--timerange 20210501-20210701`, -FreqAI will train have trained 8 separate models at the end of `--timerange` (because the full range comprises 8 weeks). After the training of the model, FreqAI will backtest the subsequent 7 days. The "sliding window" then moves one week forward (emulating FreqAI retraining once per week in live mode) and the new model uses the previous 30 days (including the 7 days used for backtesting by the previous model) to train. This is repeated until the end of `--timerange`. - -!!! Note - Although fractional `backtest_period_days` is allowed, the user should be aware that the `--timerange` is divided by this value to determine the number of models that FreqAI will need to train in order to backtest the full range. For example, if the user wants to set a `--timerange` of 10 days, and asks for a `backtest_period_days` of 0.1, FreqAI will need to train 100 models per pair to complete the full backtest. Because of this, a true backtest of FreqAI adaptive training would take a *very* long time. The best way to fully test a model is to run it dry and let it constantly train. In this case, backtesting would take the exact same amount of time as a dry run. - -### Downloading data for backtesting -Live/dry instances will download the data automatically for the user, but users who wish to use backtesting functionality still need to download the necessary data using `download-data` (details [here](data-download.md#data-downloading)). FreqAI users need to pay careful attention to understanding how much *additional* data needs to be downloaded to ensure that they have a sufficient amount of training data *before* the start of their backtesting timerange. The amount of additional data can be roughly estimated by moving the start date of the timerange backwards by `train_period_days` and the `startup_candle_count` ([details](#setting-the-startupcandlecount)) from the beginning of the desired backtesting timerange. - -As an example, if we wish to backtest the `--timerange` above of `20210501-20210701`, and we use the example config which sets `train_period_days` to 15. The startup candle count is 40 on a maximum `include_timeframes` of 1h. We would need 20210501 - 15 days - 40 * 1h / 24 hours = 20210414 (16.7 days earlier than the start of the desired training timerange). - -### Defining model expirations - -During dry/live mode, FreqAI trains each coin pair sequentially (on separate threads/GPU from the main Freqtrade bot). This means that there is always an age discrepancy between models. If a user is training on 50 pairs, and each pair requires 5 minutes to train, the oldest model will be over 4 hours old. This may be undesirable if the characteristic time scale (the trade duration target) for a strategy is less than 4 hours. The user can decide to only make trade entries if the model is less than -a certain number of hours old by setting the `expiration_hours` in the config file: - -```json - "freqai": { - "expiration_hours": 0.5, - } -``` - -In the presented example config, the user will only allow predictions on models that are less than 1/2 hours old. - -### Purging old model data - -FreqAI stores new model files each time it retrains. These files become obsolete as new models are trained and FreqAI adapts to new market conditions. Users planning to leave FreqAI running for extended periods of time with high frequency retraining should enable `purge_old_models` in their config: - -```json - "freqai": { - "purge_old_models": true, - } -``` - -This will automatically purge all models older than the two most recently trained ones. - -### Returning additional info from training - -The user may find that there are some important metrics that they'd like to return to the strategy at the end of each model training. -The user can include these metrics by assigning them to `dk.data['extra_returns_per_train']['my_new_value'] = XYZ` inside their custom prediction model class. FreqAI takes the `my_new_value` assigned in this dictionary and expands it to fit the return dataframe to the strategy. -The user can then use the value in the strategy with `dataframe['my_new_value']`. An example of how this is already used in FreqAI is -the `&*_mean` and `&*_std` values, which indicate the mean and standard deviation of the particular target (label) during the most recent training. -An example, where the user wants to use live metrics from the trade database, is shown below: - -```json - "freqai": { - "extra_returns_per_train": {"total_profit": 4} - } -``` - -The user needs to set the standard dictionary in the config so that FreqAI can return proper dataframe shapes. These values will likely be overridden by the prediction model, but in the case where the model has yet to set them, or needs a default initial value, this is the value that will be returned. - -### Setting up a follower - -The user can define: - -```json - "freqai": { - "follow_mode": true, - "identifier": "example" - } -``` - -to indicate to the bot that it should not train models, but instead should look for models trained by a leader with the same `identifier`. In this example, the user has a leader bot with the `identifier: "example"`. The leader bot is already running or launching simultaneously as the follower. -The follower will load models created by the leader and inference them to obtain predictions. - -## Data manipulation techniques - -### Feature normalization - -The feature set created by the user is automatically normalized to the training data. This includes all test data and unseen prediction data (dry/live/backtest). - -### Reducing data dimensionality with Principal Component Analysis - -Users can reduce the dimensionality of their features by activating the `principal_component_analysis` in the config: - -```json - "freqai": { - "feature_parameters" : { - "principal_component_analysis": true - } - } -``` - -This will perform PCA on the features and reduce the dimensionality of the data so that the explained variance of the data set is >= 0.999. - -### Stratifying the data for training and testing the model - -The user can stratify (group) the training/testing data using: - -```json - "freqai": { - "feature_parameters" : { - "stratify_training_data": 3 - } - } -``` - -This will split the data chronologically so that every Xth data point is used to test the model after training. In the -example above, the user is asking for every third data point in the dataframe to be used for -testing; the other points are used for training. - -The test data is used to evaluate the performance of the model after training. If the test score is high, the model is able to capture the behavior of the data well. If the test score is low, either the model either does not capture the complexity of the data, the test data is significantly different from the train data, or a different model should be used. - -### Using the `inlier_metric` - -The `inlier_metric` is a metric aimed at quantifying how different a prediction data point is from the most recent historic data points. - -User can set `inlier_metric_window` to set the look back window. FreqAI will compute the distance between the present prediction point and each of the previous data points (total of `inlier_metric_window` points). - -This function goes one step further - during training, it computes the `inlier_metric` for all training data points and builds weibull distributions for each each lookback point. The cumulative distribution function for the weibull distribution is used to produce a quantile for each of the data points. The quantiles for each lookback point are averaged to create the `inlier_metric`. - -FreqAI adds this `inlier_metric` score to the training features! In other words, your model is trained to recognize how this temporal inlier metric is related to the user set labels. - -This function does **not** remove outliers from the data set. - -### Controlling the model learning process - -Model training parameters are unique to the machine learning library selected by the user. FreqAI allows the user to set any parameter for any library using the `model_training_parameters` dictionary in the user configuration file. The example configuration file (found in `config_examples/config_freqai.example.json`) show some of the example parameters associated with `Catboost` and `LightGBM`, but the user can add any parameters available in those libraries. - -Data split parameters are defined in `data_split_parameters` which can be any parameters associated with `Sklearn`'s `train_test_split()` function. - -FreqAI includes some additional parameters such as `weight_factor`, which allows the user to weight more recent data more strongly -than past data via an exponential function: - -$$ W_i = \exp(\frac{-i}{\alpha*n}) $$ - -where $W_i$ is the weight of data point $i$ in a total set of $n$ data points. Below is a figure showing the effect of different weight factors on the data points (candles) in a feature set. - -![weight-factor](assets/freqai_weight-factor.jpg) - -`train_test_split()` has a parameters called `shuffle` that allows the user to keep the data unshuffled. This is particularly useful to avoid biasing training with temporally auto-correlated data. - -Finally, `label_period_candles` defines the offset (number of candles into the future) used for the `labels`. In the presented example config, -the user is asking for `labels` that are 24 candles in the future. - -### Outlier removal - -#### Removing outliers with the Dissimilarity Index - -The user can tell FreqAI to remove outlier data points from the training/test data sets using a Dissimilarity Index by including the following statement in the config: - -```json - "freqai": { - "feature_parameters" : { - "DI_threshold": 1 - } - } -``` - -Equity and crypto markets suffer from a high level of non-patterned noise in the form of outlier data points. The Dissimilarity Index (DI) aims to quantify the uncertainty associated with each prediction made by the model. The DI allows predictions which are outliers (not existent in the model feature space) to be thrown out due to low levels of certainty. - -To do so, FreqAI measures the distance between each training data point (feature vector), $X_{a}$, and all other training data points: - -$$ d_{ab} = \sqrt{\sum_{j=1}^p(X_{a,j}-X_{b,j})^2} $$ - -where $d_{ab}$ is the distance between the normalized points $a$ and $b$. $p$ is the number of features, i.e., the length of the vector $X$. The characteristic distance, $\overline{d}$ for a set of training data points is simply the mean of the average distances: - -$$ \overline{d} = \sum_{a=1}^n(\sum_{b=1}^n(d_{ab}/n)/n) $$ - -$\overline{d}$ quantifies the spread of the training data, which is compared to the distance between a new prediction feature vectors, $X_k$ and all the training data: - -$$ d_k = \arg \min d_{k,i} $$ - -which enables the estimation of the Dissimilarity Index as: - -$$ DI_k = d_k/\overline{d} $$ - -The user can tweak the DI through the `DI_threshold` to increase or decrease the extrapolation of the trained model. - -Below is a figure that describes the DI for a 3D data set. - -![DI](assets/freqai_DI.jpg) - -#### Removing outliers using a Support Vector Machine (SVM) - -The user can tell FreqAI to remove outlier data points from the training/test data sets using a SVM by setting: - -```json - "freqai": { - "feature_parameters" : { - "use_SVM_to_remove_outliers": true - } - } -``` - -FreqAI will train an SVM on the training data (or components of it if the user activated -`principal_component_analysis`) and remove any data point that the SVM deems to be beyond the feature space. - -The parameter `shuffle` is by default set to `False` to ensure consistent results. If it is set to `True`, running the SVM multiple times on the same data set might result in different outcomes due to `max_iter` being to low for the algorithm to reach the demanded `tol`. Increasing `max_iter` solves this issue but causes the procedure to take longer time. - -The parameter `nu`, *very* broadly, is the amount of data points that should be considered outliers. - -#### Removing outliers with DBSCAN - -The user can configure FreqAI to use DBSCAN to cluster and remove outliers from the training/test data set or incoming outliers from predictions, by activating `use_DBSCAN_to_remove_outliers` in the config: - -```json - "freqai": { - "feature_parameters" : { - "use_DBSCAN_to_remove_outliers": true - } - } -``` - -DBSCAN is an unsupervised machine learning algorithm that clusters data without needing to know how many clusters there should be. - -Given a number of data points $N$, and a distance $\varepsilon$, DBSCAN clusters the data set by setting all data points that have $N-1$ other data points within a distance of $\varepsilon$ as *core points*. A data point that is within a distance of $\varepsilon$ from a *core point* but that does not have $N-1$ other data points within a distance of $\varepsilon$ from itself is considered an *edge point*. A cluster is then the collection of *core points* and *edge points*. Data points that have no other data points at a distance $<\varepsilon$ are considered outliers. The figure below shows a cluster with $N = 3$. - -![dbscan](assets/freqai_dbscan.jpg) - -FreqAI uses `sklearn.cluster.DBSCAN` (details are available on scikit-learn's webpage [here](#https://scikit-learn.org/stable/modules/generated/sklearn.cluster.DBSCAN.html)) with `min_samples` ($N$) taken as double the no. of user-defined features, and `eps` ($\varepsilon$) taken as the longest distance in the *k-distance graph* computed from the nearest neighbors in the pairwise distances of all data points in the feature set. - -## Additional information - -### Common pitfalls - -FreqAI cannot be combined with dynamic `VolumePairlists` (or any pairlist filter that adds and removes pairs dynamically). -This is for performance reasons - FreqAI relies on making quick predictions/retrains. To do this effectively, -it needs to download all the training data at the beginning of a dry/live instance. FreqAI stores and appends -new candles automatically for future retrains. This means that if new pairs arrive later in the dry run due to a volume pairlist, it will not have the data ready. However, FreqAI does work with the `ShuffleFilter` or a `VolumePairlist` which keeps the total pairlist constant (but reorders the pairs according to volume). +`FreqAI` cannot be combined with dynamic `VolumePairlists` (or any pairlist filter that adds and removes pairs dynamically). +This is for performance reasons - `FreqAI` relies on making quick predictions/retrains. To do this effectively, +it needs to download all the training data at the beginning of a dry/live instance. `FreqAI` stores and appends +new candles automatically for future retrains. This means that if new pairs arrive later in the dry run due to a volume pairlist, it will not have the data ready. However, `FreqAI` does work with the `ShufflePairlist` or a `VolumePairlist` which keeps the total pairlist constant (but reorders the pairs according to volume). ## Credits -FreqAI was developed by a group of individuals who all contributed specific skillsets to the project. +`FreqAI` is developed by a group of individuals who all contribute specific skillsets to the project. Conception and software development: Robert Caulk @robcaulk -Theoretical brainstorming, data analysis: +Theoretical brainstorming and data analysis: Elin Törnquist @th0rntwig -Code review, software architecture brainstorming: +Code review and software architecture brainstorming: @xmatthias +Software development: +Wagner Costa @wagnercosta + Beta testing and bug reporting: -@bloodhunter4rc, Salah Lamkadem @ikonx, @ken11o2, @longyu, @paranoidandy, @smidelis, @smarm, -Juha Nykänen @suikula, Wagner Costa @wagnercosta - - -## Reinforcement Learning - -Setting up and running a Reinforcement Learning model is as quick and simple as running a Regressor. Users can start training and trading live from example files using: - -```bash -freqtrade trade --freqaimodel ReinforcementLearner --strategy ReinforcementLearningExample5ac --strategy-path freqtrade/freqai/example_strats --config config_examples/config_freqai-rl.example.json -``` - -As users begin to modify the strategy and the prediction model, they will quickly realize some important differences between the Reinforcement Learner and the Regressors/Classifiers. Firstly, the strategy does not set a target value (no labels!). Instead, the user sets a `calculate_reward()` function inside their custom `ReinforcementLearner.py` file. A default `calculate_reward()` is provided inside `prediction_models/ReinforcementLearner.py` to give users the necessary building blocks to start their own models. It is inside the `calculate_reward()` where users express their creative theories about the market. For example, the user wants to reward their agent when it makes a winning trade, and penalize the agent when it makes a losing trade. Or perhaps, the user wishes to reward the agnet for entering trades, and penalize the agent for sitting in trades too long. Below we show examples of how these rewards are all calculated: - -```python - class MyRLEnv(Base5ActionRLEnv): - """ - User made custom environment. This class inherits from BaseEnvironment and gym.env. - Users can override any functions from those parent classes. Here is an example - of a user customized `calculate_reward()` function. - """ - def calculate_reward(self, action): - # first, penalize if the action is not valid - if not self._is_valid(action): - return -2 - pnl = self.get_unrealized_profit() - rew = np.sign(pnl) * (pnl + 1) - factor = 100 - # reward agent for entering trades - if action in (Actions.Long_enter.value, Actions.Short_enter.value) \ - and self._position == Positions.Neutral: - return 25 - # discourage agent from not entering trades - if action == Actions.Neutral.value and self._position == Positions.Neutral: - return -1 - max_trade_duration = self.rl_config.get('max_trade_duration_candles', 300) - trade_duration = self._current_tick - self._last_trade_tick - if trade_duration <= max_trade_duration: - factor *= 1.5 - elif trade_duration > max_trade_duration: - factor *= 0.5 - # discourage sitting in position - if self._position in (Positions.Short, Positions.Long) and \ - action == Actions.Neutral.value: - return -1 * trade_duration / max_trade_duration - # close long - if action == Actions.Long_exit.value and self._position == Positions.Long: - if pnl > self.profit_aim * self.rr: - factor *= self.rl_config['model_reward_parameters'].get('win_reward_factor', 2) - return float(rew * factor) - # close short - if action == Actions.Short_exit.value and self._position == Positions.Short: - if pnl > self.profit_aim * self.rr: - factor *= self.rl_config['model_reward_parameters'].get('win_reward_factor', 2) - return float(rew * factor) - return 0. -``` - -After users realize there are no labels to set, they will soon understand that the agent is making its "own" entry and exit decisions. This makes strategy construction rather simple. The entry and exit signals come from the agent in the form of an integer - which are used directly to decide entries and exits in the strategy: - -```python - def populate_any_indicators( - self, pair, df, tf, informative=None, set_generalized_indicators=False - ): - ... - - if set_generalized_indicators: - # For RL, there are no direct targets to set. This sets the base action to neutral - # until the agent sends an action. - df["&-action"] = 0 - - return df - -``` - -and then the `&-action` will be used in `populate_entry/exit` functions: - -```python - def populate_entry_trend(self, df: DataFrame, metadata: dict) -> DataFrame: - - enter_long_conditions = [df["do_predict"] == 1, df["&-action"] == 1] - - if enter_long_conditions: - df.loc[ - reduce(lambda x, y: x & y, enter_long_conditions), ["enter_long", "enter_tag"] - ] = (1, "long") - - enter_short_conditions = [df["do_predict"] == 1, df["&-action"] == 3] - - if enter_short_conditions: - df.loc[ - reduce(lambda x, y: x & y, enter_short_conditions), ["enter_short", "enter_tag"] - ] = (1, "short") - - return df - - def populate_exit_trend(self, df: DataFrame, metadata: dict) -> DataFrame: - exit_long_conditions = [df["do_predict"] == 1, df["&-action"] == 2] - if exit_long_conditions: - df.loc[reduce(lambda x, y: x & y, exit_long_conditions), "exit_long"] = 1 - - exit_short_conditions = [df["do_predict"] == 1, df["&-action"] == 4] - if exit_short_conditions: - df.loc[reduce(lambda x, y: x & y, exit_short_conditions), "exit_short"] = 1 - - return df -``` - -Users should be careful to consider that `&-action` depends on which environment they choose to use. The example above shows 5 actions, where 0 is neutral, 1 is enter long, 2 is exit long, 3 is enter short and 4 is exit short. - -### Creating a custom agent - -Users can inherit from `stable_baselines3` and customize anything they wish about their agent. Doing this is for advanced users only, an example is presented in `freqai/RL/ReinforcementLearnerCustomAgent.py` - -### Using Tensorboard - -Reinforcement Learning models benefit from tracking training metrics. FreqAI has integrated Tensorboard to allow users to track training and evaluation performance across all coins and across all retrainings. To start, the user should ensure Tensorboard is installed on their computer: - -```bash -pip3 install tensorboard -``` - -Next, the user can activate Tensorboard with the following command: - -```bash -cd freqtrade -tensorboard --logdir user_data/models/unique-id -``` - -where `unique-id` is the `identifier` set in the `freqai` configuration file. - -![tensorboard](assets/tensorboard.png) \ No newline at end of file +Stefan Gehring @bloodhunter4rc, @longyu, Andrew Robert Lawless @paranoidandy, Pascal Schmidt @smidelis, Ryan McMullan @smarmau, +Juha Nykänen @suikula, Johan van der Vlugt @jooopiert, Richárd Józsa @richardjosza diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index 0f55c1b79..7dff75a02 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -22,6 +22,7 @@ You may also use something like `.*DOWN/BTC` or `.*UP/BTC` to exclude leveraged * [`StaticPairList`](#static-pair-list) (default, if not configured differently) * [`VolumePairList`](#volume-pair-list) +* [`ProducerPairList`](#producerpairlist) * [`AgeFilter`](#agefilter) * [`OffsetFilter`](#offsetfilter) * [`PerformanceFilter`](#performancefilter) @@ -84,7 +85,7 @@ Filtering instances (not the first position in the list) will not apply any cach You can define a minimum volume with `min_value` - which will filter out pairs with a volume lower than the specified value in the specified timerange. -### VolumePairList Advanced mode +##### VolumePairList Advanced mode `VolumePairList` can also operate in an advanced mode to build volume over a given timerange of specified candle size. It utilizes exchange historical candle data, builds a typical price (calculated by (open+high+low)/3) and multiplies the typical price with every candle's volume. The sum is the `quoteVolume` over the given range. This allows different scenarios, for a more smoothened volume, when using longer ranges with larger candle sizes, or the opposite when using a short range with small candles. @@ -146,6 +147,32 @@ More sophisticated approach can be used, by using `lookback_timeframe` for candl !!! Note `VolumePairList` does not support backtesting mode. +#### ProducerPairList + +With `ProducerPairList`, you can reuse the pairlist from a [Producer](producer-consumer.md) without explicitly defining the pairlist on each consumer. + +[Consumer mode](producer-consumer.md) is required for this pairlist to work. + +The pairlist will perform a check on active pairs against the current exchange configuration to avoid attempting to trade on invalid markets. + +You can limit the length of the pairlist with the optional parameter `number_assets`. Using `"number_assets"=0` or omitting this key will result in the reuse of all producer pairs valid for the current setup. + +```json +"pairlists": [ + { + "method": "ProducerPairList", + "number_assets": 5, + "producer_name": "default", + } +], +``` + + +!!! Tip "Combining pairlists" + This pairlist can be combined with all other pairlists and filters for further pairlist reduction, and can also act as an "additional" pairlist, on top of already defined pairs. + `ProducerPairList` can also be used multiple times in sequence, combining the pairs from multiple producers. + Obviously in complex such configurations, the Producer may not provide data for all pairs, so the strategy must be fit for this. + #### AgeFilter Removes pairs that have been listed on the exchange for less than `min_days_listed` days (defaults to `10`) or more than `max_days_listed` days (defaults `None` mean infinity). diff --git a/docs/producer-consumer.md b/docs/producer-consumer.md new file mode 100644 index 000000000..b69406edf --- /dev/null +++ b/docs/producer-consumer.md @@ -0,0 +1,163 @@ +# Producer / Consumer mode + +freqtrade provides a mechanism whereby an instance (also called `consumer`) may listen to messages from an upstream freqtrade instance (also called `producer`) using the message websocket. Mainly, `analyzed_df` and `whitelist` messages. This allows the reuse of computed indicators (and signals) for pairs in multiple bots without needing to compute them multiple times. + +See [Message Websocket](rest-api.md#message-websocket) in the Rest API docs for setting up the `api_server` configuration for your message websocket (this will be your producer). + +!!! Note + We strongly recommend to set `ws_token` to something random and known only to yourself to avoid unauthorized access to your bot. + +## Configuration + +Enable subscribing to an instance by adding the `external_message_consumer` section to the consumer's config file. + +```json +{ + //... + "external_message_consumer": { + "enabled": true, + "producers": [ + { + "name": "default", // This can be any name you'd like, default is "default" + "host": "127.0.0.1", // The host from your producer's api_server config + "port": 8080, // The port from your producer's api_server config + "ws_token": "sercet_Ws_t0ken" // The ws_token from your producer's api_server config + } + ], + // The following configurations are optional, and usually not required + // "wait_timeout": 300, + // "ping_timeout": 10, + // "sleep_time": 10, + // "remove_entry_exit_signals": false, + // "message_size_limit": 8 + } + //... +} +``` + +| Parameter | Description | +|------------|-------------| +| `enabled` | **Required.** Enable consumer mode. If set to false, all other settings in this section are ignored.
*Defaults to `false`.*
**Datatype:** boolean . +| `producers` | **Required.** List of producers
**Datatype:** Array. +| `producers.name` | **Required.** Name of this producer. This name must be used in calls to `get_producer_pairs()` and `get_producer_df()` if more than one producer is used.
**Datatype:** string +| `producers.host` | **Required.** The hostname or IP address from your producer.
**Datatype:** string +| `producers.port` | **Required.** The port matching the above host.
**Datatype:** string +| `producers.ws_token` | **Required.** `ws_token` as configured on the producer.
**Datatype:** string +| | **Optional settings** +| `wait_timeout` | Timeout until we ping again if no message is received.
*Defaults to `300`.*
**Datatype:** Integer - in seconds. +| `wait_timeout` | Ping timeout
*Defaults to `10`.*
**Datatype:** Integer - in seconds. +| `sleep_time` | Sleep time before retrying to connect.
*Defaults to `10`.*
**Datatype:** Integer - in seconds. +| `remove_entry_exit_signals` | Remove signal columns from the dataframe (set them to 0) on dataframe receipt.
*Defaults to `10`.*
**Datatype:** Integer - in seconds. +| `message_size_limit` | Size limit per message
*Defaults to `8`.*
**Datatype:** Integer - Megabytes. + +Instead of (or as well as) calculating indicators in `populate_indicators()` the follower instance listens on the connection to a producer instance's messages (or multiple producer instances in advanced configurations) and requests the producer's most recently analyzed dataframes for each pair in the active whitelist. + +A consumer instance will then have a full copy of the analyzed dataframes without the need to calculate them itself. + +## Examples + +### Example - Producer Strategy + +A simple strategy with multiple indicators. No special considerations are required in the strategy itself. + +```py +class ProducerStrategy(IStrategy): + #... + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + Calculate indicators in the standard freqtrade way which can then be broadcast to other instances + """ + dataframe['rsi'] = ta.RSI(dataframe) + bollinger = qtpylib.bollinger_bands(qtpylib.typical_price(dataframe), window=20, stds=2) + dataframe['bb_lowerband'] = bollinger['lower'] + dataframe['bb_middleband'] = bollinger['mid'] + dataframe['bb_upperband'] = bollinger['upper'] + dataframe['tema'] = ta.TEMA(dataframe, timeperiod=9) + + return dataframe + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + Populates the entry signal for the given dataframe + """ + dataframe.loc[ + ( + (qtpylib.crossed_above(dataframe['rsi'], self.buy_rsi.value)) & + (dataframe['tema'] <= dataframe['bb_middleband']) & + (dataframe['tema'] > dataframe['tema'].shift(1)) & + (dataframe['volume'] > 0) + ), + 'enter_long'] = 1 + + return dataframe +``` + +!!! Tip "FreqAI" + You can use this to setup [FreqAI](freqai.md) on a powerful machine, while you run consumers on simple machines like raspberries, which can interpret the signals generated from the producer in different ways. + + +### Example - Consumer Strategy + +A logically equivalent strategy which calculates no indicators itself, but will have the same analyzed dataframes available to make trading decisions based on the indicators calculated in the producer. In this example the consumer has the same entry criteria, however this is not necessary. The consumer may use different logic to enter/exit trades, and only use the indicators as specified. + +```py +class ConsumerStrategy(IStrategy): + #... + process_only_new_candles = False # required for consumers + + _columns_to_expect = ['rsi_default', 'tema_default', 'bb_middleband_default'] + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + Use the websocket api to get pre-populated indicators from another freqtrade instance. + Use `self.dp.get_producer_df(pair)` to get the dataframe + """ + pair = metadata['pair'] + timeframe = self.timeframe + + producer_pairs = self.dp.get_producer_pairs() + # You can specify which producer to get pairs from via: + # self.dp.get_producer_pairs("my_other_producer") + + # This func returns the analyzed dataframe, and when it was analyzed + producer_dataframe, _ = self.dp.get_producer_df(pair) + # You can get other data if the producer makes it available: + # self.dp.get_producer_df( + # pair, + # timeframe="1h", + # candle_type=CandleType.SPOT, + # producer_name="my_other_producer" + # ) + + if not producer_dataframe.empty: + # If you plan on passing the producer's entry/exit signal directly, + # specify ffill=False or it will have unintended results + merged_dataframe = merge_informative_pair(dataframe, producer_dataframe, + timeframe, timeframe, + append_timeframe=False, + suffix="default") + return merged_dataframe + else: + dataframe[self._columns_to_expect] = 0 + + return dataframe + + def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + """ + Populates the entry signal for the given dataframe + """ + # Use the dataframe columns as if we calculated them ourselves + dataframe.loc[ + ( + (qtpylib.crossed_above(dataframe['rsi_default'], self.buy_rsi.value)) & + (dataframe['tema_default'] <= dataframe['bb_middleband_default']) & + (dataframe['tema_default'] > dataframe['tema_default'].shift(1)) & + (dataframe['volume'] > 0) + ), + 'enter_long'] = 1 + + return dataframe +``` + +!!! Tip "Using upstream signals" + By setting `remove_entry_exit_signals=false`, you can also use the producer's signals directly. They should be available as `enter_long_default` (assuming `suffix="default"` was used) - and can be used as either signal directly, or as additional indicator. diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index da6713b76..176947438 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,6 +1,6 @@ markdown==3.3.7 mkdocs==1.3.1 -mkdocs-material==8.5.2 +mkdocs-material==8.5.3 mdx_truly_sane_lists==1.3 pymdown-extensions==9.5 jinja2==3.1.2 diff --git a/docs/rest-api.md b/docs/rest-api.md index cc82aadda..c7d762648 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -31,7 +31,8 @@ Sample configuration: "jwt_secret_key": "somethingrandom", "CORS_origins": [], "username": "Freqtrader", - "password": "SuperSecret1!" + "password": "SuperSecret1!", + "ws_token": "sercet_Ws_t0ken" }, ``` @@ -66,7 +67,7 @@ secrets.token_hex() !!! Danger "Password selection" Please make sure to select a very strong, unique password to protect your bot from unauthorized access. - Also change `jwt_secret_key` to something random (no need to remember this, but it'll be used to encrypt your session, so it better be something unique!). + Also change `jwt_secret_key` to something random (no need to remember this, but it'll be used to encrypt your session, so it better be something unique!). ### Configuration with docker @@ -93,7 +94,6 @@ Make sure that the following 2 lines are available in your docker-compose file: !!! Danger "Security warning" By using `8080:8080` in the docker port mapping, the API will be available to everyone connecting to the server under the correct port, so others may be able to control your bot. - ## Rest API ### Consuming the API @@ -274,7 +274,7 @@ reload_config Reload configuration. show_config - + Returns part of the configuration, relevant for trading operations. start @@ -322,6 +322,73 @@ whitelist ``` +### Message WebSocket + +The API Server includes a websocket endpoint for subscribing to RPC messages from the freqtrade Bot. +This can be used to consume real-time data from your bot, such as entry/exit fill messages, whitelist changes, populated indicators for pairs, and more. + +This is also used to setup [Producer/Consumer mode](producer-consumer.md) in Freqtrade. + +Assuming your rest API is set to `127.0.0.1` on port `8080`, the endpoint is available at `http://localhost:8080/api/v1/message/ws`. + +To access the websocket endpoint, the `ws_token` is required as a query parameter in the endpoint URL. + +To generate a safe `ws_token` you can run the following code: + +``` python +>>> import secrets +>>> secrets.token_urlsafe(25) +'hZ-y58LXyX_HZ8O1cJzVyN6ePWrLpNQv4Q' +``` + +You would then add that token under `ws_token` in your `api_server` config. Like so: + +``` json +"api_server": { + "enabled": true, + "listen_ip_address": "127.0.0.1", + "listen_port": 8080, + "verbosity": "error", + "enable_openapi": false, + "jwt_secret_key": "somethingrandom", + "CORS_origins": [], + "username": "Freqtrader", + "password": "SuperSecret1!", + "ws_token": "hZ-y58LXyX_HZ8O1cJzVyN6ePWrLpNQv4Q" // <----- +}, +``` + +You can now connect to the endpoint at `http://localhost:8080/api/v1/message/ws?token=hZ-y58LXyX_HZ8O1cJzVyN6ePWrLpNQv4Q`. + +!!! Danger "Reuse of example tokens" + Please do not use the above example token. To make sure you are secure, generate a completely new token. + +#### Using the WebSocket + +Once connected to the WebSocket, the bot will broadcast RPC messages to anyone who is subscribed to them. To subscribe to a list of messages, you must send a JSON request through the WebSocket like the one below. The `data` key must be a list of message type strings. + +``` json +{ + "type": "subscribe", + "data": ["whitelist", "analyzed_df"] // A list of string message types +} +``` + +For a list of message types, please refer to the RPCMessageType enum in `freqtrade/enums/rpcmessagetype.py` + +Now anytime those types of RPC messages are sent in the bot, you will receive them through the WebSocket as long as the connection is active. They typically take the same form as the request: + +``` json +{ + "type": "analyzed_df", + "data": { + "key": ["NEO/BTC", "5m", "spot"], + "df": {}, // The dataframe + "la": "2022-09-08 22:14:41.457786+00:00" + } +} +``` + ### OpenAPI interface To enable the builtin openAPI interface (Swagger UI), specify `"enable_openapi": true` in the api_server configuration. diff --git a/docs/strategy-advanced.md b/docs/strategy-advanced.md index a3115bfb2..f55cda5e2 100644 --- a/docs/strategy-advanced.md +++ b/docs/strategy-advanced.md @@ -106,6 +106,12 @@ def custom_exit(self, pair: str, trade: Trade, current_time: datetime, current_r !!! Note `enter_tag` is limited to 100 characters, remaining data will be truncated. +!!! Warning + There is only one `enter_tag` column, which is used for both long and short trades. + As a consequence, this column must be treated as "last write wins" (it's just a dataframe column after all). + In fancy situations, where multiple signals collide (or if signals are deactivated again based on different conditions), this can lead to odd results with the wrong tag applied to an entry signal. + These results are a consequence of the strategy overwriting prior tags - where the last tag will "stick" and will be the one freqtrade will use. + ## Exit tag Similar to [Buy Tagging](#buy-tag), you can also specify a sell tag. diff --git a/docs/strategy-callbacks.md b/docs/strategy-callbacks.md index 0b8403414..ea10fc472 100644 --- a/docs/strategy-callbacks.md +++ b/docs/strategy-callbacks.md @@ -643,7 +643,7 @@ This callback is **not** called when there is an open order (either buy or sell) Additional Buys are ignored once you have reached the maximum amount of extra buys that you have set on `max_entry_position_adjustment`, but the callback is called anyway looking for partial exits. -Position adjustments will always be applied in the direction of the trade, so a positive value will always increase your position (negative values will decrease your position), no matter if it's a long or short trade. Modifications to leverage are not possible. +Position adjustments will always be applied in the direction of the trade, so a positive value will always increase your position (negative values will decrease your position), no matter if it's a long or short trade. Modifications to leverage are not possible, and the stake-amount is assumed to be before applying leverage. !!! Note "About stake size" Using fixed stake size means it will be the amount used for the first order, just like without position adjustment. diff --git a/docs/updating.md b/docs/updating.md index 8dc7279a4..893bc846e 100644 --- a/docs/updating.md +++ b/docs/updating.md @@ -37,3 +37,12 @@ pip install -e . # Ensure freqUI is at the latest version freqtrade install-ui ``` + +### Problems updating + +Update-problems usually come missing dependencies (you didn't follow the above instructions) - or from updated dependencies, which fail to install (for example TA-lib). +Please refer to the corresponding installation sections (common problems linked below) + +Common problems and their solutions: + +* [ta-lib update on windows](windows_installation.md#2-install-ta-lib) diff --git a/docs/windows_installation.md b/docs/windows_installation.md index 242c994c4..5cfae8c10 100644 --- a/docs/windows_installation.md +++ b/docs/windows_installation.md @@ -23,7 +23,7 @@ git clone https://github.com/freqtrade/freqtrade.git Install ta-lib according to the [ta-lib documentation](https://github.com/mrjbq7/ta-lib#windows). -As compiling from source on windows has heavy dependencies (requires a partial visual studio installation), there is also a repository of unofficial pre-compiled windows Wheels [here](https://www.lfd.uci.edu/~gohlke/pythonlibs/#ta-lib), which need to be downloaded and installed using `pip install TA_Lib-0.4.24-cp38-cp38-win_amd64.whl` (make sure to use the version matching your python version). +As compiling from source on windows has heavy dependencies (requires a partial visual studio installation), there is also a repository of unofficial pre-compiled windows Wheels [here](https://www.lfd.uci.edu/~gohlke/pythonlibs/#ta-lib), which need to be downloaded and installed using `pip install TA_Lib-0.4.25-cp38-cp38-win_amd64.whl` (make sure to use the version matching your python version). Freqtrade provides these dependencies for the latest 3 Python versions (3.8, 3.9 and 3.10) and for 64bit Windows. Other versions must be downloaded from the above link. @@ -34,7 +34,7 @@ python -m venv .env .env\Scripts\activate.ps1 # optionally install ta-lib from wheel # Eventually adjust the below filename to match the downloaded wheel -pip install build_helpers/TA_Lib-0.4.19-cp38-cp38-win_amd64.whl +pip install --find-links build_helpers\ TA-Lib -U pip install -r requirements.txt pip install -e . freqtrade diff --git a/environment.yml b/environment.yml index d6d85de9d..5298b2baa 100644 --- a/environment.yml +++ b/environment.yml @@ -34,6 +34,7 @@ dependencies: - schedule - python-dateutil - joblib + - pyarrow # ============================ diff --git a/freqtrade/__init__.py b/freqtrade/__init__.py index 77c305c66..1e62266a8 100644 --- a/freqtrade/__init__.py +++ b/freqtrade/__init__.py @@ -1,5 +1,5 @@ """ Freqtrade bot """ -__version__ = '2022.9.dev' +__version__ = '2022.10.dev' if 'dev' in __version__: try: diff --git a/freqtrade/commands/build_config_commands.py b/freqtrade/commands/build_config_commands.py index 01cfa800a..1abd26328 100644 --- a/freqtrade/commands/build_config_commands.py +++ b/freqtrade/commands/build_config_commands.py @@ -211,6 +211,7 @@ def ask_user_config() -> Dict[str, Any]: ) # Force JWT token to be a random string answers['api_server_jwt_key'] = secrets.token_hex() + answers['api_server_ws_token'] = secrets.token_urlsafe(25) return answers diff --git a/freqtrade/commands/cli_options.py b/freqtrade/commands/cli_options.py index f383f0768..e50fb86d8 100644 --- a/freqtrade/commands/cli_options.py +++ b/freqtrade/commands/cli_options.py @@ -440,7 +440,7 @@ AVAILABLE_CLI_OPTIONS = { "dataformat_trades": Arg( '--data-format-trades', help='Storage format for downloaded trades data. (default: `jsongz`).', - choices=constants.AVAILABLE_DATAHANDLERS, + choices=constants.AVAILABLE_DATAHANDLERS_TRADES, ), "show_timerange": Arg( '--show-timerange', diff --git a/freqtrade/configuration/config_validation.py b/freqtrade/configuration/config_validation.py index 8d9112bef..7055d9551 100644 --- a/freqtrade/configuration/config_validation.py +++ b/freqtrade/configuration/config_validation.py @@ -1,4 +1,5 @@ import logging +from collections import Counter from copy import deepcopy from typing import Any, Dict @@ -85,6 +86,7 @@ def validate_config_consistency(conf: Dict[str, Any], preliminary: bool = False) _validate_unlimited_amount(conf) _validate_ask_orderbook(conf) _validate_freqai_hyperopt(conf) + _validate_consumers(conf) validate_migrated_strategy_settings(conf) # validate configuration before returning @@ -332,6 +334,23 @@ def _validate_freqai_hyperopt(conf: Dict[str, Any]) -> None: 'Using analyze-per-epoch parameter is not supported with a FreqAI strategy.') +def _validate_consumers(conf: Dict[str, Any]) -> None: + emc_conf = conf.get('external_message_consumer', {}) + if emc_conf.get('enabled', False): + if len(emc_conf.get('producers', [])) < 1: + raise OperationalException("You must specify at least 1 Producer to connect to.") + + producer_names = [p['name'] for p in emc_conf.get('producers', [])] + duplicates = [item for item, count in Counter(producer_names).items() if count > 1] + if duplicates: + raise OperationalException( + f"Producer names must be unique. Duplicate: {', '.join(duplicates)}") + if conf.get('process_only_new_candles', True): + # Warning here or require it? + logger.warning("To receive best performance with external data, " + "please set `process_only_new_candles` to False") + + def _strategy_settings(conf: Dict[str, Any]) -> None: process_deprecated_setting(conf, None, 'use_sell_signal', None, 'use_exit_signal') diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 0b01a7db8..4fa3b7481 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -31,12 +31,13 @@ HYPEROPT_LOSS_BUILTIN = ['ShortTradeDurHyperOptLoss', 'OnlyProfitHyperOptLoss', 'CalmarHyperOptLoss', 'MaxDrawDownHyperOptLoss', 'MaxDrawDownRelativeHyperOptLoss', 'ProfitDrawDownHyperOptLoss'] -AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList', +AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList', 'ProducerPairList', 'AgeFilter', 'OffsetFilter', 'PerformanceFilter', 'PrecisionFilter', 'PriceFilter', 'RangeStabilityFilter', 'ShuffleFilter', 'SpreadFilter', 'VolatilityFilter'] AVAILABLE_PROTECTIONS = ['CooldownPeriod', 'LowProfitPairs', 'MaxDrawdown', 'StoplossGuard'] -AVAILABLE_DATAHANDLERS = ['json', 'jsongz', 'hdf5'] +AVAILABLE_DATAHANDLERS_TRADES = ['json', 'jsongz', 'hdf5'] +AVAILABLE_DATAHANDLERS = AVAILABLE_DATAHANDLERS_TRADES + ['feather', 'parquet'] BACKTEST_BREAKDOWNS = ['day', 'week', 'month'] BACKTEST_CACHE_AGE = ['none', 'day', 'week', 'month'] BACKTEST_CACHE_DEFAULT = 'day' @@ -243,6 +244,7 @@ CONF_SCHEMA = { 'exchange': {'$ref': '#/definitions/exchange'}, 'edge': {'$ref': '#/definitions/edge'}, 'freqai': {'$ref': '#/definitions/freqai'}, + 'external_message_consumer': {'$ref': '#/definitions/external_message_consumer'}, 'experimental': { 'type': 'object', 'properties': { @@ -404,6 +406,7 @@ CONF_SCHEMA = { }, 'username': {'type': 'string'}, 'password': {'type': 'string'}, + 'ws_token': {'type': ['string', 'array'], 'items': {'type': 'string'}}, 'jwt_secret_key': {'type': 'string'}, 'CORS_origins': {'type': 'array', 'items': {'type': 'string'}}, 'verbosity': {'type': 'string', 'enum': ['error', 'info']}, @@ -432,7 +435,7 @@ CONF_SCHEMA = { }, 'dataformat_trades': { 'type': 'string', - 'enum': AVAILABLE_DATAHANDLERS, + 'enum': AVAILABLE_DATAHANDLERS_TRADES, 'default': 'jsongz' }, 'position_adjustment_enable': {'type': 'boolean'}, @@ -488,6 +491,47 @@ CONF_SCHEMA = { }, 'required': ['process_throttle_secs', 'allowed_risk'] }, + 'external_message_consumer': { + 'type': 'object', + 'properties': { + 'enabled': {'type': 'boolean', 'default': False}, + 'producers': { + 'type': 'array', + 'items': { + 'type': 'object', + 'properties': { + 'name': {'type': 'string'}, + 'host': {'type': 'string'}, + 'port': { + 'type': 'integer', + 'default': 8080, + 'minimum': 0, + 'maximum': 65535 + }, + 'ws_token': {'type': 'string'}, + }, + 'required': ['name', 'host', 'ws_token'] + } + }, + 'wait_timeout': {'type': 'integer', 'minimum': 0}, + 'sleep_time': {'type': 'integer', 'minimum': 0}, + 'ping_timeout': {'type': 'integer', 'minimum': 0}, + 'remove_entry_exit_signals': {'type': 'boolean', 'default': False}, + 'initial_candle_limit': { + 'type': 'integer', + 'minimum': 0, + 'maximum': 1500, + 'default': 1500 + }, + 'message_size_limit': { # In megabytes + 'type': 'integer', + 'minimum': 1, + 'maxmium': 20, + 'default': 8, + } + }, + 'required': ['producers'] + }, "freqai": { "type": "object", "properties": { @@ -508,7 +552,7 @@ CONF_SCHEMA = { "weight_factor": {"type": "number", "default": 0}, "principal_component_analysis": {"type": "boolean", "default": False}, "use_SVM_to_remove_outliers": {"type": "boolean", "default": False}, - "plot_feature_importance": {"type": "boolean", "default": False}, + "plot_feature_importances": {"type": "integer", "default": 0}, "svm_params": {"type": "object", "properties": { "shuffle": {"type": "boolean", "default": False}, @@ -523,6 +567,7 @@ CONF_SCHEMA = { "properties": { "test_size": {"type": "number"}, "random_state": {"type": "integer"}, + "shuffle": {"type": "boolean", "default": False} }, }, "model_training_parameters": { diff --git a/freqtrade/data/btanalysis.py b/freqtrade/data/btanalysis.py index 9e38f6833..c32db9165 100644 --- a/freqtrade/data/btanalysis.py +++ b/freqtrade/data/btanalysis.py @@ -284,7 +284,7 @@ def load_backtest_data(filename: Union[Path, str], strategy: Optional[str] = Non df['enter_tag'] = df['buy_tag'] df = df.drop(['buy_tag'], axis=1) if 'orders' not in df.columns: - df.loc[:, 'orders'] = None + df['orders'] = None else: # old format - only with lists. @@ -341,9 +341,9 @@ def trade_list_to_dataframe(trades: List[LocalTrade]) -> pd.DataFrame: """ df = pd.DataFrame.from_records([t.to_json(True) for t in trades], columns=BT_DATA_COLUMNS) if len(df) > 0: - 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[:, 'close_rate'] = df['close_rate'].astype('float64') + df['close_date'] = pd.to_datetime(df['close_date'], utc=True) + df['open_date'] = pd.to_datetime(df['open_date'], utc=True) + df['close_rate'] = df['close_rate'].astype('float64') return df diff --git a/freqtrade/data/converter.py b/freqtrade/data/converter.py index 67461973f..98ed15489 100644 --- a/freqtrade/data/converter.py +++ b/freqtrade/data/converter.py @@ -47,8 +47,7 @@ def ohlcv_to_dataframe(ohlcv: list, timeframe: str, pair: str, *, def clean_ohlcv_dataframe(data: DataFrame, timeframe: str, pair: str, *, - fill_missing: bool = True, - drop_incomplete: bool = True) -> DataFrame: + fill_missing: bool, drop_incomplete: bool) -> DataFrame: """ Cleanse a OHLCV dataframe by * Grouping it by date (removes duplicate tics) diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 43850ddd9..4d7296ee7 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -14,9 +14,10 @@ from pandas import DataFrame from freqtrade.configuration import TimeRange from freqtrade.constants import Config, ListPairsWithTimeframes, PairWithTimeframe from freqtrade.data.history import load_pair_history -from freqtrade.enums import CandleType, RunMode +from freqtrade.enums import CandleType, RPCMessageType, RunMode from freqtrade.exceptions import ExchangeError, OperationalException from freqtrade.exchange import Exchange, timeframe_to_seconds +from freqtrade.rpc import RPCManager from freqtrade.util import PeriodicCache @@ -28,17 +29,33 @@ MAX_DATAFRAME_CANDLES = 1000 class DataProvider: - def __init__(self, config: Config, exchange: Optional[Exchange], pairlists=None) -> None: + def __init__( + self, + config: Config, + exchange: Optional[Exchange], + pairlists=None, + rpc: Optional[RPCManager] = None + ) -> None: self._config = config self._exchange = exchange self._pairlists = pairlists + self.__rpc = rpc self.__cached_pairs: Dict[PairWithTimeframe, Tuple[DataFrame, datetime]] = {} self.__slice_index: Optional[int] = None self.__cached_pairs_backtesting: Dict[PairWithTimeframe, DataFrame] = {} + self.__producer_pairs_df: Dict[str, + Dict[PairWithTimeframe, Tuple[DataFrame, datetime]]] = {} + self.__producer_pairs: Dict[str, List[str]] = {} self._msg_queue: deque = deque() + self._default_candle_type = self._config.get('candle_type_def', CandleType.SPOT) + self._default_timeframe = self._config.get('timeframe', '1h') + self.__msg_cache = PeriodicCache( - maxsize=1000, ttl=timeframe_to_seconds(self._config.get('timeframe', '1h'))) + maxsize=1000, ttl=timeframe_to_seconds(self._default_timeframe)) + + self.producers = self._config.get('external_message_consumer', {}).get('producers', []) + self.external_data_enabled = len(self.producers) > 0 def _set_dataframe_max_index(self, limit_index: int): """ @@ -63,9 +80,110 @@ class DataProvider: :param dataframe: analyzed dataframe :param candle_type: Any of the enum CandleType (must match trading mode!) """ - self.__cached_pairs[(pair, timeframe, candle_type)] = ( + pair_key = (pair, timeframe, candle_type) + self.__cached_pairs[pair_key] = ( dataframe, datetime.now(timezone.utc)) + # For multiple producers we will want to merge the pairlists instead of overwriting + def _set_producer_pairs(self, pairlist: List[str], producer_name: str = "default"): + """ + Set the pairs received to later be used. + + :param pairlist: List of pairs + """ + self.__producer_pairs[producer_name] = pairlist + + def get_producer_pairs(self, producer_name: str = "default") -> List[str]: + """ + Get the pairs cached from the producer + + :returns: List of pairs + """ + return self.__producer_pairs.get(producer_name, []).copy() + + def _emit_df( + self, + pair_key: PairWithTimeframe, + dataframe: DataFrame + ) -> None: + """ + Send this dataframe as an ANALYZED_DF message to RPC + + :param pair_key: PairWithTimeframe tuple + :param data: Tuple containing the DataFrame and the datetime it was cached + """ + if self.__rpc: + self.__rpc.send_msg( + { + 'type': RPCMessageType.ANALYZED_DF, + 'data': { + 'key': pair_key, + 'df': dataframe, + 'la': datetime.now(timezone.utc) + } + } + ) + + def _add_external_df( + self, + pair: str, + dataframe: DataFrame, + last_analyzed: datetime, + timeframe: str, + candle_type: CandleType, + producer_name: str = "default" + ) -> None: + """ + Add the pair data to this class from an external source. + + :param pair: pair to get the data for + :param timeframe: Timeframe to get data for + :param candle_type: Any of the enum CandleType (must match trading mode!) + """ + pair_key = (pair, timeframe, candle_type) + + if producer_name not in self.__producer_pairs_df: + self.__producer_pairs_df[producer_name] = {} + + _last_analyzed = datetime.now(timezone.utc) if not last_analyzed else last_analyzed + + self.__producer_pairs_df[producer_name][pair_key] = (dataframe, _last_analyzed) + logger.debug(f"External DataFrame for {pair_key} from {producer_name} added.") + + def get_producer_df( + self, + pair: str, + timeframe: Optional[str] = None, + candle_type: Optional[CandleType] = None, + producer_name: str = "default" + ) -> Tuple[DataFrame, datetime]: + """ + Get the pair data from producers. + + :param pair: pair to get the data for + :param timeframe: Timeframe to get data for + :param candle_type: Any of the enum CandleType (must match trading mode!) + :returns: Tuple of the DataFrame and last analyzed timestamp + """ + _timeframe = self._default_timeframe if not timeframe else timeframe + _candle_type = self._default_candle_type if not candle_type else candle_type + + pair_key = (pair, _timeframe, _candle_type) + + # If we have no data from this Producer yet + if producer_name not in self.__producer_pairs_df: + # We don't have this data yet, return empty DataFrame and datetime (01-01-1970) + return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc)) + + # If we do have data from that Producer, but no data on this pair_key + if pair_key not in self.__producer_pairs_df[producer_name]: + # We don't have this data yet, return empty DataFrame and datetime (01-01-1970) + return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc)) + + # We have it, return this data + df, la = self.__producer_pairs_df[producer_name][pair_key] + return (df.copy(), la) + def add_pairlisthandler(self, pairlists) -> None: """ Allow adding pairlisthandler after initialization @@ -90,8 +208,10 @@ class DataProvider: if saved_pair not in self.__cached_pairs_backtesting: timerange = TimeRange.parse_timerange(None if self._config.get( 'timerange') is None else str(self._config.get('timerange'))) - # Move informative start time respecting startup_candle_count - startup_candles = self.get_required_startup(str(timeframe)) + + # It is not necessary to add the training candles, as they + # were already added at the beginning of the backtest. + startup_candles = self.get_required_startup(str(timeframe), False) tf_seconds = timeframe_to_seconds(str(timeframe)) timerange.subtract_start(tf_seconds * startup_candles) self.__cached_pairs_backtesting[saved_pair] = load_pair_history( @@ -105,7 +225,7 @@ class DataProvider: ) return self.__cached_pairs_backtesting[saved_pair].copy() - def get_required_startup(self, timeframe: str) -> int: + def get_required_startup(self, timeframe: str, add_train_candles: bool = True) -> int: freqai_config = self._config.get('freqai', {}) if not freqai_config.get('enabled', False): return self._config.get('startup_candle_count', 0) @@ -115,7 +235,9 @@ class DataProvider: # make sure the startupcandles is at least the set maximum indicator periods self._config['startup_candle_count'] = max(startup_candles, max(indicator_periods)) tf_seconds = timeframe_to_seconds(timeframe) - train_candles = freqai_config['train_period_days'] * 86400 / tf_seconds + train_candles = 0 + if add_train_candles: + train_candles = freqai_config['train_period_days'] * 86400 / tf_seconds total_candles = int(self._config['startup_candle_count'] + train_candles) logger.info(f'Increasing startup_candle_count for freqai to {total_candles}') return total_candles diff --git a/freqtrade/data/history/featherdatahandler.py b/freqtrade/data/history/featherdatahandler.py new file mode 100644 index 000000000..22a6805e7 --- /dev/null +++ b/freqtrade/data/history/featherdatahandler.py @@ -0,0 +1,130 @@ +import logging +from typing import Optional + +from pandas import DataFrame, read_feather, to_datetime + +from freqtrade.configuration import TimeRange +from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS, TradeList +from freqtrade.enums import CandleType + +from .idatahandler import IDataHandler + + +logger = logging.getLogger(__name__) + + +class FeatherDataHandler(IDataHandler): + + _columns = DEFAULT_DATAFRAME_COLUMNS + + def ohlcv_store( + self, pair: str, timeframe: str, data: DataFrame, candle_type: CandleType) -> None: + """ + Store data in json format "values". + format looks as follows: + [[,,,,]] + :param pair: Pair - used to generate filename + :param timeframe: Timeframe - used to generate filename + :param data: Dataframe containing OHLCV data + :param candle_type: Any of the enum CandleType (must match trading mode!) + :return: None + """ + filename = self._pair_data_filename(self._datadir, pair, timeframe, candle_type) + self.create_dir_if_needed(filename) + + data.reset_index(drop=True).loc[:, self._columns].to_feather( + filename, compression_level=9, compression='lz4') + + def _ohlcv_load(self, pair: str, timeframe: str, + timerange: Optional[TimeRange], candle_type: CandleType + ) -> DataFrame: + """ + Internal method used to load data for one pair from disk. + Implements the loading and conversion to a Pandas dataframe. + Timerange trimming and dataframe validation happens outside of this method. + :param pair: Pair to load data + :param timeframe: Timeframe (e.g. "5m") + :param timerange: Limit data to be loaded to this timerange. + Optionally implemented by subclasses to avoid loading + all data where possible. + :param candle_type: Any of the enum CandleType (must match trading mode!) + :return: DataFrame with ohlcv data, or empty DataFrame + """ + filename = self._pair_data_filename( + self._datadir, pair, timeframe, candle_type=candle_type) + if not filename.exists(): + # Fallback mode for 1M files + filename = self._pair_data_filename( + self._datadir, pair, timeframe, candle_type=candle_type, no_timeframe_modify=True) + if not filename.exists(): + return DataFrame(columns=self._columns) + + pairdata = read_feather(filename) + pairdata.columns = self._columns + pairdata = pairdata.astype(dtype={'open': 'float', 'high': 'float', + 'low': 'float', 'close': 'float', 'volume': 'float'}) + pairdata['date'] = to_datetime(pairdata['date'], + unit='ms', + utc=True, + infer_datetime_format=True) + return pairdata + + def ohlcv_append( + self, + pair: str, + timeframe: str, + data: DataFrame, + candle_type: CandleType + ) -> None: + """ + Append data to existing data structures + :param pair: Pair + :param timeframe: Timeframe this ohlcv data is for + :param data: Data to append. + :param candle_type: Any of the enum CandleType (must match trading mode!) + """ + raise NotImplementedError() + + def trades_store(self, pair: str, data: TradeList) -> None: + """ + Store trades data (list of Dicts) to file + :param pair: Pair - used for filename + :param data: List of Lists containing trade data, + column sequence as in DEFAULT_TRADES_COLUMNS + """ + # filename = self._pair_trades_filename(self._datadir, pair) + + raise NotImplementedError() + # array = pa.array(data) + # array + # feather.write_feather(data, filename) + + def trades_append(self, pair: str, data: TradeList): + """ + Append data to existing files + :param pair: Pair - used for filename + :param data: List of Lists containing trade data, + column sequence as in DEFAULT_TRADES_COLUMNS + """ + raise NotImplementedError() + + def _trades_load(self, pair: str, timerange: Optional[TimeRange] = None) -> TradeList: + """ + Load a pair from file, either .json.gz or .json + # TODO: respect timerange ... + :param pair: Load trades for this pair + :param timerange: Timerange to load trades for - currently not implemented + :return: List of trades + """ + raise NotImplementedError() + # filename = self._pair_trades_filename(self._datadir, pair) + # tradesdata = misc.file_load_json(filename) + + # if not tradesdata: + # return [] + + # return tradesdata + + @classmethod + def _get_file_extension(cls): + return "feather" diff --git a/freqtrade/data/history/hdf5datahandler.py b/freqtrade/data/history/hdf5datahandler.py index 01b7af7e7..fd46115de 100644 --- a/freqtrade/data/history/hdf5datahandler.py +++ b/freqtrade/data/history/hdf5datahandler.py @@ -81,6 +81,7 @@ class HDF5DataHandler(IDataHandler): raise ValueError("Wrong dataframe format") pairdata = pairdata.astype(dtype={'open': 'float', 'high': 'float', 'low': 'float', 'close': 'float', 'volume': 'float'}) + pairdata = pairdata.reset_index(drop=True) return pairdata def ohlcv_append( diff --git a/freqtrade/data/history/history_utils.py b/freqtrade/data/history/history_utils.py index 6a6e29429..93534e919 100644 --- a/freqtrade/data/history/history_utils.py +++ b/freqtrade/data/history/history_utils.py @@ -26,7 +26,7 @@ def load_pair_history(pair: str, datadir: Path, *, timerange: Optional[TimeRange] = None, fill_up_missing: bool = True, - drop_incomplete: bool = True, + drop_incomplete: bool = False, startup_candles: int = 0, data_format: str = None, data_handler: IDataHandler = None, diff --git a/freqtrade/data/history/idatahandler.py b/freqtrade/data/history/idatahandler.py index 8c1823c00..80e29f4c0 100644 --- a/freqtrade/data/history/idatahandler.py +++ b/freqtrade/data/history/idatahandler.py @@ -272,10 +272,10 @@ class IDataHandler(ABC): return res def ohlcv_load(self, pair, timeframe: str, - candle_type: CandleType, + candle_type: CandleType, *, timerange: Optional[TimeRange] = None, fill_missing: bool = True, - drop_incomplete: bool = True, + drop_incomplete: bool = False, startup_candles: int = 0, warn_no_data: bool = True, ) -> DataFrame: @@ -375,6 +375,12 @@ def get_datahandlerclass(datatype: str) -> Type[IDataHandler]: elif datatype == 'hdf5': from .hdf5datahandler import HDF5DataHandler return HDF5DataHandler + elif datatype == 'feather': + from .featherdatahandler import FeatherDataHandler + return FeatherDataHandler + elif datatype == 'parquet': + from .parquetdatahandler import ParquetDataHandler + return ParquetDataHandler else: raise ValueError(f"No datahandler for datatype {datatype} available.") diff --git a/freqtrade/data/history/parquetdatahandler.py b/freqtrade/data/history/parquetdatahandler.py new file mode 100644 index 000000000..57581861d --- /dev/null +++ b/freqtrade/data/history/parquetdatahandler.py @@ -0,0 +1,129 @@ +import logging +from typing import Optional + +from pandas import DataFrame, read_parquet, to_datetime + +from freqtrade.configuration import TimeRange +from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS, TradeList +from freqtrade.enums import CandleType + +from .idatahandler import IDataHandler + + +logger = logging.getLogger(__name__) + + +class ParquetDataHandler(IDataHandler): + + _columns = DEFAULT_DATAFRAME_COLUMNS + + def ohlcv_store( + self, pair: str, timeframe: str, data: DataFrame, candle_type: CandleType) -> None: + """ + Store data in json format "values". + format looks as follows: + [[,,,,]] + :param pair: Pair - used to generate filename + :param timeframe: Timeframe - used to generate filename + :param data: Dataframe containing OHLCV data + :param candle_type: Any of the enum CandleType (must match trading mode!) + :return: None + """ + filename = self._pair_data_filename(self._datadir, pair, timeframe, candle_type) + self.create_dir_if_needed(filename) + + data.reset_index(drop=True).loc[:, self._columns].to_parquet(filename) + + def _ohlcv_load(self, pair: str, timeframe: str, + timerange: Optional[TimeRange], candle_type: CandleType + ) -> DataFrame: + """ + Internal method used to load data for one pair from disk. + Implements the loading and conversion to a Pandas dataframe. + Timerange trimming and dataframe validation happens outside of this method. + :param pair: Pair to load data + :param timeframe: Timeframe (e.g. "5m") + :param timerange: Limit data to be loaded to this timerange. + Optionally implemented by subclasses to avoid loading + all data where possible. + :param candle_type: Any of the enum CandleType (must match trading mode!) + :return: DataFrame with ohlcv data, or empty DataFrame + """ + filename = self._pair_data_filename( + self._datadir, pair, timeframe, candle_type=candle_type) + if not filename.exists(): + # Fallback mode for 1M files + filename = self._pair_data_filename( + self._datadir, pair, timeframe, candle_type=candle_type, no_timeframe_modify=True) + if not filename.exists(): + return DataFrame(columns=self._columns) + + pairdata = read_parquet(filename) + pairdata.columns = self._columns + pairdata = pairdata.astype(dtype={'open': 'float', 'high': 'float', + 'low': 'float', 'close': 'float', 'volume': 'float'}) + pairdata['date'] = to_datetime(pairdata['date'], + unit='ms', + utc=True, + infer_datetime_format=True) + return pairdata + + def ohlcv_append( + self, + pair: str, + timeframe: str, + data: DataFrame, + candle_type: CandleType + ) -> None: + """ + Append data to existing data structures + :param pair: Pair + :param timeframe: Timeframe this ohlcv data is for + :param data: Data to append. + :param candle_type: Any of the enum CandleType (must match trading mode!) + """ + raise NotImplementedError() + + def trades_store(self, pair: str, data: TradeList) -> None: + """ + Store trades data (list of Dicts) to file + :param pair: Pair - used for filename + :param data: List of Lists containing trade data, + column sequence as in DEFAULT_TRADES_COLUMNS + """ + # filename = self._pair_trades_filename(self._datadir, pair) + + raise NotImplementedError() + # array = pa.array(data) + # array + # feather.write_feather(data, filename) + + def trades_append(self, pair: str, data: TradeList): + """ + Append data to existing files + :param pair: Pair - used for filename + :param data: List of Lists containing trade data, + column sequence as in DEFAULT_TRADES_COLUMNS + """ + raise NotImplementedError() + + def _trades_load(self, pair: str, timerange: Optional[TimeRange] = None) -> TradeList: + """ + Load a pair from file, either .json.gz or .json + # TODO: respect timerange ... + :param pair: Load trades for this pair + :param timerange: Timerange to load trades for - currently not implemented + :return: List of trades + """ + raise NotImplementedError() + # filename = self._pair_trades_filename(self._datadir, pair) + # tradesdata = misc.file_load_json(filename) + + # if not tradesdata: + # return [] + + # return tradesdata + + @classmethod + def _get_file_extension(cls): + return "parquet" diff --git a/freqtrade/enums/__init__.py b/freqtrade/enums/__init__.py index d2f5474fc..146d65f2d 100644 --- a/freqtrade/enums/__init__.py +++ b/freqtrade/enums/__init__.py @@ -6,7 +6,7 @@ from freqtrade.enums.exittype import ExitType from freqtrade.enums.hyperoptstate import HyperoptState from freqtrade.enums.marginmode import MarginMode from freqtrade.enums.ordertypevalue import OrderTypeValues -from freqtrade.enums.rpcmessagetype import RPCMessageType +from freqtrade.enums.rpcmessagetype import RPCMessageType, RPCRequestType from freqtrade.enums.runmode import NON_UTIL_MODES, OPTIMIZE_MODES, TRADING_MODES, RunMode from freqtrade.enums.signaltype import SignalDirection, SignalTagType, SignalType from freqtrade.enums.state import State diff --git a/freqtrade/enums/rpcmessagetype.py b/freqtrade/enums/rpcmessagetype.py index 415d8f18c..fae121a09 100644 --- a/freqtrade/enums/rpcmessagetype.py +++ b/freqtrade/enums/rpcmessagetype.py @@ -1,7 +1,7 @@ from enum import Enum -class RPCMessageType(Enum): +class RPCMessageType(str, Enum): STATUS = 'status' WARNING = 'warning' STARTUP = 'startup' @@ -19,8 +19,19 @@ class RPCMessageType(Enum): STRATEGY_MSG = 'strategy_msg' + WHITELIST = 'whitelist' + ANALYZED_DF = 'analyzed_df' + def __repr__(self): return self.value def __str__(self): return self.value + + +# Enum for parsing requests from ws consumers +class RPCRequestType(str, Enum): + SUBSCRIBE = 'subscribe' + + WHITELIST = 'whitelist' + ANALYZED_DF = 'analyzed_df' diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index f9fb4a8b1..a0d4b2d82 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -68,6 +68,37 @@ class Binance(Exchange): tickers = deep_merge_dicts(bidsasks, tickers, allow_null_overrides=False) return tickers + @retrier + def additional_exchange_init(self) -> None: + """ + Additional exchange initialization logic. + .api will be available at this point. + Must be overridden in child methods if required. + """ + try: + if self.trading_mode == TradingMode.FUTURES and not self._config['dry_run']: + position_side = self._api.fapiPrivateGetPositionsideDual() + self._log_exchange_response('position_side_setting', position_side) + assets_margin = self._api.fapiPrivateGetMultiAssetsMargin() + self._log_exchange_response('multi_asset_margin', assets_margin) + msg = "" + if position_side.get('dualSidePosition') is True: + msg += ( + "\nHedge Mode is not supported by freqtrade. " + "Please change 'Position Mode' on your binance futures account.") + if assets_margin.get('multiAssetsMargin') is True: + msg += ("\nMulti-Asset Mode is not supported by freqtrade. " + "Please change 'Asset Mode' on your binance futures account.") + if msg: + raise OperationalException(msg) + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e + except (ccxt.NetworkError, ccxt.ExchangeError) as e: + raise TemporaryError( + f'Could not set leverage due to {e.__class__.__name__}. Message: {e}') from e + except ccxt.BaseError as e: + raise OperationalException(e) from e + @retrier def _set_leverage( self, diff --git a/freqtrade/exchange/binance_leverage_tiers.json b/freqtrade/exchange/binance_leverage_tiers.json index 2fa326bb1..cf2fd7287 100644 --- a/freqtrade/exchange/binance_leverage_tiers.json +++ b/freqtrade/exchange/binance_leverage_tiers.json @@ -4485,6 +4485,120 @@ } } ], + "BTCUSDT_221230": [ + { + "tier": 1.0, + "currency": "USDT", + "minNotional": 0.0, + "maxNotional": 375000.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 25.0, + "info": { + "bracket": "1", + "initialLeverage": "25", + "notionalCap": "375000", + "notionalFloor": "0", + "maintMarginRatio": "0.02", + "cum": "0.0" + } + }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 375000.0, + "maxNotional": 2000000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "2", + "initialLeverage": "10", + "notionalCap": "2000000", + "notionalFloor": "375000", + "maintMarginRatio": "0.05", + "cum": "11250.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 2000000.0, + "maxNotional": 4000000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "3", + "initialLeverage": "5", + "notionalCap": "4000000", + "notionalFloor": "2000000", + "maintMarginRatio": "0.1", + "cum": "111250.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 4000000.0, + "maxNotional": 10000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "4", + "initialLeverage": "4", + "notionalCap": "10000000", + "notionalFloor": "4000000", + "maintMarginRatio": "0.125", + "cum": "211250.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 10000000.0, + "maxNotional": 20000000.0, + "maintenanceMarginRate": 0.15, + "maxLeverage": 3.0, + "info": { + "bracket": "5", + "initialLeverage": "3", + "notionalCap": "20000000", + "notionalFloor": "10000000", + "maintMarginRatio": "0.15", + "cum": "461250.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 20000000.0, + "maxNotional": 40000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "40000000", + "notionalFloor": "20000000", + "maintMarginRatio": "0.25", + "cum": "2461250.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 40000000.0, + "maxNotional": 400000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "7", + "initialLeverage": "1", + "notionalCap": "400000000", + "notionalFloor": "40000000", + "maintMarginRatio": "0.5", + "cum": "1.246125E7" + } + } + ], "BTS/USDT": [ { "tier": 1.0, @@ -5759,6 +5873,104 @@ } } ], + "CVX/USDT": [ + { + "tier": 1.0, + "currency": "USDT", + "minNotional": 0.0, + "maxNotional": 5000.0, + "maintenanceMarginRate": 0.01, + "maxLeverage": 25.0, + "info": { + "bracket": "1", + "initialLeverage": "25", + "notionalCap": "5000", + "notionalFloor": "0", + "maintMarginRatio": "0.01", + "cum": "0.0" + } + }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 5000.0, + "maxNotional": 25000.0, + "maintenanceMarginRate": 0.025, + "maxLeverage": 20.0, + "info": { + "bracket": "2", + "initialLeverage": "20", + "notionalCap": "25000", + "notionalFloor": "5000", + "maintMarginRatio": "0.025", + "cum": "75.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 25000.0, + "maxNotional": 100000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "3", + "initialLeverage": "10", + "notionalCap": "100000", + "notionalFloor": "25000", + "maintMarginRatio": "0.05", + "cum": "700.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 100000.0, + "maxNotional": 250000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "4", + "initialLeverage": "5", + "notionalCap": "250000", + "notionalFloor": "100000", + "maintMarginRatio": "0.1", + "cum": "5700.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 250000.0, + "maxNotional": 1000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 2.0, + "info": { + "bracket": "5", + "initialLeverage": "2", + "notionalCap": "1000000", + "notionalFloor": "250000", + "maintMarginRatio": "0.125", + "cum": "11950.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 1000000.0, + "maxNotional": 5000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "6", + "initialLeverage": "1", + "notionalCap": "5000000", + "notionalFloor": "1000000", + "maintMarginRatio": "0.5", + "cum": "386950.0" + } + } + ], "DAR/USDT": [ { "tier": 1.0, @@ -8105,6 +8317,120 @@ } } ], + "ETHUSDT_221230": [ + { + "tier": 1.0, + "currency": "USDT", + "minNotional": 0.0, + "maxNotional": 375000.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 25.0, + "info": { + "bracket": "1", + "initialLeverage": "25", + "notionalCap": "375000", + "notionalFloor": "0", + "maintMarginRatio": "0.02", + "cum": "0.0" + } + }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 375000.0, + "maxNotional": 2000000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "2", + "initialLeverage": "10", + "notionalCap": "2000000", + "notionalFloor": "375000", + "maintMarginRatio": "0.05", + "cum": "11250.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 2000000.0, + "maxNotional": 4000000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "3", + "initialLeverage": "5", + "notionalCap": "4000000", + "notionalFloor": "2000000", + "maintMarginRatio": "0.1", + "cum": "111250.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 4000000.0, + "maxNotional": 10000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "4", + "initialLeverage": "4", + "notionalCap": "10000000", + "notionalFloor": "4000000", + "maintMarginRatio": "0.125", + "cum": "211250.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 10000000.0, + "maxNotional": 20000000.0, + "maintenanceMarginRate": 0.15, + "maxLeverage": 3.0, + "info": { + "bracket": "5", + "initialLeverage": "3", + "notionalCap": "20000000", + "notionalFloor": "10000000", + "maintMarginRatio": "0.15", + "cum": "461250.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 20000000.0, + "maxNotional": 40000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "40000000", + "notionalFloor": "20000000", + "maintMarginRatio": "0.25", + "cum": "2461250.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 40000000.0, + "maxNotional": 400000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "7", + "initialLeverage": "1", + "notionalCap": "400000000", + "notionalFloor": "40000000", + "maintMarginRatio": "0.5", + "cum": "1.246125E7" + } + } + ], "FIL/BUSD": [ { "tier": 1.0, @@ -10138,10 +10464,10 @@ "minNotional": 0.0, "maxNotional": 5000.0, "maintenanceMarginRate": 0.01, - "maxLeverage": 50.0, + "maxLeverage": 25.0, "info": { "bracket": "1", - "initialLeverage": "50", + "initialLeverage": "25", "notionalCap": "5000", "notionalFloor": "0", "maintMarginRatio": "0.01", @@ -10216,13 +10542,13 @@ "tier": 6.0, "currency": "USDT", "minNotional": 1000000.0, - "maxNotional": 30000000.0, + "maxNotional": 5000000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "6", "initialLeverage": "1", - "notionalCap": "30000000", + "notionalCap": "5000000", "notionalFloor": "1000000", "maintMarginRatio": "0.5", "cum": "386950.0" @@ -11389,6 +11715,104 @@ } } ], + "LDO/USDT": [ + { + "tier": 1.0, + "currency": "USDT", + "minNotional": 0.0, + "maxNotional": 5000.0, + "maintenanceMarginRate": 0.01, + "maxLeverage": 25.0, + "info": { + "bracket": "1", + "initialLeverage": "25", + "notionalCap": "5000", + "notionalFloor": "0", + "maintMarginRatio": "0.01", + "cum": "0.0" + } + }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 5000.0, + "maxNotional": 25000.0, + "maintenanceMarginRate": 0.025, + "maxLeverage": 20.0, + "info": { + "bracket": "2", + "initialLeverage": "20", + "notionalCap": "25000", + "notionalFloor": "5000", + "maintMarginRatio": "0.025", + "cum": "75.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 25000.0, + "maxNotional": 100000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "3", + "initialLeverage": "10", + "notionalCap": "100000", + "notionalFloor": "25000", + "maintMarginRatio": "0.05", + "cum": "700.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 100000.0, + "maxNotional": 250000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "4", + "initialLeverage": "5", + "notionalCap": "250000", + "notionalFloor": "100000", + "maintMarginRatio": "0.1", + "cum": "5700.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 250000.0, + "maxNotional": 1000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 2.0, + "info": { + "bracket": "5", + "initialLeverage": "2", + "notionalCap": "1000000", + "notionalFloor": "250000", + "maintMarginRatio": "0.125", + "cum": "11950.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 1000000.0, + "maxNotional": 5000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "6", + "initialLeverage": "1", + "notionalCap": "5000000", + "notionalFloor": "1000000", + "maintMarginRatio": "0.5", + "cum": "386950.0" + } + } + ], "LEVER/BUSD": [ { "tier": 1.0, @@ -19209,4 +19633,4 @@ } } ] -} \ No newline at end of file +} diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index c68fc5873..f01e464fa 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -2891,7 +2891,7 @@ def amount_to_contracts(amount: float, contract_size: Optional[float]) -> float: :return: num-contracts """ if contract_size and contract_size != 1: - return amount / contract_size + return float(FtPrecise(amount) / FtPrecise(contract_size)) else: return amount @@ -2905,7 +2905,7 @@ def contracts_to_amount(num_contracts: float, contract_size: Optional[float]) -> """ if contract_size and contract_size != 1: - return num_contracts * contract_size + return float(FtPrecise(num_contracts) * FtPrecise(contract_size)) else: return num_contracts diff --git a/freqtrade/exchange/okx.py b/freqtrade/exchange/okx.py index 2db5fb6a9..6792c2cba 100644 --- a/freqtrade/exchange/okx.py +++ b/freqtrade/exchange/okx.py @@ -78,7 +78,8 @@ class Okx(Exchange): raise DDosProtection(e) from e except (ccxt.NetworkError, ccxt.ExchangeError) as e: raise TemporaryError( - f'Could not set leverage due to {e.__class__.__name__}. Message: {e}') from e + f'Error in additional_exchange_init due to {e.__class__.__name__}. Message: {e}' + ) from e except ccxt.BaseError as e: raise OperationalException(e) from e diff --git a/freqtrade/freqai/base_models/BaseClassifierModel.py b/freqtrade/freqai/base_models/BaseClassifierModel.py index 288a833cf..09f1bf98c 100644 --- a/freqtrade/freqai/base_models/BaseClassifierModel.py +++ b/freqtrade/freqai/base_models/BaseClassifierModel.py @@ -1,4 +1,5 @@ import logging +from time import time from typing import Any, Tuple import numpy as np @@ -32,7 +33,9 @@ class BaseClassifierModel(IFreqaiModel): :model: Trained model which can be used to inference (self.predict) """ - logger.info("-------------------- Starting training " f"{pair} --------------------") + logger.info(f"-------------------- Starting training {pair} --------------------") + + start_time = time() # filter the features requested by user in the configuration file and elegantly handle NaNs features_filtered, labels_filtered = dk.filter_features( @@ -45,10 +48,10 @@ class BaseClassifierModel(IFreqaiModel): start_date = unfiltered_df["date"].iloc[0].strftime("%Y-%m-%d") end_date = unfiltered_df["date"].iloc[-1].strftime("%Y-%m-%d") logger.info(f"-------------------- Training on data from {start_date} to " - f"{end_date}--------------------") + f"{end_date} --------------------") # split data into train/test data. data_dictionary = dk.make_train_test_datasets(features_filtered, labels_filtered) - if not self.freqai_info.get('fit_live_predictions', 0) or not self.live: + if not self.freqai_info.get("fit_live_predictions", 0) or not self.live: dk.fit_labels() # normalize all data based on train_dataset only data_dictionary = dk.normalize_data(data_dictionary) @@ -57,13 +60,16 @@ class BaseClassifierModel(IFreqaiModel): self.data_cleaning_train(dk) logger.info( - f'Training model on {len(dk.data_dictionary["train_features"].columns)}' - f' features and {len(data_dictionary["train_features"])} data points' + f"Training model on {len(dk.data_dictionary['train_features'].columns)} features" ) + logger.info(f"Training model on {len(data_dictionary['train_features'])} data points") model = self.fit(data_dictionary, dk) - logger.info(f"--------------------done training {pair}--------------------") + end_time = time() + + logger.info(f"-------------------- Done training {pair} " + f"({end_time - start_time:.2f} secs) --------------------") return model @@ -86,7 +92,7 @@ class BaseClassifierModel(IFreqaiModel): filtered_df = dk.normalize_data_from_metadata(filtered_df) dk.data_dictionary["prediction_features"] = filtered_df - self.data_cleaning_predict(dk, filtered_df) + self.data_cleaning_predict(dk) predictions = self.model.predict(dk.data_dictionary["prediction_features"]) pred_df = DataFrame(predictions, columns=dk.label_list) diff --git a/freqtrade/freqai/base_models/BaseRegressionModel.py b/freqtrade/freqai/base_models/BaseRegressionModel.py index 401acbaff..5d89dd356 100644 --- a/freqtrade/freqai/base_models/BaseRegressionModel.py +++ b/freqtrade/freqai/base_models/BaseRegressionModel.py @@ -1,4 +1,5 @@ import logging +from time import time from typing import Any, Tuple import numpy as np @@ -31,7 +32,9 @@ class BaseRegressionModel(IFreqaiModel): :model: Trained model which can be used to inference (self.predict) """ - logger.info("-------------------- Starting training " f"{pair} --------------------") + logger.info(f"-------------------- Starting training {pair} --------------------") + + start_time = time() # filter the features requested by user in the configuration file and elegantly handle NaNs features_filtered, labels_filtered = dk.filter_features( @@ -44,10 +47,10 @@ class BaseRegressionModel(IFreqaiModel): start_date = unfiltered_df["date"].iloc[0].strftime("%Y-%m-%d") end_date = unfiltered_df["date"].iloc[-1].strftime("%Y-%m-%d") logger.info(f"-------------------- Training on data from {start_date} to " - f"{end_date}--------------------") + f"{end_date} --------------------") # split data into train/test data. data_dictionary = dk.make_train_test_datasets(features_filtered, labels_filtered) - if not self.freqai_info.get('fit_live_predictions', 0) or not self.live: + if not self.freqai_info.get("fit_live_predictions", 0) or not self.live: dk.fit_labels() # normalize all data based on train_dataset only data_dictionary = dk.normalize_data(data_dictionary) @@ -56,13 +59,16 @@ class BaseRegressionModel(IFreqaiModel): self.data_cleaning_train(dk) logger.info( - f'Training model on {len(dk.data_dictionary["train_features"].columns)}' - f' features and {len(data_dictionary["train_features"])} data points' + f"Training model on {len(dk.data_dictionary['train_features'].columns)} features" ) + logger.info(f"Training model on {len(data_dictionary['train_features'])} data points") model = self.fit(data_dictionary, dk) - logger.info(f"--------------------done training {pair}--------------------") + end_time = time() + + logger.info(f"-------------------- Done training {pair} " + f"({end_time - start_time:.2f} secs) --------------------") return model @@ -86,7 +92,7 @@ class BaseRegressionModel(IFreqaiModel): dk.data_dictionary["prediction_features"] = filtered_df # optional additional data cleaning/analysis - self.data_cleaning_predict(dk, filtered_df) + self.data_cleaning_predict(dk) predictions = self.model.predict(dk.data_dictionary["prediction_features"]) pred_df = DataFrame(predictions, columns=dk.label_list) diff --git a/freqtrade/freqai/base_models/BaseTensorFlowModel.py b/freqtrade/freqai/base_models/BaseTensorFlowModel.py index 7f0d3c142..00f9d6cba 100644 --- a/freqtrade/freqai/base_models/BaseTensorFlowModel.py +++ b/freqtrade/freqai/base_models/BaseTensorFlowModel.py @@ -1,4 +1,5 @@ import logging +from time import time from typing import Any from pandas import DataFrame @@ -28,7 +29,9 @@ class BaseTensorFlowModel(IFreqaiModel): :model: Trained model which can be used to inference (self.predict) """ - logger.info("-------------------- Starting training " f"{pair} --------------------") + logger.info(f"-------------------- Starting training {pair} --------------------") + + start_time = time() # filter the features requested by user in the configuration file and elegantly handle NaNs features_filtered, labels_filtered = dk.filter_features( @@ -41,10 +44,10 @@ class BaseTensorFlowModel(IFreqaiModel): start_date = unfiltered_df["date"].iloc[0].strftime("%Y-%m-%d") end_date = unfiltered_df["date"].iloc[-1].strftime("%Y-%m-%d") logger.info(f"-------------------- Training on data from {start_date} to " - f"{end_date}--------------------") + f"{end_date} --------------------") # split data into train/test data. data_dictionary = dk.make_train_test_datasets(features_filtered, labels_filtered) - if not self.freqai_info.get('fit_live_predictions', 0) or not self.live: + if not self.freqai_info.get("fit_live_predictions", 0) or not self.live: dk.fit_labels() # normalize all data based on train_dataset only data_dictionary = dk.normalize_data(data_dictionary) @@ -53,12 +56,15 @@ class BaseTensorFlowModel(IFreqaiModel): self.data_cleaning_train(dk) logger.info( - f'Training model on {len(dk.data_dictionary["train_features"].columns)}' - f' features and {len(data_dictionary["train_features"])} data points' + f"Training model on {len(dk.data_dictionary['train_features'].columns)} features" ) + logger.info(f"Training model on {len(data_dictionary['train_features'])} data points") model = self.fit(data_dictionary, dk) - logger.info(f"--------------------done training {pair}--------------------") + end_time = time() + + logger.info(f"-------------------- Done training {pair} " + f"({end_time - start_time:.2f} secs) --------------------") return model diff --git a/freqtrade/freqai/base_models/FreqaiMultiOutputRegressor.py b/freqtrade/freqai/base_models/FreqaiMultiOutputRegressor.py index a9db81e31..54136d5e0 100644 --- a/freqtrade/freqai/base_models/FreqaiMultiOutputRegressor.py +++ b/freqtrade/freqai/base_models/FreqaiMultiOutputRegressor.py @@ -1,4 +1,3 @@ - from joblib import Parallel from sklearn.multioutput import MultiOutputRegressor, _fit_estimator from sklearn.utils.fixes import delayed diff --git a/freqtrade/freqai/data_drawer.py b/freqtrade/freqai/data_drawer.py index 03840317f..9bbcdad8b 100644 --- a/freqtrade/freqai/data_drawer.py +++ b/freqtrade/freqai/data_drawer.py @@ -314,6 +314,7 @@ class FreqaiDataDrawer: """ dk.find_features(dataframe) + dk.find_labels(dataframe) full_labels = dk.label_list + dk.unique_class_list @@ -377,7 +378,27 @@ class FreqaiDataDrawer: if self.config.get("freqai", {}).get("purge_old_models", False): self.purge_old_models() - # Functions pulled back from FreqaiDataKitchen because they relied on DataDrawer + def save_metadata(self, dk: FreqaiDataKitchen) -> None: + """ + Saves only metadata for backtesting studies if user prefers + not to save model data. This saves tremendous amounts of space + for users generating huge studies. + This is only active when `save_backtest_models`: false (not default) + """ + if not dk.data_path.is_dir(): + dk.data_path.mkdir(parents=True, exist_ok=True) + + save_path = Path(dk.data_path) + + dk.data["data_path"] = str(dk.data_path) + dk.data["model_filename"] = str(dk.model_filename) + dk.data["training_features_list"] = list(dk.data_dictionary["train_features"].columns) + dk.data["label_list"] = dk.label_list + + with open(save_path / f"{dk.model_filename}_metadata.json", "w") as fp: + rapidjson.dump(dk.data, fp, default=self.np_encoder, number_mode=rapidjson.NM_NATIVE) + + return def save_data(self, model: Any, coin: str, dk: FreqaiDataKitchen) -> None: """ @@ -406,7 +427,7 @@ class FreqaiDataDrawer: dk.data["data_path"] = str(dk.data_path) dk.data["model_filename"] = str(dk.model_filename) - dk.data["training_features_list"] = list(dk.data_dictionary["train_features"].columns) + dk.data["training_features_list"] = dk.training_features_list dk.data["label_list"] = dk.label_list # store the metadata with open(save_path / f"{dk.model_filename}_metadata.json", "w") as fp: @@ -434,6 +455,16 @@ class FreqaiDataDrawer: return + def load_metadata(self, dk: FreqaiDataKitchen) -> None: + """ + Load only metadata into datakitchen to increase performance during + presaved backtesting (prediction file loading). + """ + with open(dk.data_path / f"{dk.model_filename}_metadata.json", "r") as fp: + dk.data = json.load(fp) + dk.training_features_list = dk.data["training_features_list"] + dk.label_list = dk.data["label_list"] + def load_data(self, coin: str, dk: FreqaiDataKitchen) -> Any: """ loads all data required to make a prediction on a sub-train time range diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 73717abce..3e7f795b2 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -138,20 +138,15 @@ class FreqaiDataKitchen: """ feat_dict = self.freqai_config["feature_parameters"] + if 'shuffle' not in self.freqai_config['data_split_parameters']: + self.freqai_config["data_split_parameters"].update({'shuffle': False}) + weights: npt.ArrayLike if feat_dict.get("weight_factor", 0) > 0: weights = self.set_weights_higher_recent(len(filtered_dataframe)) else: weights = np.ones(len(filtered_dataframe)) - if feat_dict.get("stratify_training_data", 0) > 0: - stratification = np.zeros(len(filtered_dataframe)) - for i in range(1, len(stratification)): - if i % feat_dict.get("stratify_training_data", 0) == 0: - stratification[i] = 1 - else: - stratification = None - if self.freqai_config.get('data_split_parameters', {}).get('test_size', 0.1) != 0: ( train_features, @@ -164,7 +159,6 @@ class FreqaiDataKitchen: filtered_dataframe[: filtered_dataframe.shape[0]], labels, weights, - stratify=stratification, **self.config["freqai"]["data_split_parameters"], ) else: @@ -214,7 +208,7 @@ class FreqaiDataKitchen: filtered_df = unfiltered_df.filter(training_feature_list, axis=1) filtered_df = filtered_df.replace([np.inf, -np.inf], np.nan) - drop_index = pd.isnull(filtered_df).any(1) # get the rows that have NaNs, + drop_index = pd.isnull(filtered_df).any(axis=1) # get the rows that have NaNs, drop_index = drop_index.replace(True, 1).replace(False, 0) # pep8 requirement. if (training_filter): const_cols = list((filtered_df.nunique() == 1).loc[lambda x: x].index) @@ -225,7 +219,7 @@ class FreqaiDataKitchen: # about removing any row with NaNs # if labels has multiple columns (user wants to train multiple modelEs), we detect here labels = unfiltered_df.filter(label_list, axis=1) - drop_index_labels = pd.isnull(labels).any(1) + drop_index_labels = pd.isnull(labels).any(axis=1) drop_index_labels = drop_index_labels.replace(True, 1).replace(False, 0) dates = unfiltered_df['date'] filtered_df = filtered_df[ @@ -253,7 +247,7 @@ class FreqaiDataKitchen: else: # we are backtesting so we need to preserve row number to send back to strategy, # so now we use do_predict to avoid any prediction based on a NaN - drop_index = pd.isnull(filtered_df).any(1) + drop_index = pd.isnull(filtered_df).any(axis=1) self.data["filter_drop_index_prediction"] = drop_index filtered_df.fillna(0, inplace=True) # replacing all NaNs with zeros to avoid issues in 'prediction', but any prediction @@ -470,27 +464,6 @@ class FreqaiDataKitchen: return df - def remove_training_from_backtesting( - self - ) -> DataFrame: - """ - Function which takes the backtesting time range and - remove training data from dataframe, keeping only the - startup_candle_count candles - """ - startup_candle_count = self.config.get('startup_candle_count', 0) - tf = self.config['timeframe'] - tr = self.config["timerange"] - - backtesting_timerange = TimeRange.parse_timerange(tr) - if startup_candle_count > 0 and backtesting_timerange: - backtesting_timerange.subtract_start(timeframe_to_seconds(tf) * startup_candle_count) - - start = datetime.fromtimestamp(backtesting_timerange.startts, tz=timezone.utc) - df = self.return_dataframe - df = df.loc[df["date"] >= start, :] - return df - def principal_component_analysis(self) -> None: """ Performs Principal Component Analysis on the data for dimensionality reduction @@ -833,7 +806,7 @@ class FreqaiDataKitchen: :, :no_prev_pts ] distances = distances.replace([np.inf, -np.inf], np.nan) - drop_index = pd.isnull(distances).any(1) + drop_index = pd.isnull(distances).any(axis=1) distances = distances[drop_index == 0] inliers = pd.DataFrame(index=distances.index) @@ -856,7 +829,7 @@ class FreqaiDataKitchen: inlier_metric = pd.DataFrame( data=inliers.sum(axis=1) / no_prev_pts, - columns=['inlier_metric'], + columns=['%-inlier_metric'], index=compute_df.index ) @@ -906,11 +879,15 @@ class FreqaiDataKitchen: """ column_names = dataframe.columns features = [c for c in column_names if "%" in c] - labels = [c for c in column_names if "&" in c] + if not features: raise OperationalException("Could not find any features!") self.training_features_list = features + + def find_labels(self, dataframe: DataFrame) -> None: + column_names = dataframe.columns + labels = [c for c in column_names if "&" in c] self.label_list = labels def check_if_pred_in_training_spaces(self) -> None: @@ -998,8 +975,6 @@ class FreqaiDataKitchen: to_keep = [col for col in dataframe.columns if not col.startswith("&")] self.return_dataframe = pd.concat([dataframe[to_keep], self.full_df], axis=1) - - self.return_dataframe = self.remove_training_from_backtesting() self.full_df = DataFrame() return @@ -1233,7 +1208,8 @@ class FreqaiDataKitchen: def get_unique_classes_from_labels(self, dataframe: DataFrame) -> None: - self.find_features(dataframe) + # self.find_features(dataframe) + self.find_labels(dataframe) for key in self.label_list: if dataframe[key].dtype == object: diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 44535f191..a55aad8ff 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -66,7 +66,7 @@ class IFreqaiModel(ABC): self.first = True self.set_full_path() self.follow_mode: bool = self.freqai_info.get("follow_mode", False) - self.save_backtest_models: bool = self.freqai_info.get("save_backtest_models", False) + self.save_backtest_models: bool = self.freqai_info.get("save_backtest_models", True) if self.save_backtest_models: logger.info('Backtesting module configured to save all models.') self.dd = FreqaiDataDrawer(Path(self.full_path), self.config, self.follow_mode) @@ -93,6 +93,7 @@ class IFreqaiModel(ABC): self.begin_time_train: float = 0 self.base_tf_seconds = timeframe_to_seconds(self.config['timeframe']) self.continual_learning = self.freqai_info.get('continual_learning', False) + self.plot_features = self.ft_params.get("plot_feature_importances", 0) self._threads: List[threading.Thread] = [] self._stop_event = threading.Event() @@ -258,7 +259,8 @@ class IFreqaiModel(ABC): # following tr_train. Both of these windows slide through the # entire backtest for tr_train, tr_backtest in zip(dk.training_timeranges, dk.backtesting_timeranges): - (_, _, _) = self.dd.get_pair_dict_info(metadata["pair"]) + pair = metadata["pair"] + (_, _, _) = self.dd.get_pair_dict_info(pair) train_it += 1 total_trains = len(dk.backtesting_timeranges) self.training_timerange = tr_train @@ -273,39 +275,41 @@ class IFreqaiModel(ABC): tr_train.stopts, tz=timezone.utc).strftime(DATETIME_PRINT_FORMAT) logger.info( - f"Training {metadata['pair']}, {self.pair_it}/{self.total_pairs} pairs" + f"Training {pair}, {self.pair_it}/{self.total_pairs} pairs" f" from {tr_train_startts_str} to {tr_train_stopts_str}, {train_it}/{total_trains} " "trains" ) trained_timestamp_int = int(trained_timestamp.stopts) dk.data_path = Path( - dk.full_path - / - f"sub-train-{metadata['pair'].split('/')[0]}_{trained_timestamp_int}" + dk.full_path / f"sub-train-{pair.split('/')[0]}_{trained_timestamp_int}" ) - dk.set_new_model_names(metadata["pair"], trained_timestamp) + dk.set_new_model_names(pair, trained_timestamp) if dk.check_if_backtest_prediction_exists(): + self.dd.load_metadata(dk) + dk.find_features(dataframe_train) + self.check_if_feature_list_matches_strategy(dk) append_df = dk.get_backtesting_prediction() dk.append_predictions(append_df) else: - if not self.model_exists( - metadata["pair"], dk, trained_timestamp=trained_timestamp_int - ): + if not self.model_exists(dk): dk.find_features(dataframe_train) - self.model = self.train(dataframe_train, metadata["pair"], dk) - self.dd.pair_dict[metadata["pair"]]["trained_timestamp"] = int( + dk.find_labels(dataframe_train) + self.model = self.train(dataframe_train, pair, dk) + self.dd.pair_dict[pair]["trained_timestamp"] = int( trained_timestamp.stopts) - + if self.plot_features: + plot_feature_importance(self.model, pair, dk, self.plot_features) if self.save_backtest_models: logger.info('Saving backtest model to disk.') - self.dd.save_data(self.model, metadata["pair"], dk) + self.dd.save_data(self.model, pair, dk) + else: + logger.info('Saving metadata to disk.') + self.dd.save_metadata(dk) else: - self.model = self.dd.load_data(metadata["pair"], dk) - - self.check_if_feature_list_matches_strategy(dataframe_train, dk) + self.model = self.dd.load_data(pair, dk) pred_df, do_preds = self.predict(dataframe_backtest, dk) append_df = dk.get_predictions_to_append(pred_df, do_preds) @@ -385,8 +389,7 @@ class IFreqaiModel(ABC): self.dd.return_null_values_to_strategy(dataframe, dk) return dk - # ensure user is feeding the correct indicators to the model - self.check_if_feature_list_matches_strategy(dataframe, dk) + dk.find_labels(dataframe) self.build_strategy_return_arrays(dataframe, dk, metadata["pair"], trained_timestamp) @@ -431,7 +434,7 @@ class IFreqaiModel(ABC): return def check_if_feature_list_matches_strategy( - self, dataframe: DataFrame, dk: FreqaiDataKitchen + self, dk: FreqaiDataKitchen ) -> None: """ Ensure user is passing the proper feature set if they are reusing an `identifier` pointing @@ -440,18 +443,21 @@ class IFreqaiModel(ABC): :param dk: FreqaiDataKitchen = non-persistent data container/analyzer for current coin/bot loop """ - dk.find_features(dataframe) + if "training_features_list_raw" in dk.data: feature_list = dk.data["training_features_list_raw"] else: - feature_list = dk.training_features_list + feature_list = dk.data['training_features_list'] + if dk.training_features_list != feature_list: raise OperationalException( "Trying to access pretrained model with `identifier` " "but found different features furnished by current strategy." "Change `identifier` to train from scratch, or ensure the" "strategy is furnishing the same features as the pretrained" - "model" + "model. In case of --strategy-list, please be aware that FreqAI " + "requires all strategies to maintain identical " + "populate_any_indicator() functions" ) def data_cleaning_train(self, dk: FreqaiDataKitchen) -> None: @@ -490,20 +496,23 @@ class IFreqaiModel(ABC): if self.freqai_info["feature_parameters"].get('noise_standard_deviation', 0): dk.add_noise_to_training_features() - def data_cleaning_predict(self, dk: FreqaiDataKitchen, dataframe: DataFrame) -> None: + def data_cleaning_predict(self, dk: FreqaiDataKitchen) -> None: """ Base data cleaning method for predict. Functions here are complementary to the functions of data_cleaning_train. """ ft_params = self.freqai_info["feature_parameters"] + # ensure user is feeding the correct indicators to the model + self.check_if_feature_list_matches_strategy(dk) + if ft_params.get('inlier_metric_window', 0): dk.compute_inlier_metric(set_='predict') if ft_params.get( "principal_component_analysis", False ): - dk.pca_transform(self.dk.data_dictionary['prediction_features']) + dk.pca_transform(dk.data_dictionary['prediction_features']) if ft_params.get("use_SVM_to_remove_outliers", False): dk.use_SVM_to_remove_outliers(predict=True) @@ -514,14 +523,7 @@ class IFreqaiModel(ABC): if ft_params.get("use_DBSCAN_to_remove_outliers", False): dk.use_DBSCAN_to_remove_outliers(predict=True) - def model_exists( - self, - pair: str, - dk: FreqaiDataKitchen, - trained_timestamp: int = None, - model_filename: str = "", - scanning: bool = False, - ) -> bool: + def model_exists(self, dk: FreqaiDataKitchen) -> bool: """ Given a pair and path, check if a model already exists :param pair: pair e.g. BTC/USD @@ -529,11 +531,11 @@ class IFreqaiModel(ABC): :return: :boolean: whether the model file exists or not. """ - path_to_modelfile = Path(dk.data_path / f"{model_filename}_model.joblib") + path_to_modelfile = Path(dk.data_path / f"{dk.model_filename}_model.joblib") file_exists = path_to_modelfile.is_file() - if file_exists and not scanning: + if file_exists: logger.info("Found model at %s", dk.data_path / dk.model_filename) - elif not scanning: + else: logger.info("Could not find model at %s", dk.data_path / dk.model_filename) return file_exists @@ -580,16 +582,16 @@ class IFreqaiModel(ABC): # find the features indicated by strategy and store in datakitchen dk.find_features(unfiltered_dataframe) - # import pytest - # pytest.set_trace() + dk.find_labels(unfiltered_dataframe) + model = self.train(unfiltered_dataframe, pair, dk) self.dd.pair_dict[pair]["trained_timestamp"] = new_trained_timerange.stopts dk.set_new_model_names(pair, new_trained_timerange) self.dd.save_data(model, pair, dk) - if self.freqai_info["feature_parameters"].get("plot_feature_importance", False): - plot_feature_importance(model, pair, dk) + if self.plot_features: + plot_feature_importance(model, pair, dk, self.plot_features) if self.freqai_info.get("purge_old_models", False): self.dd.purge_old_models() diff --git a/freqtrade/freqai/utils.py b/freqtrade/freqai/utils.py index f6358925c..22bc1e06e 100644 --- a/freqtrade/freqai/utils.py +++ b/freqtrade/freqai/utils.py @@ -170,7 +170,7 @@ def plot_feature_importance(model: Any, pair: str, dk: FreqaiDataKitchen, # Data preparation fi_df = pd.DataFrame({ - "feature_names": np.array(dk.training_features_list), + "feature_names": np.array(dk.data_dictionary['train_features'].columns), "feature_importance": np.array(feature_importance) }) fi_df_top = fi_df.nlargest(count_max, "feature_importance")[::-1] diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index eb5705c34..387bae534 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -29,6 +29,7 @@ from freqtrade.plugins.pairlistmanager import PairListManager from freqtrade.plugins.protectionmanager import ProtectionManager from freqtrade.resolvers import ExchangeResolver, StrategyResolver from freqtrade.rpc import RPCManager +from freqtrade.rpc.external_message_consumer import ExternalMessageConsumer from freqtrade.strategy.interface import IStrategy from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper from freqtrade.util import FtPrecise @@ -72,6 +73,8 @@ class FreqtradeBot(LoggingMixin): PairLocks.timeframe = self.config['timeframe'] + self.pairlists = PairListManager(self.exchange, self.config) + # RPC runs in separate threads, can start handling external commands just after # initialization, even before Freqtradebot has a chance to start its throttling, # so anything in the Freqtradebot instance should be ready (initialized), including @@ -79,9 +82,10 @@ class FreqtradeBot(LoggingMixin): # Keep this at the end of this initialization method. self.rpc: RPCManager = RPCManager(self) - self.pairlists = PairListManager(self.exchange, self.config) + self.dataprovider = DataProvider(self.config, self.exchange, rpc=self.rpc) + self.pairlists = PairListManager(self.exchange, self.config, self.dataprovider) - self.dataprovider = DataProvider(self.config, self.exchange, self.pairlists) + self.dataprovider.add_pairlisthandler(self.pairlists) # Attach Dataprovider to strategy instance self.strategy.dp = self.dataprovider @@ -92,6 +96,10 @@ class FreqtradeBot(LoggingMixin): self.edge = Edge(self.config, self.exchange, self.strategy) if \ self.config.get('edge', {}).get('enabled', False) else None + # Init ExternalMessageConsumer if enabled + self.emc = ExternalMessageConsumer(self.config, self.dataprovider) if \ + self.config.get('external_message_consumer', {}).get('enabled', False) else None + self.active_pair_whitelist = self._refresh_active_whitelist() # Set initial bot state from config @@ -151,9 +159,11 @@ class FreqtradeBot(LoggingMixin): finally: self.strategy.ft_bot_cleanup() - self.rpc.cleanup() - Trade.commit() - self.exchange.close() + self.rpc.cleanup() + if self.emc: + self.emc.shutdown() + Trade.commit() + self.exchange.close() def startup(self) -> None: """ @@ -254,6 +264,7 @@ class FreqtradeBot(LoggingMixin): pairs that have open trades. """ # Refresh whitelist + _prev_whitelist = self.pairlists.whitelist self.pairlists.refresh_pairlist() _whitelist = self.pairlists.whitelist @@ -266,6 +277,11 @@ class FreqtradeBot(LoggingMixin): # Extend active-pair whitelist with pairs of open trades # It ensures that candle (OHLCV) data are downloaded for open trades as well _whitelist.extend([trade.pair for trade in trades if trade.pair not in _whitelist]) + + # Called last to include the included pairs + if _prev_whitelist != _whitelist: + self.rpc.send_msg({'type': RPCMessageType.WHITELIST, 'data': _whitelist}) + return _whitelist def get_free_open_trades(self) -> int: @@ -584,7 +600,7 @@ class FreqtradeBot(LoggingMixin): # We should decrease our position amount = self.exchange.amount_to_contract_precision( trade.pair, - abs(float(FtPrecise(stake_amount) / FtPrecise(current_exit_rate)))) + abs(float(FtPrecise(stake_amount * trade.leverage) / FtPrecise(current_exit_rate)))) if amount > trade.amount: # This is currently ineffective as remaining would become < min tradable # Fixing this would require checking for 0.0 there - @@ -1327,11 +1343,12 @@ class FreqtradeBot(LoggingMixin): replacing: Optional[bool] = False ) -> bool: """ - Buy cancel - cancel order + entry cancel - cancel order :param replacing: Replacing order - prevent trade deletion. - :return: True if order was fully cancelled + :return: True if trade was fully cancelled """ was_trade_fully_canceled = False + side = trade.entry_side.capitalize() # Cancelled orders may have the status of 'canceled' or 'closed' if order['status'] not in constants.NON_OPEN_EXCHANGE_STATES: @@ -1358,7 +1375,6 @@ class FreqtradeBot(LoggingMixin): corder = order reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE'] - side = trade.entry_side.capitalize() logger.info('%s order %s for %s.', side, reason, trade) # Using filled to determine the filled amount @@ -1372,24 +1388,13 @@ class FreqtradeBot(LoggingMixin): was_trade_fully_canceled = True reason += f", {constants.CANCEL_REASON['FULLY_CANCELLED']}" else: - # FIXME TODO: This could possibly reworked to not duplicate the code 15 lines below. self.update_trade_state(trade, trade.open_order_id, corder) - trade.open_order_id = None logger.info(f'{side} Order timeout for {trade}.') else: - # if trade is partially complete, edit the stake details for the trade - # and close the order - # cancel_order may not contain the full order dict, so we need to fallback - # to the order dict acquired before cancelling. - # we need to fall back to the values from order if corder does not contain these keys. - trade.amount = filled_amount - # * Check edge cases, we don't want to make leverage > 1.0 if we don't have to - # * (for leverage modes which aren't isolated futures) - - trade.stake_amount = trade.amount * trade.open_rate / trade.leverage + # update_trade_state (and subsequently recalc_trade_from_orders) will handle updates + # to the trade object self.update_trade_state(trade, trade.open_order_id, corder) - trade.open_order_id = None logger.info(f'Partial {trade.entry_side} order timeout for {trade}.') reason += f", {constants.CANCEL_REASON['PARTIALLY_FILLED']}" @@ -1426,8 +1431,6 @@ class FreqtradeBot(LoggingMixin): trade.close_rate_requested = None trade.close_profit = None trade.close_profit_abs = None - trade.close_date = None - trade.is_open = True trade.open_order_id = None trade.exit_reason = None cancelled = True @@ -1687,11 +1690,6 @@ class FreqtradeBot(LoggingMixin): 'stake_amount': trade.stake_amount, } - if 'fiat_display_currency' in self.config: - msg.update({ - 'fiat_currency': self.config['fiat_display_currency'], - }) - # Send the message self.rpc.send_msg(msg) diff --git a/freqtrade/misc.py b/freqtrade/misc.py index c3968e61c..56b3fef0e 100644 --- a/freqtrade/misc.py +++ b/freqtrade/misc.py @@ -10,9 +10,11 @@ from typing import Any, Iterator, List from typing.io import IO from urllib.parse import urlparse +import pandas import rapidjson from freqtrade.constants import DECIMAL_PER_COIN_FALLBACK, DECIMALS_PER_COIN +from freqtrade.enums import SignalTagType, SignalType logger = logging.getLogger(__name__) @@ -249,3 +251,41 @@ def parse_db_uri_for_logging(uri: str): return uri pwd = parsed_db_uri.netloc.split(':')[1].split('@')[0] return parsed_db_uri.geturl().replace(f':{pwd}@', ':*****@') + + +def dataframe_to_json(dataframe: pandas.DataFrame) -> str: + """ + Serialize a DataFrame for transmission over the wire using JSON + :param dataframe: A pandas DataFrame + :returns: A JSON string of the pandas DataFrame + """ + return dataframe.to_json(orient='split') + + +def json_to_dataframe(data: str) -> pandas.DataFrame: + """ + Deserialize JSON into a DataFrame + :param data: A JSON string + :returns: A pandas DataFrame from the JSON string + """ + dataframe = pandas.read_json(data, orient='split') + if 'date' in dataframe.columns: + dataframe['date'] = pandas.to_datetime(dataframe['date'], unit='ms', utc=True) + + return dataframe + + +def remove_entry_exit_signals(dataframe: pandas.DataFrame): + """ + Remove Entry and Exit signals from a DataFrame + + :param dataframe: The DataFrame to remove signals from + """ + dataframe[SignalType.ENTER_LONG.value] = 0 + dataframe[SignalType.EXIT_LONG.value] = 0 + dataframe[SignalType.ENTER_SHORT.value] = 0 + dataframe[SignalType.EXIT_SHORT.value] = 0 + dataframe[SignalTagType.ENTER_TAG.value] = None + dataframe[SignalTagType.EXIT_TAG.value] = None + + return dataframe diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 0a05d740d..376c2de7c 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -91,8 +91,8 @@ class Backtesting: if self.config.get('strategy_list'): if self.config.get('freqai', {}).get('enabled', False): - raise OperationalException( - "You can't use strategy_list and freqai at the same time.") + logger.warning("Using --strategy-list with FreqAI REQUIRES all strategies " + "to have identical populate_any_indicators.") for strat in list(self.config['strategy_list']): stratconf = deepcopy(self.config) stratconf['strategy'] = strat @@ -110,10 +110,10 @@ class Backtesting: self.timeframe = str(self.config.get('timeframe')) self.timeframe_min = timeframe_to_minutes(self.timeframe) self.init_backtest_detail() - self.pairlists = PairListManager(self.exchange, self.config) + self.pairlists = PairListManager(self.exchange, self.config, self.dataprovider) if 'VolumePairList' in self.pairlists.name_list: raise OperationalException("VolumePairList not allowed for backtesting. " - "Please use StaticPairlist instead.") + "Please use StaticPairList instead.") if 'PerformanceFilter' in self.pairlists.name_list: raise OperationalException("PerformanceFilter not allowed for backtesting.") @@ -139,9 +139,14 @@ class Backtesting: # Get maximum required startup period self.required_startup = max([strat.startup_candle_count for strat in self.strategylist]) + self.exchange.validate_required_startup_candles(self.required_startup, self.timeframe) + + if self.config.get('freqai', {}).get('enabled', False): + # For FreqAI, increase the required_startup to includes the training data + self.required_startup = self.dataprovider.get_required_startup(self.timeframe) + # Add maximum startup candle count to configuration for informative pairs support self.config['startup_candle_count'] = self.required_startup - self.exchange.validate_required_startup_candles(self.required_startup, self.timeframe) self.trading_mode: TradingMode = config.get('trading_mode', TradingMode.SPOT) # strategies which define "can_short=True" will fail to load in Spot mode. @@ -149,9 +154,6 @@ class Backtesting: self.init_backtest() - def __del__(self): - self.cleanup() - @staticmethod def cleanup(): LoggingMixin.show_output = True @@ -217,7 +219,7 @@ class Backtesting: pairs=self.pairlists.whitelist, timeframe=self.timeframe, timerange=self.timerange, - startup_candles=self.dataprovider.get_required_startup(self.timeframe), + startup_candles=self.config['startup_candle_count'], fail_without_data=True, data_format=self.config.get('dataformat_ohlcv', 'json'), candle_type=self.config.get('candle_type_def', CandleType.SPOT) @@ -368,10 +370,10 @@ class Backtesting: for col in HEADERS[5:]: tag_col = col in ('enter_tag', 'exit_tag') if col in df_analyzed.columns: - df_analyzed.loc[:, col] = df_analyzed.loc[:, col].replace( + df_analyzed[col] = df_analyzed.loc[:, col].replace( [nan], [0 if not tag_col else None]).shift(1) elif not df_analyzed.empty: - df_analyzed.loc[:, col] = 0 if not tag_col else None + df_analyzed[col] = 0 if not tag_col else None df_analyzed = df_analyzed.drop(df_analyzed.head(1).index) @@ -538,7 +540,7 @@ class Backtesting: if stake_amount is not None and stake_amount < 0.0: amount = amount_to_contract_precision( - abs(stake_amount) / current_rate, trade.amount_precision, + abs(stake_amount * trade.leverage) / current_rate, trade.amount_precision, self.precision_mode, trade.contract_size) if amount == 0.0: return trade diff --git a/freqtrade/optimize/hyperopt.py b/freqtrade/optimize/hyperopt.py index aef8405d5..162556705 100644 --- a/freqtrade/optimize/hyperopt.py +++ b/freqtrade/optimize/hyperopt.py @@ -61,7 +61,7 @@ class Hyperopt: """ Hyperopt class, this class contains all the logic to run a hyperopt simulation - To run a backtest: + To start a hyperopt run: hyperopt = Hyperopt(config) hyperopt.start() """ diff --git a/freqtrade/optimize/optimize_reports.py b/freqtrade/optimize/optimize_reports.py index 6c4dbcfef..8dafe2e41 100644 --- a/freqtrade/optimize/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports.py @@ -173,7 +173,7 @@ def generate_tag_metrics(tag_type: str, tabular_data = [] if tag_type in results.columns: - for tag, count in results[tag_type].value_counts().iteritems(): + for tag, count in results[tag_type].value_counts().items(): result = results[results[tag_type] == tag] if skip_nan and result['profit_abs'].isnull().all(): continue @@ -199,7 +199,7 @@ def generate_exit_reason_stats(max_open_trades: int, results: DataFrame) -> List """ tabular_data = [] - for reason, count in results['exit_reason'].value_counts().iteritems(): + for reason, count in results['exit_reason'].value_counts().items(): result = results.loc[results['exit_reason'] == reason] profit_mean = result['profit_ratio'].mean() @@ -361,7 +361,7 @@ def generate_daily_stats(results: DataFrame) -> Dict[str, Any]: winning_days = sum(daily_profit > 0) draw_days = sum(daily_profit == 0) losing_days = sum(daily_profit < 0) - daily_profit_list = [(str(idx.date()), val) for idx, val in daily_profit.iteritems()] + daily_profit_list = [(str(idx.date()), val) for idx, val in daily_profit.items()] return { 'backtest_best_day': best_rel, diff --git a/freqtrade/plugins/pairlist/ProducerPairList.py b/freqtrade/plugins/pairlist/ProducerPairList.py new file mode 100644 index 000000000..50b674e60 --- /dev/null +++ b/freqtrade/plugins/pairlist/ProducerPairList.py @@ -0,0 +1,90 @@ +""" +External Pair List provider + +Provides pair list from Leader data +""" +import logging +from typing import Any, Dict, List, Optional + +from freqtrade.exceptions import OperationalException +from freqtrade.plugins.pairlist.IPairList import IPairList + + +logger = logging.getLogger(__name__) + + +class ProducerPairList(IPairList): + """ + PairList plugin for use with external_message_consumer. + Will use pairs given from leader data. + + Usage: + "pairlists": [ + { + "method": "ProducerPairList", + "number_assets": 5, + "producer_name": "default", + } + ], + """ + + def __init__(self, exchange, pairlistmanager, + config: Dict[str, Any], pairlistconfig: Dict[str, Any], + pairlist_pos: int) -> None: + super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) + + self._num_assets: int = self._pairlistconfig.get('number_assets', 0) + self._producer_name = self._pairlistconfig.get('producer_name', 'default') + if not config.get('external_message_consumer', {}).get('enabled'): + raise OperationalException( + "ProducerPairList requires external_message_consumer to be enabled.") + + @property + def needstickers(self) -> bool: + """ + Boolean property defining if tickers are necessary. + If no Pairlist requires tickers, an empty Dict is passed + as tickers argument to filter_pairlist + """ + return False + + def short_desc(self) -> str: + """ + Short whitelist method description - used for startup-messages + -> Please overwrite in subclasses + """ + return f"{self.name} - {self._producer_name}" + + def _filter_pairlist(self, pairlist: Optional[List[str]]): + upstream_pairlist = self._pairlistmanager._dataprovider.get_producer_pairs( + self._producer_name) + + if pairlist is None: + pairlist = self._pairlistmanager._dataprovider.get_producer_pairs(self._producer_name) + + pairs = list(dict.fromkeys(pairlist + upstream_pairlist)) + if self._num_assets: + pairs = pairs[:self._num_assets] + + return pairs + + def gen_pairlist(self, tickers: Dict) -> List[str]: + """ + Generate the pairlist + :param tickers: Tickers (from exchange.get_tickers()). May be cached. + :return: List of pairs + """ + pairs = self._filter_pairlist(None) + self.log_once(f"Received pairs: {pairs}", logger.debug) + pairs = self._whitelist_for_active_markets(self.verify_whitelist(pairs, logger.info)) + return pairs + + def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: + """ + Filters and sorts pairlist and returns the whitelist again. + Called on each bot iteration - please use internal caching if necessary + :param pairlist: pairlist to filter or sort + :param tickers: Tickers (from exchange.get_tickers()). May be cached. + :return: new whitelist + """ + return self._filter_pairlist(pairlist) diff --git a/freqtrade/plugins/pairlist/VolumePairList.py b/freqtrade/plugins/pairlist/VolumePairList.py index 9dcada291..b290f76aa 100644 --- a/freqtrade/plugins/pairlist/VolumePairList.py +++ b/freqtrade/plugins/pairlist/VolumePairList.py @@ -232,6 +232,4 @@ class VolumePairList(IPairList): # Limit pairlist to the requested number of pairs pairs = pairs[:self._number_pairs] - self.log_once(f"Searching {self._number_pairs} pairs: {pairs}", logger.info) - return pairs diff --git a/freqtrade/plugins/pairlistmanager.py b/freqtrade/plugins/pairlistmanager.py index e01abb297..5ed319e93 100644 --- a/freqtrade/plugins/pairlistmanager.py +++ b/freqtrade/plugins/pairlistmanager.py @@ -3,11 +3,12 @@ PairList manager class """ import logging from functools import partial -from typing import Dict, List +from typing import Dict, List, Optional from cachetools import TTLCache, cached from freqtrade.constants import Config, ListPairsWithTimeframes +from freqtrade.data.dataprovider import DataProvider from freqtrade.enums import CandleType from freqtrade.exceptions import OperationalException from freqtrade.mixins import LoggingMixin @@ -21,13 +22,14 @@ logger = logging.getLogger(__name__) class PairListManager(LoggingMixin): - def __init__(self, exchange, config: Config) -> None: + def __init__(self, exchange, config: Config, dataprovider: DataProvider = None) -> None: self._exchange = exchange self._config = config self._whitelist = self._config['exchange'].get('pair_whitelist') self._blacklist = self._config['exchange'].get('pair_blacklist', []) self._pairlist_handlers: List[IPairList] = [] self._tickers_needed = False + self._dataprovider: Optional[DataProvider] = dataprovider for pairlist_handler_config in self._config.get('pairlists', []): pairlist_handler = PairListResolver.load_pairlist( pairlist_handler_config['method'], @@ -96,6 +98,8 @@ class PairListManager(LoggingMixin): # to ensure blacklist is respected. pairlist = self.verify_blacklist(pairlist, logger.warning) + self.log_once(f"Whitelist with {len(pairlist)} pairs: {pairlist}", logger.info) + self._whitelist = pairlist def verify_blacklist(self, pairlist: List[str], logmethod) -> List[str]: diff --git a/freqtrade/rpc/api_server/api_auth.py b/freqtrade/rpc/api_server/api_auth.py index a39e31b85..ee66fce2b 100644 --- a/freqtrade/rpc/api_server/api_auth.py +++ b/freqtrade/rpc/api_server/api_auth.py @@ -1,8 +1,10 @@ +import logging import secrets from datetime import datetime, timedelta +from typing import Any, Dict, Union import jwt -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Query, WebSocket, status from fastapi.security import OAuth2PasswordBearer from fastapi.security.http import HTTPBasic, HTTPBasicCredentials @@ -10,6 +12,8 @@ from freqtrade.rpc.api_server.api_schemas import AccessAndRefreshToken, AccessTo from freqtrade.rpc.api_server.deps import get_api_config +logger = logging.getLogger(__name__) + ALGORITHM = "HS256" router_login = APIRouter() @@ -25,7 +29,7 @@ httpbasic = HTTPBasic(auto_error=False) oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) -def get_user_from_token(token, secret_key: str, token_type: str = "access"): +def get_user_from_token(token, secret_key: str, token_type: str = "access") -> str: credentials_exception = HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Could not validate credentials", @@ -44,6 +48,45 @@ def get_user_from_token(token, secret_key: str, token_type: str = "access"): return username +# This should be reimplemented to better realign with the existing tools provided +# by FastAPI regarding API Tokens +# https://github.com/tiangolo/fastapi/blob/master/fastapi/security/api_key.py +async def validate_ws_token( + ws: WebSocket, + ws_token: Union[str, None] = Query(default=None, alias="token"), + api_config: Dict[str, Any] = Depends(get_api_config) +): + secret_ws_token = api_config.get('ws_token', None) + secret_jwt_key = api_config.get('jwt_secret_key', 'super-secret') + + # Check if ws_token is/in secret_ws_token + if ws_token and secret_ws_token: + is_valid_ws_token = False + if isinstance(secret_ws_token, str): + is_valid_ws_token = secrets.compare_digest(secret_ws_token, ws_token) + elif isinstance(secret_ws_token, list): + is_valid_ws_token = any([ + secrets.compare_digest(potential, ws_token) + for potential in secret_ws_token + ]) + + if is_valid_ws_token: + return ws_token + + # Check if ws_token is a JWT + try: + user = get_user_from_token(ws_token, secret_jwt_key) + return user + # If the token is a jwt, and it's valid return the user + except HTTPException: + pass + + # No checks passed, deny the connection + logger.debug("Denying websocket request.") + # If it doesn't match, close the websocket connection + await ws.close(code=status.WS_1008_POLICY_VIOLATION) + + def create_token(data: dict, secret_key: str, token_type: str = "access") -> str: to_encode = data.copy() if token_type == "access": diff --git a/freqtrade/rpc/api_server/api_backtest.py b/freqtrade/rpc/api_server/api_backtest.py index 06f04729b..c21828fd4 100644 --- a/freqtrade/rpc/api_server/api_backtest.py +++ b/freqtrade/rpc/api_server/api_backtest.py @@ -5,6 +5,7 @@ from datetime import datetime from typing import Any, Dict, List from fastapi import APIRouter, BackgroundTasks, Depends +from fastapi.exceptions import HTTPException from freqtrade.configuration.config_validation import validate_config_consistency from freqtrade.data.btanalysis import get_backtest_resultlist, load_and_merge_backtest_result @@ -31,6 +32,9 @@ async def api_start_backtest(bt_settings: BacktestRequest, background_tasks: Bac if ApiServer._bgtask_running: raise RPCException('Bot Background task already running') + if ':' in bt_settings.strategy: + raise HTTPException(status_code=500, detail="base64 encoded strategies are not allowed.") + btconfig = deepcopy(config) settings = dict(bt_settings) # Pydantic models will contain all keys, but non-provided ones are None diff --git a/freqtrade/rpc/api_server/api_v1.py b/freqtrade/rpc/api_server/api_v1.py index bf21715b7..135892dc6 100644 --- a/freqtrade/rpc/api_server/api_v1.py +++ b/freqtrade/rpc/api_server/api_v1.py @@ -38,7 +38,8 @@ logger = logging.getLogger(__name__) # 2.15: Add backtest history endpoints # 2.16: Additional daily metrics # 2.17: Forceentry - leverage, partial force_exit -API_VERSION = 2.17 +# 2.20: Add websocket endpoints +API_VERSION = 2.20 # Public API, requires no auth. router_public = APIRouter() @@ -264,6 +265,8 @@ def list_strategies(config=Depends(get_config)): @router.get('/strategy/{strategy}', response_model=StrategyResponse, tags=['strategy']) def get_strategy(strategy: str, config=Depends(get_config)): + if ":" in strategy: + raise HTTPException(status_code=500, detail="base64 encoded strategies are not allowed.") config_ = deepcopy(config) from freqtrade.resolvers.strategy_resolver import StrategyResolver diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py new file mode 100644 index 000000000..f55b2dbd3 --- /dev/null +++ b/freqtrade/rpc/api_server/api_ws.py @@ -0,0 +1,140 @@ +import logging +from typing import Any, Dict + +from fastapi import APIRouter, Depends, WebSocketDisconnect +from fastapi.websockets import WebSocket, WebSocketState +from pydantic import ValidationError + +from freqtrade.enums import RPCMessageType, RPCRequestType +from freqtrade.rpc.api_server.api_auth import validate_ws_token +from freqtrade.rpc.api_server.deps import get_channel_manager, get_rpc +from freqtrade.rpc.api_server.ws import WebSocketChannel +from freqtrade.rpc.api_server.ws_schemas import (WSAnalyzedDFMessage, WSMessageSchema, + WSRequestSchema, WSWhitelistMessage) +from freqtrade.rpc.rpc import RPC + + +logger = logging.getLogger(__name__) + +# Private router, protected by API Key authentication +router = APIRouter() + + +async def is_websocket_alive(ws: WebSocket) -> bool: + """ + Check if a FastAPI Websocket is still open + """ + if ( + ws.application_state == WebSocketState.CONNECTED and + ws.client_state == WebSocketState.CONNECTED + ): + return True + return False + + +async def _process_consumer_request( + request: Dict[str, Any], + channel: WebSocketChannel, + rpc: RPC +): + """ + Validate and handle a request from a websocket consumer + """ + # Validate the request, makes sure it matches the schema + try: + websocket_request = WSRequestSchema.parse_obj(request) + except ValidationError as e: + logger.error(f"Invalid request from {channel}: {e}") + return + + type, data = websocket_request.type, websocket_request.data + response: WSMessageSchema + + logger.debug(f"Request of type {type} from {channel}") + + # If we have a request of type SUBSCRIBE, set the topics in this channel + if type == RPCRequestType.SUBSCRIBE: + # If the request is empty, do nothing + if not data: + return + + # If all topics passed are a valid RPCMessageType, set subscriptions on channel + if all([any(x.value == topic for x in RPCMessageType) for topic in data]): + channel.set_subscriptions(data) + + # We don't send a response for subscriptions + return + + elif type == RPCRequestType.WHITELIST: + # Get whitelist + whitelist = rpc._ws_request_whitelist() + + # Format response + response = WSWhitelistMessage(data=whitelist) + # Send it back + await channel.send(response.dict(exclude_none=True)) + + elif type == RPCRequestType.ANALYZED_DF: + limit = None + + if data: + # Limit the amount of candles per dataframe to 'limit' or 1500 + limit = max(data.get('limit', 1500), 1500) + + # They requested the full historical analyzed dataframes + analyzed_df = rpc._ws_request_analyzed_df(limit) + + # For every dataframe, send as a separate message + for _, message in analyzed_df.items(): + response = WSAnalyzedDFMessage(data=message) + await channel.send(response.dict(exclude_none=True)) + + +@router.websocket("/message/ws") +async def message_endpoint( + ws: WebSocket, + rpc: RPC = Depends(get_rpc), + channel_manager=Depends(get_channel_manager), + token: str = Depends(validate_ws_token) +): + """ + Message WebSocket endpoint, facilitates sending RPC messages + """ + try: + channel = await channel_manager.on_connect(ws) + + if await is_websocket_alive(ws): + + logger.info(f"Consumer connected - {channel}") + + # Keep connection open until explicitly closed, and process requests + try: + while not channel.is_closed(): + request = await channel.recv() + + # Process the request here + await _process_consumer_request(request, channel, rpc) + + except WebSocketDisconnect: + # Handle client disconnects + logger.info(f"Consumer disconnected - {channel}") + await channel_manager.on_disconnect(ws) + except Exception as e: + logger.info(f"Consumer connection failed - {channel}") + logger.exception(e) + # Handle cases like - + # RuntimeError('Cannot call "send" once a closed message has been sent') + await channel_manager.on_disconnect(ws) + + else: + await ws.close() + + except RuntimeError: + # WebSocket was closed + await channel_manager.on_disconnect(ws) + + except Exception as e: + logger.error(f"Failed to serve - {ws.client}") + # Log tracebacks to keep track of what errors are happening + logger.exception(e) + await channel_manager.on_disconnect(ws) diff --git a/freqtrade/rpc/api_server/deps.py b/freqtrade/rpc/api_server/deps.py index 66654c0b1..abd3db036 100644 --- a/freqtrade/rpc/api_server/deps.py +++ b/freqtrade/rpc/api_server/deps.py @@ -41,6 +41,10 @@ def get_exchange(config=Depends(get_config)): return ApiServer._exchange +def get_channel_manager(): + return ApiServer._ws_channel_manager + + def is_webserver_mode(config=Depends(get_config)): if config['runmode'] != RunMode.WEBSERVER: raise RPCException('Bot is not in the correct state') diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py index 642f25e47..df4324740 100644 --- a/freqtrade/rpc/api_server/webserver.py +++ b/freqtrade/rpc/api_server/webserver.py @@ -1,16 +1,21 @@ +import asyncio import logging from ipaddress import IPv4Address +from threading import Thread from typing import Any, Dict import orjson import uvicorn from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware +# Look into alternatives +from janus import Queue as ThreadedQueue from starlette.responses import JSONResponse from freqtrade.constants import Config from freqtrade.exceptions import OperationalException from freqtrade.rpc.api_server.uvicorn_threaded import UvicornServer +from freqtrade.rpc.api_server.ws import ChannelManager from freqtrade.rpc.rpc import RPC, RPCException, RPCHandler @@ -44,6 +49,10 @@ class ApiServer(RPCHandler): _config: Config = {} # Exchange - only available in webserver mode. _exchange = None + # websocket message queue stuff + _ws_channel_manager = None + _ws_thread = None + _ws_loop = None def __new__(cls, *args, **kwargs): """ @@ -61,17 +70,21 @@ class ApiServer(RPCHandler): return self._standalone: bool = standalone self._server = None + self._ws_queue = None + self._ws_background_task = None + ApiServer.__initialized = True api_config = self._config['api_server'] + ApiServer._ws_channel_manager = ChannelManager() + self.app = FastAPI(title="Freqtrade API", docs_url='/docs' if api_config.get('enable_openapi', False) else None, redoc_url=None, default_response_class=FTJSONResponse, ) self.configure_app(self.app, self._config) - self.start_api() def add_rpc_handler(self, rpc: RPC): @@ -93,6 +106,19 @@ class ApiServer(RPCHandler): logger.info("Stopping API Server") self._server.cleanup() + if self._ws_thread and self._ws_loop: + logger.info("Stopping API Server background tasks") + + if self._ws_background_task: + # Cancel the queue task + self._ws_background_task.cancel() + + self._ws_thread.join() + + self._ws_thread = None + self._ws_loop = None + self._ws_background_task = None + @classmethod def shutdown(cls): cls.__initialized = False @@ -102,7 +128,9 @@ class ApiServer(RPCHandler): cls._rpc = None def send_msg(self, msg: Dict[str, str]) -> None: - pass + if self._ws_queue: + sync_q = self._ws_queue.sync_q + sync_q.put(msg) def handle_rpc_exception(self, request, exc): logger.exception(f"API Error calling: {exc}") @@ -116,6 +144,7 @@ class ApiServer(RPCHandler): from freqtrade.rpc.api_server.api_backtest import router as api_backtest from freqtrade.rpc.api_server.api_v1 import router as api_v1 from freqtrade.rpc.api_server.api_v1 import router_public as api_v1_public + from freqtrade.rpc.api_server.api_ws import router as ws_router from freqtrade.rpc.api_server.web_ui import router_ui app.include_router(api_v1_public, prefix="/api/v1") @@ -126,6 +155,7 @@ class ApiServer(RPCHandler): app.include_router(api_backtest, prefix="/api/v1", dependencies=[Depends(http_basic_or_jwt_token)], ) + app.include_router(ws_router, prefix="/api/v1") app.include_router(router_login, prefix="/api/v1", tags=["auth"]) # UI Router MUST be last! app.include_router(router_ui, prefix='') @@ -140,6 +170,48 @@ class ApiServer(RPCHandler): app.add_exception_handler(RPCException, self.handle_rpc_exception) + def start_message_queue(self): + if self._ws_thread: + return + + # Create a new loop, as it'll be just for the background thread + self._ws_loop = asyncio.new_event_loop() + + # Start the thread + self._ws_thread = Thread(target=self._ws_loop.run_forever) + self._ws_thread.start() + + # Finally, submit the coro to the thread + self._ws_background_task = asyncio.run_coroutine_threadsafe( + self._broadcast_queue_data(), loop=self._ws_loop) + + async def _broadcast_queue_data(self): + # Instantiate the queue in this coroutine so it's attached to our loop + self._ws_queue = ThreadedQueue() + async_queue = self._ws_queue.async_q + + try: + while True: + logger.debug("Getting queue messages...") + # Get data from queue + message = await async_queue.get() + logger.debug(f"Found message of type: {message.get('type')}") + # Broadcast it + await self._ws_channel_manager.broadcast(message) + # Sleep, make this configurable? + await asyncio.sleep(0.1) + except asyncio.CancelledError: + pass + + # For testing, shouldn't happen when stable + except Exception as e: + logger.exception(f"Exception happened in background task: {e}") + + finally: + # Disconnect channels and stop the loop on cancel + await self._ws_channel_manager.disconnect_all() + self._ws_loop.stop() + def start_api(self): """ Start API ... should be run in thread. @@ -177,6 +249,7 @@ class ApiServer(RPCHandler): if self._standalone: self._server.run() else: + self.start_message_queue() self._server.run_in_thread() except Exception: logger.exception("Api server failed to start.") diff --git a/freqtrade/rpc/api_server/ws/__init__.py b/freqtrade/rpc/api_server/ws/__init__.py new file mode 100644 index 000000000..055b20a9d --- /dev/null +++ b/freqtrade/rpc/api_server/ws/__init__.py @@ -0,0 +1,6 @@ +# flake8: noqa: F401 +# isort: off +from freqtrade.rpc.api_server.ws.types import WebSocketType +from freqtrade.rpc.api_server.ws.proxy import WebSocketProxy +from freqtrade.rpc.api_server.ws.serializer import HybridJSONWebSocketSerializer +from freqtrade.rpc.api_server.ws.channel import ChannelManager, WebSocketChannel diff --git a/freqtrade/rpc/api_server/ws/channel.py b/freqtrade/rpc/api_server/ws/channel.py new file mode 100644 index 000000000..69a32e266 --- /dev/null +++ b/freqtrade/rpc/api_server/ws/channel.py @@ -0,0 +1,178 @@ +import logging +from threading import RLock +from typing import List, Optional, Type +from uuid import uuid4 + +from fastapi import WebSocket as FastAPIWebSocket + +from freqtrade.rpc.api_server.ws.proxy import WebSocketProxy +from freqtrade.rpc.api_server.ws.serializer import (HybridJSONWebSocketSerializer, + WebSocketSerializer) +from freqtrade.rpc.api_server.ws.types import WebSocketType + + +logger = logging.getLogger(__name__) + + +class WebSocketChannel: + """ + Object to help facilitate managing a websocket connection + """ + + def __init__( + self, + websocket: WebSocketType, + channel_id: Optional[str] = None, + serializer_cls: Type[WebSocketSerializer] = HybridJSONWebSocketSerializer + ): + + self.channel_id = channel_id if channel_id else uuid4().hex[:8] + + # The WebSocket object + self._websocket = WebSocketProxy(websocket) + # The Serializing class for the WebSocket object + self._serializer_cls = serializer_cls + + self._subscriptions: List[str] = [] + + # Internal event to signify a closed websocket + self._closed = False + + # Wrap the WebSocket in the Serializing class + self._wrapped_ws = self._serializer_cls(self._websocket) + + def __repr__(self): + return f"WebSocketChannel({self.channel_id}, {self.remote_addr})" + + @property + def remote_addr(self): + return self._websocket.remote_addr + + async def send(self, data): + """ + Send data on the wrapped websocket + """ + await self._wrapped_ws.send(data) + + async def recv(self): + """ + Receive data on the wrapped websocket + """ + return await self._wrapped_ws.recv() + + async def ping(self): + """ + Ping the websocket + """ + return await self._websocket.ping() + + async def close(self): + """ + Close the WebSocketChannel + """ + + self._closed = True + + def is_closed(self) -> bool: + """ + Closed flag + """ + return self._closed + + def set_subscriptions(self, subscriptions: List[str] = []) -> None: + """ + Set which subscriptions this channel is subscribed to + + :param subscriptions: List of subscriptions, List[str] + """ + self._subscriptions = subscriptions + + def subscribed_to(self, message_type: str) -> bool: + """ + Check if this channel is subscribed to the message_type + + :param message_type: The message type to check + """ + return message_type in self._subscriptions + + +class ChannelManager: + def __init__(self): + self.channels = dict() + self._lock = RLock() # Re-entrant Lock + + async def on_connect(self, websocket: WebSocketType): + """ + Wrap websocket connection into Channel and add to list + + :param websocket: The WebSocket object to attach to the Channel + """ + if isinstance(websocket, FastAPIWebSocket): + try: + await websocket.accept() + except RuntimeError: + # The connection was closed before we could accept it + return + + ws_channel = WebSocketChannel(websocket) + + with self._lock: + self.channels[websocket] = ws_channel + + return ws_channel + + async def on_disconnect(self, websocket: WebSocketType): + """ + Call close on the channel if it's not, and remove from channel list + + :param websocket: The WebSocket objet attached to the Channel + """ + with self._lock: + channel = self.channels.get(websocket) + if channel: + if not channel.is_closed(): + await channel.close() + + del self.channels[websocket] + + async def disconnect_all(self): + """ + Disconnect all Channels + """ + with self._lock: + for websocket, channel in self.channels.copy().items(): + if not channel.is_closed(): + await channel.close() + + self.channels = dict() + + async def broadcast(self, data): + """ + Broadcast data on all Channels + + :param data: The data to send + """ + with self._lock: + message_type = data.get('type') + for websocket, channel in self.channels.copy().items(): + try: + if channel.subscribed_to(message_type): + await channel.send(data) + except RuntimeError: + # Handle cannot send after close cases + await self.on_disconnect(websocket) + + async def send_direct(self, channel, data): + """ + Send data directly through direct_channel only + + :param direct_channel: The WebSocketChannel object to send data through + :param data: The data to send + """ + await channel.send(data) + + def has_channels(self): + """ + Flag for more than 0 channels + """ + return len(self.channels) > 0 diff --git a/freqtrade/rpc/api_server/ws/proxy.py b/freqtrade/rpc/api_server/ws/proxy.py new file mode 100644 index 000000000..2e5a59f05 --- /dev/null +++ b/freqtrade/rpc/api_server/ws/proxy.py @@ -0,0 +1,69 @@ +from typing import Any, Tuple, Union + +from fastapi import WebSocket as FastAPIWebSocket +from websockets.client import WebSocketClientProtocol as WebSocket + +from freqtrade.rpc.api_server.ws.types import WebSocketType + + +class WebSocketProxy: + """ + WebSocketProxy object to bring the FastAPIWebSocket and websockets.WebSocketClientProtocol + under the same API + """ + + def __init__(self, websocket: WebSocketType): + self._websocket: Union[FastAPIWebSocket, WebSocket] = websocket + + @property + def remote_addr(self) -> Tuple[Any, ...]: + if isinstance(self._websocket, WebSocket): + return self._websocket.remote_address + elif isinstance(self._websocket, FastAPIWebSocket): + if self._websocket.client: + client, port = self._websocket.client.host, self._websocket.client.port + return (client, port) + return ("unknown", 0) + + async def send(self, data): + """ + Send data on the wrapped websocket + """ + if hasattr(self._websocket, "send_text"): + await self._websocket.send_text(data) + else: + await self._websocket.send(data) + + async def recv(self): + """ + Receive data on the wrapped websocket + """ + if hasattr(self._websocket, "receive_text"): + return await self._websocket.receive_text() + else: + return await self._websocket.recv() + + async def ping(self): + """ + Ping the websocket, not supported by FastAPI WebSockets + """ + if hasattr(self._websocket, "ping"): + return await self._websocket.ping() + return False + + async def close(self, code: int = 1000): + """ + Close the websocket connection, only supported by FastAPI WebSockets + """ + if hasattr(self._websocket, "close"): + try: + return await self._websocket.close(code) + except RuntimeError: + pass + + async def accept(self): + """ + Accept the WebSocket connection, only support by FastAPI WebSockets + """ + if hasattr(self._websocket, "accept"): + return await self._websocket.accept() diff --git a/freqtrade/rpc/api_server/ws/serializer.py b/freqtrade/rpc/api_server/ws/serializer.py new file mode 100644 index 000000000..6c402a100 --- /dev/null +++ b/freqtrade/rpc/api_server/ws/serializer.py @@ -0,0 +1,62 @@ +import logging +from abc import ABC, abstractmethod + +import orjson +import rapidjson +from pandas import DataFrame + +from freqtrade.misc import dataframe_to_json, json_to_dataframe +from freqtrade.rpc.api_server.ws.proxy import WebSocketProxy + + +logger = logging.getLogger(__name__) + + +class WebSocketSerializer(ABC): + def __init__(self, websocket: WebSocketProxy): + self._websocket: WebSocketProxy = websocket + + @abstractmethod + def _serialize(self, data): + raise NotImplementedError() + + @abstractmethod + def _deserialize(self, data): + raise NotImplementedError() + + async def send(self, data: bytes): + await self._websocket.send(self._serialize(data)) + + async def recv(self) -> bytes: + data = await self._websocket.recv() + + return self._deserialize(data) + + async def close(self, code: int = 1000): + await self._websocket.close(code) + + +class HybridJSONWebSocketSerializer(WebSocketSerializer): + def _serialize(self, data) -> str: + return str(orjson.dumps(data, default=_json_default), "utf-8") + + def _deserialize(self, data: str): + # RapidJSON expects strings + return rapidjson.loads(data, object_hook=_json_object_hook) + + +# Support serializing pandas DataFrames +def _json_default(z): + if isinstance(z, DataFrame): + return { + '__type__': 'dataframe', + '__value__': dataframe_to_json(z) + } + raise TypeError + + +# Support deserializing JSON to pandas DataFrames +def _json_object_hook(z): + if z.get('__type__') == 'dataframe': + return json_to_dataframe(z.get('__value__')) + return z diff --git a/freqtrade/rpc/api_server/ws/types.py b/freqtrade/rpc/api_server/ws/types.py new file mode 100644 index 000000000..9855f9e06 --- /dev/null +++ b/freqtrade/rpc/api_server/ws/types.py @@ -0,0 +1,8 @@ +from typing import Any, Dict, TypeVar + +from fastapi import WebSocket as FastAPIWebSocket +from websockets.client import WebSocketClientProtocol as WebSocket + + +WebSocketType = TypeVar("WebSocketType", FastAPIWebSocket, WebSocket) +MessageType = Dict[str, Any] diff --git a/freqtrade/rpc/api_server/ws_schemas.py b/freqtrade/rpc/api_server/ws_schemas.py new file mode 100644 index 000000000..255226d84 --- /dev/null +++ b/freqtrade/rpc/api_server/ws_schemas.py @@ -0,0 +1,63 @@ +from datetime import datetime +from typing import Any, Dict, List, Optional + +from pandas import DataFrame +from pydantic import BaseModel + +from freqtrade.constants import PairWithTimeframe +from freqtrade.enums.rpcmessagetype import RPCMessageType, RPCRequestType + + +class BaseArbitraryModel(BaseModel): + class Config: + arbitrary_types_allowed = True + + +class WSRequestSchema(BaseArbitraryModel): + type: RPCRequestType + data: Optional[Any] = None + + +class WSMessageSchema(BaseArbitraryModel): + type: RPCMessageType + data: Optional[Any] = None + + class Config: + extra = 'allow' + + +# ------------------------------ REQUEST SCHEMAS ---------------------------- + + +class WSSubscribeRequest(WSRequestSchema): + type: RPCRequestType = RPCRequestType.SUBSCRIBE + data: List[RPCMessageType] + + +class WSWhitelistRequest(WSRequestSchema): + type: RPCRequestType = RPCRequestType.WHITELIST + data: None = None + + +class WSAnalyzedDFRequest(WSRequestSchema): + type: RPCRequestType = RPCRequestType.ANALYZED_DF + data: Dict[str, Any] = {"limit": 1500} + + +# ------------------------------ MESSAGE SCHEMAS ---------------------------- + +class WSWhitelistMessage(WSMessageSchema): + type: RPCMessageType = RPCMessageType.WHITELIST + data: List[str] + + +class WSAnalyzedDFMessage(WSMessageSchema): + class AnalyzedDFData(BaseArbitraryModel): + key: PairWithTimeframe + df: DataFrame + la: datetime + + type: RPCMessageType = RPCMessageType.ANALYZED_DF + data: AnalyzedDFData + +# -------------------------------------------------------------------------- diff --git a/freqtrade/rpc/discord.py b/freqtrade/rpc/discord.py index 9efe6f427..c48508300 100644 --- a/freqtrade/rpc/discord.py +++ b/freqtrade/rpc/discord.py @@ -30,9 +30,9 @@ class Discord(Webhook): pass def send_msg(self, msg) -> None: - logger.info(f"Sending discord message: {msg}") if msg['type'].value in self.config['discord']: + logger.info(f"Sending discord message: {msg}") msg['strategy'] = self.strategy msg['timeframe'] = self.timeframe diff --git a/freqtrade/rpc/external_message_consumer.py b/freqtrade/rpc/external_message_consumer.py new file mode 100644 index 000000000..f5ba4b490 --- /dev/null +++ b/freqtrade/rpc/external_message_consumer.py @@ -0,0 +1,335 @@ +""" +ExternalMessageConsumer module + +Main purpose is to connect to external bot's message websocket to consume data +from it +""" +import asyncio +import logging +import socket +from threading import Thread +from typing import TYPE_CHECKING, Any, Callable, Dict, List, TypedDict + +import websockets +from pydantic import ValidationError + +from freqtrade.data.dataprovider import DataProvider +from freqtrade.enums import RPCMessageType +from freqtrade.misc import remove_entry_exit_signals +from freqtrade.rpc.api_server.ws import WebSocketChannel +from freqtrade.rpc.api_server.ws_schemas import (WSAnalyzedDFMessage, WSAnalyzedDFRequest, + WSMessageSchema, WSRequestSchema, + WSSubscribeRequest, WSWhitelistMessage, + WSWhitelistRequest) + + +if TYPE_CHECKING: + import websockets.connect + + +class Producer(TypedDict): + name: str + host: str + port: int + ws_token: str + + +logger = logging.getLogger(__name__) + + +class ExternalMessageConsumer: + """ + The main controller class for consuming external messages from + other freqtrade bot's + """ + + def __init__( + self, + config: Dict[str, Any], + dataprovider: DataProvider + ): + self._config = config + self._dp = dataprovider + + self._running = False + self._thread = None + self._loop = None + self._main_task = None + self._sub_tasks = None + + self._emc_config = self._config.get('external_message_consumer', {}) + + self.enabled = self._emc_config.get('enabled', False) + self.producers: List[Producer] = self._emc_config.get('producers', []) + + self.wait_timeout = self._emc_config.get('wait_timeout', 300) # in seconds + self.ping_timeout = self._emc_config.get('ping_timeout', 10) # in seconds + self.sleep_time = self._emc_config.get('sleep_time', 10) # in seconds + + # The amount of candles per dataframe on the initial request + self.initial_candle_limit = self._emc_config.get('initial_candle_limit', 1500) + + # Message size limit, in megabytes. Default 8mb, Use bitwise operator << 20 to convert + # as the websockets client expects bytes. + self.message_size_limit = (self._emc_config.get('message_size_limit', 8) << 20) + + # Setting these explicitly as they probably shouldn't be changed by a user + # Unless we somehow integrate this with the strategy to allow creating + # callbacks for the messages + self.topics = [RPCMessageType.WHITELIST, RPCMessageType.ANALYZED_DF] + + # Allow setting data for each initial request + self._initial_requests: List[WSRequestSchema] = [ + WSSubscribeRequest(data=self.topics), + WSWhitelistRequest(), + WSAnalyzedDFRequest() + ] + + # Specify which function to use for which RPCMessageType + self._message_handlers: Dict[str, Callable[[str, WSMessageSchema], None]] = { + RPCMessageType.WHITELIST: self._consume_whitelist_message, + RPCMessageType.ANALYZED_DF: self._consume_analyzed_df_message, + } + + self.start() + + def start(self): + """ + Start the main internal loop in another thread to run coroutines + """ + if self._thread and self._loop: + return + + logger.info("Starting ExternalMessageConsumer") + + self._loop = asyncio.new_event_loop() + self._thread = Thread(target=self._loop.run_forever) + self._running = True + self._thread.start() + + self._main_task = asyncio.run_coroutine_threadsafe(self._main(), loop=self._loop) + + def shutdown(self): + """ + Shutdown the loop, thread, and tasks + """ + if self._thread and self._loop: + logger.info("Stopping ExternalMessageConsumer") + self._running = False + + if self._sub_tasks: + # Cancel sub tasks + for task in self._sub_tasks: + task.cancel() + + if self._main_task: + # Cancel the main task + self._main_task.cancel() + + self._thread.join() + + self._thread = None + self._loop = None + self._sub_tasks = None + self._main_task = None + + async def _main(self): + """ + The main task coroutine + """ + lock = asyncio.Lock() + + try: + # Create a connection to each producer + self._sub_tasks = [ + self._loop.create_task(self._handle_producer_connection(producer, lock)) + for producer in self.producers + ] + + await asyncio.gather(*self._sub_tasks) + except asyncio.CancelledError: + pass + finally: + # Stop the loop once we are done + self._loop.stop() + + async def _handle_producer_connection(self, producer: Producer, lock: asyncio.Lock): + """ + Main connection loop for the consumer + + :param producer: Dictionary containing producer info + :param lock: An asyncio Lock + """ + try: + await self._create_connection(producer, lock) + except asyncio.CancelledError: + # Exit silently + pass + + async def _create_connection(self, producer: Producer, lock: asyncio.Lock): + """ + Actually creates and handles the websocket connection, pinging on timeout + and handling connection errors. + + :param producer: Dictionary containing producer info + :param lock: An asyncio Lock + """ + while self._running: + try: + host, port = producer['host'], producer['port'] + token = producer['ws_token'] + name = producer['name'] + ws_url = f"ws://{host}:{port}/api/v1/message/ws?token={token}" + + # This will raise InvalidURI if the url is bad + async with websockets.connect(ws_url, max_size=self.message_size_limit) as ws: + channel = WebSocketChannel(ws, channel_id=name) + + logger.info(f"Producer connection success - {channel}") + + # Now request the initial data from this Producer + for request in self._initial_requests: + await channel.send( + request.dict(exclude_none=True) + ) + + # Now receive data, if none is within the time limit, ping + await self._receive_messages(channel, producer, lock) + + except (websockets.exceptions.InvalidURI, ValueError) as e: + logger.error(f"{ws_url} is an invalid WebSocket URL - {e}") + break + + except ( + socket.gaierror, + ConnectionRefusedError, + websockets.exceptions.InvalidStatusCode, + websockets.exceptions.InvalidMessage + ) as e: + logger.error(f"Connection Refused - {e} retrying in {self.sleep_time}s") + await asyncio.sleep(self.sleep_time) + continue + + except ( + websockets.exceptions.ConnectionClosedError, + websockets.exceptions.ConnectionClosedOK + ): + # Just keep trying to connect again indefinitely + await asyncio.sleep(self.sleep_time) + continue + + except Exception as e: + # An unforseen error has occurred, log and continue + logger.error("Unexpected error has occurred:") + logger.exception(e) + continue + + async def _receive_messages( + self, + channel: WebSocketChannel, + producer: Producer, + lock: asyncio.Lock + ): + """ + Loop to handle receiving messages from a Producer + + :param channel: The WebSocketChannel object for the WebSocket + :param producer: Dictionary containing producer info + :param lock: An asyncio Lock + """ + while self._running: + try: + message = await asyncio.wait_for( + channel.recv(), + timeout=self.wait_timeout + ) + + try: + async with lock: + # Handle the message + self.handle_producer_message(producer, message) + except Exception as e: + logger.exception(f"Error handling producer message: {e}") + + except (asyncio.TimeoutError, websockets.exceptions.ConnectionClosed): + # We haven't received data yet. Check the connection and continue. + try: + # ping + ping = await channel.ping() + + await asyncio.wait_for(ping, timeout=self.ping_timeout) + logger.debug(f"Connection to {channel} still alive...") + + continue + except Exception as e: + logger.warning(f"Ping error {channel} - retrying in {self.sleep_time}s") + logger.debug(e, exc_info=e) + await asyncio.sleep(self.sleep_time) + + break + + def handle_producer_message(self, producer: Producer, message: Dict[str, Any]): + """ + Handles external messages from a Producer + """ + producer_name = producer.get('name', 'default') + + try: + producer_message = WSMessageSchema.parse_obj(message) + except ValidationError as e: + logger.error(f"Invalid message from `{producer_name}`: {e}") + return + + if not producer_message.data: + logger.error(f"Empty message received from `{producer_name}`") + return + + logger.debug(f"Received message of type `{producer_message.type}` from `{producer_name}`") + + message_handler = self._message_handlers.get(producer_message.type) + + if not message_handler: + logger.info(f"Received unhandled message: `{producer_message.data}`, ignoring...") + return + + message_handler(producer_name, producer_message) + + def _consume_whitelist_message(self, producer_name: str, message: WSMessageSchema): + try: + # Validate the message + whitelist_message = WSWhitelistMessage.parse_obj(message) + except ValidationError as e: + logger.error(f"Invalid message from `{producer_name}`: {e}") + return + + # Add the pairlist data to the DataProvider + self._dp._set_producer_pairs(whitelist_message.data, producer_name=producer_name) + + logger.debug(f"Consumed message from `{producer_name}` of type `RPCMessageType.WHITELIST`") + + def _consume_analyzed_df_message(self, producer_name: str, message: WSMessageSchema): + try: + df_message = WSAnalyzedDFMessage.parse_obj(message) + except ValidationError as e: + logger.error(f"Invalid message from `{producer_name}`: {e}") + return + + key = df_message.data.key + df = df_message.data.df + la = df_message.data.la + + pair, timeframe, candle_type = key + + # If set, remove the Entry and Exit signals from the Producer + if self._emc_config.get('remove_entry_exit_signals', False): + df = remove_entry_exit_signals(df) + + # Add the dataframe to the dataprovider + self._dp._add_external_df(pair, df, + last_analyzed=la, + timeframe=timeframe, + candle_type=candle_type, + producer_name=producer_name) + + logger.debug( + f"Consumed message from `{producer_name}` of type `RPCMessageType.ANALYZED_DF`") diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 6602cdd35..143b11911 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -25,7 +25,7 @@ from freqtrade.exceptions import ExchangeError, PricingError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs from freqtrade.loggers import bufferHandler from freqtrade.misc import decimals_per_coin, shorten_date -from freqtrade.persistence import PairLocks, Trade +from freqtrade.persistence import Order, PairLocks, Trade from freqtrade.persistence.models import PairLock from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist from freqtrade.rpc.fiat_convert import CryptoToFiatConverter @@ -166,9 +166,9 @@ class RPC: else: results = [] for trade in trades: - order = None + order: Optional[Order] = None if trade.open_order_id: - order = self._freqtrade.exchange.fetch_order(trade.open_order_id, trade.pair) + order = trade.select_order_by_order_id(trade.open_order_id) # calculate profit and send message to user if trade.is_open: try: @@ -219,7 +219,7 @@ class RPC: stoploss_entry_dist=stoploss_entry_dist, stoploss_entry_dist_ratio=round(stoploss_entry_dist_ratio, 8), open_order='({} {} rem={:.8f})'.format( - order['type'], order['side'], order['remaining'] + order.order_type, order.side, order.remaining ) if order else None, )) results.append(trade_dict) @@ -773,6 +773,9 @@ class RPC: is_short = trade.is_short if not self._freqtrade.strategy.position_adjustment_enable: raise RPCException(f'position for {pair} already open - id: {trade.id}') + else: + if Trade.get_open_trade_count() >= self._config['max_open_trades']: + raise RPCException("Maximum number of trades is reached.") if not stake_amount: # gen stake amount @@ -1039,14 +1042,52 @@ class RPC: def _rpc_analysed_dataframe(self, pair: str, timeframe: str, limit: Optional[int]) -> Dict[str, Any]: + """ Analyzed dataframe in Dict form """ + _data, last_analyzed = self.__rpc_analysed_dataframe_raw(pair, timeframe, limit) + return self._convert_dataframe_to_dict(self._freqtrade.config['strategy'], + pair, timeframe, _data, last_analyzed) + + def __rpc_analysed_dataframe_raw(self, pair: str, timeframe: str, + limit: Optional[int]) -> Tuple[DataFrame, datetime]: + """ Get the dataframe and last analyze from the dataprovider """ _data, last_analyzed = self._freqtrade.dataprovider.get_analyzed_dataframe( pair, timeframe) _data = _data.copy() + if limit: _data = _data.iloc[-limit:] - return self._convert_dataframe_to_dict(self._freqtrade.config['strategy'], - pair, timeframe, _data, last_analyzed) + return _data, last_analyzed + + def _ws_all_analysed_dataframes( + self, + pairlist: List[str], + limit: Optional[int] + ) -> Dict[str, Any]: + """ Get the analysed dataframes of each pair in the pairlist """ + timeframe = self._freqtrade.config['timeframe'] + candle_type = self._freqtrade.config.get('candle_type_def', CandleType.SPOT) + _data = {} + + for pair in pairlist: + dataframe, last_analyzed = self.__rpc_analysed_dataframe_raw(pair, timeframe, limit) + + _data[pair] = { + "key": (pair, timeframe, candle_type), + "df": dataframe, + "la": last_analyzed + } + + return _data + + def _ws_request_analyzed_df(self, limit: Optional[int]): + """ Historical Analyzed Dataframes for WebSocket """ + whitelist = self._freqtrade.active_pair_whitelist + return self._ws_all_analysed_dataframes(whitelist, limit) + + def _ws_request_whitelist(self): + """ Whitelist data for WebSocket """ + return self._freqtrade.active_pair_whitelist @staticmethod def _rpc_analysed_history_full(config, pair: str, timeframe: str, diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index 8390e61aa..e3b31d225 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -67,7 +67,8 @@ class RPCManager: 'status': 'stopping bot' } """ - logger.info('Sending rpc message: %s', msg) + if msg.get('type') not in (RPCMessageType.ANALYZED_DF, RPCMessageType.WHITELIST): + logger.info('Sending rpc message: %s', msg) if 'pair' in msg: msg.update({ 'base_currency': self._rpc._freqtrade.exchange.get_pair_base_currency(msg['pair']) diff --git a/freqtrade/rpc/webhook.py b/freqtrade/rpc/webhook.py index 6109e80bc..bb3b3922f 100644 --- a/freqtrade/rpc/webhook.py +++ b/freqtrade/rpc/webhook.py @@ -61,6 +61,14 @@ class Webhook(RPCHandler): RPCMessageType.STARTUP, RPCMessageType.WARNING): valuedict = whconfig.get('webhookstatus') + elif msg['type'] in ( + RPCMessageType.PROTECTION_TRIGGER, + RPCMessageType.PROTECTION_TRIGGER_GLOBAL, + RPCMessageType.WHITELIST, + RPCMessageType.ANALYZED_DF, + RPCMessageType.STRATEGY_MSG): + # Don't fail for non-implemented types + return else: raise NotImplementedError('Unknown message type: {}'.format(msg['type'])) if not valuedict: diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 5e765e85b..8f803045f 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -16,6 +16,7 @@ from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, RunMode, Sign SignalTagType, SignalType, TradingMode) from freqtrade.exceptions import OperationalException, StrategyError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_next_date, timeframe_to_seconds +from freqtrade.misc import remove_entry_exit_signals from freqtrade.persistence import Order, PairLocks, Trade from freqtrade.strategy.hyper import HyperStrategyMixin from freqtrade.strategy.informative_decorator import (InformativeData, PopulateIndicators, @@ -742,20 +743,19 @@ class IStrategy(ABC, HyperStrategyMixin): # always run if process_only_new_candles is set to false if (not self.process_only_new_candles or self._last_candle_seen_per_pair.get(pair, None) != dataframe.iloc[-1]['date']): + # Defs that only make change on new candle data. dataframe = self.analyze_ticker(dataframe, metadata) + self._last_candle_seen_per_pair[pair] = dataframe.iloc[-1]['date'] - self.dp._set_cached_df( - pair, self.timeframe, dataframe, - candle_type=self.config.get('candle_type_def', CandleType.SPOT)) + + candle_type = self.config.get('candle_type_def', CandleType.SPOT) + self.dp._set_cached_df(pair, self.timeframe, dataframe, candle_type=candle_type) + self.dp._emit_df((pair, self.timeframe, candle_type), dataframe) + else: logger.debug("Skipping TA Analysis for already analyzed candle") - dataframe[SignalType.ENTER_LONG.value] = 0 - dataframe[SignalType.EXIT_LONG.value] = 0 - dataframe[SignalType.ENTER_SHORT.value] = 0 - dataframe[SignalType.EXIT_SHORT.value] = 0 - dataframe[SignalTagType.ENTER_TAG.value] = None - dataframe[SignalTagType.EXIT_TAG.value] = None + dataframe = remove_entry_exit_signals(dataframe) logger.debug("Loop Analysis Launched") diff --git a/freqtrade/templates/base_config.json.j2 b/freqtrade/templates/base_config.json.j2 index 681af84c6..299734a50 100644 --- a/freqtrade/templates/base_config.json.j2 +++ b/freqtrade/templates/base_config.json.j2 @@ -67,6 +67,7 @@ "verbosity": "error", "enable_openapi": false, "jwt_secret_key": "{{ api_server_jwt_key }}", + "ws_token": "{{ api_server_ws_token }}", "CORS_origins": [], "username": "{{ api_server_username }}", "password": "{{ api_server_password }}" diff --git a/mkdocs.yml b/mkdocs.yml index 257db7867..6477c1feb 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -23,6 +23,13 @@ nav: - Data Downloading: data-download.md - Backtesting: backtesting.md - Hyperopt: hyperopt.md + - FreqAI: + - Introduction: freqai.md + - Configuration: freqai-configuration.md + - Parameter table: freqai-parameter-table.md + - Feature engineering: freqai-feature-engineering.md + - Running FreqAI: freqai-running.md + - Developer guide: freqai-developers.md - Short / Leverage: leverage.md - Utility Sub-commands: utils.md - Plotting: plotting.md @@ -35,7 +42,7 @@ nav: - Advanced Post-installation Tasks: advanced-setup.md - Advanced Strategy: strategy-advanced.md - Advanced Hyperopt: advanced-hyperopt.md - - FreqAI: freqai.md + - Producer/Consumer mode: producer-consumer.md - Edge Positioning: edge.md - Sandbox Testing: sandbox-testing.md - FAQ: faq.md diff --git a/requirements-dev.txt b/requirements-dev.txt index 69749f843..d50105662 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -25,6 +25,6 @@ nbconvert==7.0.0 # mypy types types-cachetools==5.2.1 types-filelock==3.2.7 -types-requests==2.28.10 +types-requests==2.28.11 types-tabulate==0.8.11 types-python-dateutil==2.8.19 diff --git a/requirements.txt b/requirements.txt index e68d0e295..366b3c3fa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,13 @@ numpy==1.23.3 -pandas==1.4.4 +pandas==1.5.0; platform_machine != 'armv7l' +# Piwheels doesn't have 1.5.0 yet. +pandas==1.4.3; platform_machine == 'armv7l' pandas-ta==0.3.14b -ccxt==1.93.66 +ccxt==1.93.98 # Pin cryptography for now due to rust build errors with piwheels cryptography==38.0.1 -aiohttp==3.8.1 +aiohttp==3.8.3 SQLAlchemy==1.4.41 python-telegram-bot==13.14 arrow==1.2.3 @@ -13,7 +15,7 @@ cachetools==4.2.2 requests==2.28.1 urllib3==1.26.12 jsonschema==4.16.0 -TA-Lib==0.4.24 +TA-Lib==0.4.25 technical==1.3.0 tabulate==0.8.10 pycoingecko==3.0.0 @@ -21,6 +23,7 @@ jinja2==3.1.2 tables==3.7.0 blosc==1.10.6 joblib==1.2.0 +pyarrow==9.0.0; platform_machine != 'armv7l' # find first, C search in arrays py_find_1st==1.1.5 @@ -50,3 +53,7 @@ python-dateutil==2.8.2 #Futures schedule==1.1.0 + +#WS Messages +websockets==10.3 +janus==1.0.0 diff --git a/setup.cfg b/setup.cfg index d711534d9..60ec8a75f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -49,4 +49,3 @@ exclude = __pycache__, .eggs, user_data, - diff --git a/setup.py b/setup.py index 8f04e75f7..d3f9ea7c0 100644 --- a/setup.py +++ b/setup.py @@ -8,13 +8,11 @@ hyperopt = [ 'scikit-learn', 'scikit-optimize>=0.7.0', 'filelock', - 'joblib', 'progressbar2', ] freqai = [ 'scikit-learn', - 'joblib', 'catboost; platform_machine != "aarch64"', 'lightgbm', ] @@ -74,12 +72,16 @@ setup( 'pandas', 'tables', 'blosc', + 'joblib>=1.2.0', + 'pyarrow; platform_machine != "armv7l"', 'fastapi', 'uvicorn', 'psutil', 'pyjwt', 'aiofiles', - 'schedule' + 'schedule', + 'websockets', + 'janus' ], extras_require={ 'dev': all_extra, diff --git a/tests/conftest.py b/tests/conftest.py index 4039f9367..a9eeb481e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -58,6 +58,11 @@ def log_has(line, logs): return any(line == message for message in logs.messages) +def log_has_when(line, logs, when): + """Check if line is found in caplog's messages during a specified stage""" + return any(line == message.message for message in logs.get_records(when)) + + def log_has_re(line, logs): """Check if line matches some caplog's message.""" return any(re.match(line, message) for message in logs.messages) @@ -195,6 +200,8 @@ def patch_freqtradebot(mocker, config) -> None: mocker.patch('freqtrade.freqtradebot.RPCManager._init', MagicMock()) mocker.patch('freqtrade.freqtradebot.RPCManager.send_msg', MagicMock()) patch_whitelist(mocker, config) + mocker.patch('freqtrade.freqtradebot.ExternalMessageConsumer') + mocker.patch('freqtrade.configuration.config_validation._validate_consumers') def get_patched_freqtradebot(mocker, config) -> FreqtradeBot: diff --git a/tests/data/test_btanalysis.py b/tests/data/test_btanalysis.py index 72084d067..ec7b457ea 100644 --- a/tests/data/test_btanalysis.py +++ b/tests/data/test_btanalysis.py @@ -235,7 +235,7 @@ def test_calculate_market_change(testdatadir): data = load_data(datadir=testdatadir, pairs=pairs, timeframe='5m') result = calculate_market_change(data) assert isinstance(result, float) - assert pytest.approx(result) == 0.00955514 + assert pytest.approx(result) == 0.01100002 def test_combine_dataframes_with_mean(testdatadir): @@ -275,7 +275,7 @@ def test_create_cum_profit1(testdatadir): filename = testdatadir / "backtest_results/backtest-result_new.json" bt_data = load_backtest_data(filename) # Move close-time to "off" the candle, to make sure the logic still works - bt_data.loc[:, 'close_date'] = bt_data.loc[:, 'close_date'] + DateOffset(seconds=20) + bt_data['close_date'] = bt_data.loc[:, 'close_date'] + DateOffset(seconds=20) timerange = TimeRange.parse_timerange("20180110-20180112") df = load_pair_history(pair="TRX/BTC", timeframe='5m', diff --git a/tests/data/test_datahandler.py b/tests/data/test_datahandler.py new file mode 100644 index 000000000..5d6d60f84 --- /dev/null +++ b/tests/data/test_datahandler.py @@ -0,0 +1,436 @@ +# pragma pylint: disable=missing-docstring, protected-access, C0103 + +import re +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from pandas import DataFrame + +from freqtrade.configuration import TimeRange +from freqtrade.constants import AVAILABLE_DATAHANDLERS +from freqtrade.data.history.featherdatahandler import FeatherDataHandler +from freqtrade.data.history.hdf5datahandler import HDF5DataHandler +from freqtrade.data.history.idatahandler import IDataHandler, get_datahandler, get_datahandlerclass +from freqtrade.data.history.jsondatahandler import JsonDataHandler, JsonGzDataHandler +from freqtrade.data.history.parquetdatahandler import ParquetDataHandler +from freqtrade.enums import CandleType, TradingMode +from tests.conftest import log_has + + +def test_datahandler_ohlcv_get_pairs(testdatadir): + pairs = JsonDataHandler.ohlcv_get_pairs(testdatadir, '5m', candle_type=CandleType.SPOT) + # Convert to set to avoid failures due to sorting + assert set(pairs) == {'UNITTEST/BTC', 'XLM/BTC', 'ETH/BTC', 'TRX/BTC', 'LTC/BTC', + 'XMR/BTC', 'ZEC/BTC', 'ADA/BTC', 'ETC/BTC', 'NXT/BTC', + 'DASH/BTC', 'XRP/ETH'} + + pairs = JsonGzDataHandler.ohlcv_get_pairs(testdatadir, '8m', candle_type=CandleType.SPOT) + assert set(pairs) == {'UNITTEST/BTC'} + + pairs = HDF5DataHandler.ohlcv_get_pairs(testdatadir, '5m', candle_type=CandleType.SPOT) + assert set(pairs) == {'UNITTEST/BTC'} + + pairs = JsonDataHandler.ohlcv_get_pairs(testdatadir, '1h', candle_type=CandleType.MARK) + assert set(pairs) == {'UNITTEST/USDT', 'XRP/USDT'} + + pairs = JsonGzDataHandler.ohlcv_get_pairs(testdatadir, '1h', candle_type=CandleType.FUTURES) + assert set(pairs) == {'XRP/USDT'} + + pairs = HDF5DataHandler.ohlcv_get_pairs(testdatadir, '1h', candle_type=CandleType.MARK) + assert set(pairs) == {'UNITTEST/USDT:USDT'} + + +@pytest.mark.parametrize('filename,pair,timeframe,candletype', [ + ('XMR_BTC-5m.json', 'XMR_BTC', '5m', ''), + ('XMR_USDT-1h.h5', 'XMR_USDT', '1h', ''), + ('BTC-PERP-1h.h5', 'BTC-PERP', '1h', ''), + ('BTC_USDT-2h.jsongz', 'BTC_USDT', '2h', ''), + ('BTC_USDT-2h-mark.jsongz', 'BTC_USDT', '2h', 'mark'), + ('XMR_USDT-1h-mark.h5', 'XMR_USDT', '1h', 'mark'), + ('XMR_USDT-1h-random.h5', 'XMR_USDT', '1h', 'random'), + ('BTC-PERP-1h-index.h5', 'BTC-PERP', '1h', 'index'), + ('XMR_USDT_USDT-1h-mark.h5', 'XMR_USDT_USDT', '1h', 'mark'), +]) +def test_datahandler_ohlcv_regex(filename, pair, timeframe, candletype): + regex = JsonDataHandler._OHLCV_REGEX + + match = re.search(regex, filename) + assert len(match.groups()) > 1 + assert match[1] == pair + assert match[2] == timeframe + assert match[3] == candletype + + +@pytest.mark.parametrize('input,expected', [ + ('XMR_USDT', 'XMR/USDT'), + ('BTC_USDT', 'BTC/USDT'), + ('USDT_BUSD', 'USDT/BUSD'), + ('BTC_USDT_USDT', 'BTC/USDT:USDT'), # Futures + ('XRP_USDT_USDT', 'XRP/USDT:USDT'), # futures + ('BTC-PERP', 'BTC-PERP'), + ('BTC-PERP_USDT', 'BTC-PERP:USDT'), # potential FTX case + ('UNITTEST_USDT', 'UNITTEST/USDT'), +]) +def test_rebuild_pair_from_filename(input, expected): + + assert IDataHandler.rebuild_pair_from_filename(input) == expected + + +def test_datahandler_ohlcv_get_available_data(testdatadir): + paircombs = JsonDataHandler.ohlcv_get_available_data(testdatadir, TradingMode.SPOT) + # Convert to set to avoid failures due to sorting + assert set(paircombs) == { + ('UNITTEST/BTC', '5m', CandleType.SPOT), + ('ETH/BTC', '5m', CandleType.SPOT), + ('XLM/BTC', '5m', CandleType.SPOT), + ('TRX/BTC', '5m', CandleType.SPOT), + ('LTC/BTC', '5m', CandleType.SPOT), + ('XMR/BTC', '5m', CandleType.SPOT), + ('ZEC/BTC', '5m', CandleType.SPOT), + ('UNITTEST/BTC', '1m', CandleType.SPOT), + ('ADA/BTC', '5m', CandleType.SPOT), + ('ETC/BTC', '5m', CandleType.SPOT), + ('NXT/BTC', '5m', CandleType.SPOT), + ('DASH/BTC', '5m', CandleType.SPOT), + ('XRP/ETH', '1m', CandleType.SPOT), + ('XRP/ETH', '5m', CandleType.SPOT), + ('UNITTEST/BTC', '30m', CandleType.SPOT), + ('UNITTEST/BTC', '8m', CandleType.SPOT), + ('NOPAIR/XXX', '4m', CandleType.SPOT), + } + + paircombs = JsonDataHandler.ohlcv_get_available_data(testdatadir, TradingMode.FUTURES) + # Convert to set to avoid failures due to sorting + assert set(paircombs) == { + ('UNITTEST/USDT', '1h', 'mark'), + ('XRP/USDT', '1h', 'futures'), + ('XRP/USDT', '1h', 'mark'), + ('XRP/USDT', '8h', 'mark'), + ('XRP/USDT', '8h', 'funding_rate'), + } + + paircombs = JsonGzDataHandler.ohlcv_get_available_data(testdatadir, TradingMode.SPOT) + assert set(paircombs) == {('UNITTEST/BTC', '8m', CandleType.SPOT)} + paircombs = HDF5DataHandler.ohlcv_get_available_data(testdatadir, TradingMode.SPOT) + assert set(paircombs) == {('UNITTEST/BTC', '5m', CandleType.SPOT)} + + +def test_jsondatahandler_trades_get_pairs(testdatadir): + pairs = JsonGzDataHandler.trades_get_pairs(testdatadir) + # Convert to set to avoid failures due to sorting + assert set(pairs) == {'XRP/ETH', 'XRP/OLD'} + + +def test_jsondatahandler_ohlcv_purge(mocker, testdatadir): + mocker.patch.object(Path, "exists", MagicMock(return_value=False)) + unlinkmock = mocker.patch.object(Path, "unlink", MagicMock()) + dh = JsonGzDataHandler(testdatadir) + assert not dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', '') + assert not dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', candle_type='mark') + assert unlinkmock.call_count == 0 + + mocker.patch.object(Path, "exists", MagicMock(return_value=True)) + assert dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', '') + assert dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', candle_type='mark') + assert unlinkmock.call_count == 2 + + +def test_jsondatahandler_ohlcv_load(testdatadir, caplog): + dh = JsonDataHandler(testdatadir) + df = dh.ohlcv_load('XRP/ETH', '5m', 'spot') + assert len(df) == 712 + + df_mark = dh.ohlcv_load('UNITTEST/USDT', '1h', candle_type="mark") + assert len(df_mark) == 100 + + df_no_mark = dh.ohlcv_load('UNITTEST/USDT', '1h', 'spot') + assert len(df_no_mark) == 0 + + # Failure case (empty array) + df1 = dh.ohlcv_load('NOPAIR/XXX', '4m', 'spot') + assert len(df1) == 0 + assert log_has("Could not load data for NOPAIR/XXX.", caplog) + assert df.columns.equals(df1.columns) + + +@pytest.mark.parametrize('datahandler', ['feather', 'parquet']) +def test_datahandler_trades_not_supported(datahandler, testdatadir, ): + dh = get_datahandler(testdatadir, datahandler) + with pytest.raises(NotImplementedError): + dh.trades_load('UNITTEST/ETH') + with pytest.raises(NotImplementedError): + dh.trades_store('UNITTEST/ETH', MagicMock()) + + +def test_jsondatahandler_trades_load(testdatadir, caplog): + dh = JsonGzDataHandler(testdatadir) + logmsg = "Old trades format detected - converting" + dh.trades_load('XRP/ETH') + assert not log_has(logmsg, caplog) + + # Test conversation is happening + dh.trades_load('XRP/OLD') + assert log_has(logmsg, caplog) + + +def test_jsondatahandler_trades_purge(mocker, testdatadir): + mocker.patch.object(Path, "exists", MagicMock(return_value=False)) + unlinkmock = mocker.patch.object(Path, "unlink", MagicMock()) + dh = JsonGzDataHandler(testdatadir) + assert not dh.trades_purge('UNITTEST/NONEXIST') + assert unlinkmock.call_count == 0 + + mocker.patch.object(Path, "exists", MagicMock(return_value=True)) + assert dh.trades_purge('UNITTEST/NONEXIST') + assert unlinkmock.call_count == 1 + + +@pytest.mark.parametrize('datahandler', AVAILABLE_DATAHANDLERS) +def test_datahandler_ohlcv_append(datahandler, testdatadir, ): + dh = get_datahandler(testdatadir, datahandler) + with pytest.raises(NotImplementedError): + dh.ohlcv_append('UNITTEST/ETH', '5m', DataFrame(), CandleType.SPOT) + with pytest.raises(NotImplementedError): + dh.ohlcv_append('UNITTEST/ETH', '5m', DataFrame(), CandleType.MARK) + + +@pytest.mark.parametrize('datahandler', AVAILABLE_DATAHANDLERS) +def test_datahandler_trades_append(datahandler, testdatadir): + dh = get_datahandler(testdatadir, datahandler) + with pytest.raises(NotImplementedError): + dh.trades_append('UNITTEST/ETH', []) + + +def test_hdf5datahandler_trades_get_pairs(testdatadir): + pairs = HDF5DataHandler.trades_get_pairs(testdatadir) + # Convert to set to avoid failures due to sorting + assert set(pairs) == {'XRP/ETH'} + + +def test_hdf5datahandler_trades_load(testdatadir): + dh = get_datahandler(testdatadir, 'hdf5') + trades = dh.trades_load('XRP/ETH') + assert isinstance(trades, list) + + trades1 = dh.trades_load('UNITTEST/NONEXIST') + assert trades1 == [] + # data goes from 2019-10-11 - 2019-10-13 + timerange = TimeRange.parse_timerange('20191011-20191012') + + trades2 = dh._trades_load('XRP/ETH', timerange) + assert len(trades) > len(trades2) + # Check that ID is None (If it's nan, it's wrong) + assert trades2[0][2] is None + + # unfiltered load has trades before starttime + assert len([t for t in trades if t[0] < timerange.startts * 1000]) >= 0 + # filtered list does not have trades before starttime + assert len([t for t in trades2 if t[0] < timerange.startts * 1000]) == 0 + # unfiltered load has trades after endtime + assert len([t for t in trades if t[0] > timerange.stopts * 1000]) > 0 + # filtered list does not have trades after endtime + assert len([t for t in trades2 if t[0] > timerange.stopts * 1000]) == 0 + + +def test_hdf5datahandler_trades_store(testdatadir, tmpdir): + tmpdir1 = Path(tmpdir) + dh = get_datahandler(testdatadir, 'hdf5') + trades = dh.trades_load('XRP/ETH') + + dh1 = get_datahandler(tmpdir1, 'hdf5') + dh1.trades_store('XRP/NEW', trades) + file = tmpdir1 / 'XRP_NEW-trades.h5' + assert file.is_file() + # Load trades back + trades_new = dh1.trades_load('XRP/NEW') + + assert len(trades_new) == len(trades) + assert trades[0][0] == trades_new[0][0] + assert trades[0][1] == trades_new[0][1] + # assert trades[0][2] == trades_new[0][2] # This is nan - so comparison does not make sense + assert trades[0][3] == trades_new[0][3] + assert trades[0][4] == trades_new[0][4] + assert trades[0][5] == trades_new[0][5] + assert trades[0][6] == trades_new[0][6] + assert trades[-1][0] == trades_new[-1][0] + assert trades[-1][1] == trades_new[-1][1] + # assert trades[-1][2] == trades_new[-1][2] # This is nan - so comparison does not make sense + assert trades[-1][3] == trades_new[-1][3] + assert trades[-1][4] == trades_new[-1][4] + assert trades[-1][5] == trades_new[-1][5] + assert trades[-1][6] == trades_new[-1][6] + + +def test_hdf5datahandler_trades_purge(mocker, testdatadir): + mocker.patch.object(Path, "exists", MagicMock(return_value=False)) + unlinkmock = mocker.patch.object(Path, "unlink", MagicMock()) + dh = get_datahandler(testdatadir, 'hdf5') + assert not dh.trades_purge('UNITTEST/NONEXIST') + assert unlinkmock.call_count == 0 + + mocker.patch.object(Path, "exists", MagicMock(return_value=True)) + assert dh.trades_purge('UNITTEST/NONEXIST') + assert unlinkmock.call_count == 1 + + +@pytest.mark.parametrize('pair,timeframe,candle_type,candle_append,startdt,enddt', [ + # Data goes from 2018-01-10 - 2018-01-30 + ('UNITTEST/BTC', '5m', 'spot', '', '2018-01-15', '2018-01-19'), + # Mark data goes from to 2021-11-15 2021-11-19 + ('UNITTEST/USDT:USDT', '1h', 'mark', '-mark', '2021-11-16', '2021-11-18'), +]) +def test_hdf5datahandler_ohlcv_load_and_resave( + testdatadir, + tmpdir, + pair, + timeframe, + candle_type, + candle_append, + startdt, enddt +): + tmpdir1 = Path(tmpdir) + tmpdir2 = tmpdir1 + if candle_type not in ('', 'spot'): + tmpdir2 = tmpdir1 / 'futures' + tmpdir2.mkdir() + dh = get_datahandler(testdatadir, 'hdf5') + ohlcv = dh._ohlcv_load(pair, timeframe, None, candle_type=candle_type) + assert isinstance(ohlcv, DataFrame) + assert len(ohlcv) > 0 + + file = tmpdir2 / f"UNITTEST_NEW-{timeframe}{candle_append}.h5" + assert not file.is_file() + + dh1 = get_datahandler(tmpdir1, 'hdf5') + dh1.ohlcv_store('UNITTEST/NEW', timeframe, ohlcv, candle_type=candle_type) + assert file.is_file() + + assert not ohlcv[ohlcv['date'] < startdt].empty + + timerange = TimeRange.parse_timerange(f"{startdt.replace('-', '')}-{enddt.replace('-', '')}") + + # Call private function to ensure timerange is filtered in hdf5 + ohlcv = dh._ohlcv_load(pair, timeframe, timerange, candle_type=candle_type) + ohlcv1 = dh1._ohlcv_load('UNITTEST/NEW', timeframe, timerange, candle_type=candle_type) + assert len(ohlcv) == len(ohlcv1) + assert ohlcv.equals(ohlcv1) + assert ohlcv[ohlcv['date'] < startdt].empty + assert ohlcv[ohlcv['date'] > enddt].empty + + # Try loading inexisting file + ohlcv = dh.ohlcv_load('UNITTEST/NONEXIST', timeframe, candle_type=candle_type) + assert ohlcv.empty + + +@pytest.mark.parametrize('pair,timeframe,candle_type,candle_append,startdt,enddt', [ + # Data goes from 2018-01-10 - 2018-01-30 + ('UNITTEST/BTC', '5m', 'spot', '', '2018-01-15', '2018-01-19'), + # Mark data goes from to 2021-11-15 2021-11-19 + ('UNITTEST/USDT', '1h', 'mark', '-mark', '2021-11-16', '2021-11-18'), +]) +@pytest.mark.parametrize('datahandler', ['hdf5', 'feather', 'parquet']) +def test_generic_datahandler_ohlcv_load_and_resave( + datahandler, + testdatadir, + tmpdir, + pair, + timeframe, + candle_type, + candle_append, + startdt, enddt +): + tmpdir1 = Path(tmpdir) + tmpdir2 = tmpdir1 + if candle_type not in ('', 'spot'): + tmpdir2 = tmpdir1 / 'futures' + tmpdir2.mkdir() + # Load data from one common file + dhbase = get_datahandler(testdatadir, 'json') + ohlcv = dhbase._ohlcv_load(pair, timeframe, None, candle_type=candle_type) + assert isinstance(ohlcv, DataFrame) + assert len(ohlcv) > 0 + + # Get data to test + dh = get_datahandler(testdatadir, datahandler) + + file = tmpdir2 / f"UNITTEST_NEW-{timeframe}{candle_append}.{dh._get_file_extension()}" + assert not file.is_file() + + dh1 = get_datahandler(tmpdir1, datahandler) + dh1.ohlcv_store('UNITTEST/NEW', timeframe, ohlcv, candle_type=candle_type) + assert file.is_file() + + assert not ohlcv[ohlcv['date'] < startdt].empty + + timerange = TimeRange.parse_timerange(f"{startdt.replace('-', '')}-{enddt.replace('-', '')}") + + ohlcv = dhbase.ohlcv_load(pair, timeframe, timerange=timerange, candle_type=candle_type) + if datahandler == 'hdf5': + ohlcv1 = dh1._ohlcv_load('UNITTEST/NEW', timeframe, timerange, candle_type=candle_type) + if candle_type == 'mark': + ohlcv1['volume'] = 0.0 + else: + ohlcv1 = dh1.ohlcv_load('UNITTEST/NEW', timeframe, + timerange=timerange, candle_type=candle_type) + + assert len(ohlcv) == len(ohlcv1) + assert ohlcv.equals(ohlcv1) + assert ohlcv[ohlcv['date'] < startdt].empty + assert ohlcv[ohlcv['date'] > enddt].empty + + # Try loading inexisting file + ohlcv = dh.ohlcv_load('UNITTEST/NONEXIST', timeframe, candle_type=candle_type) + assert ohlcv.empty + + +def test_hdf5datahandler_ohlcv_purge(mocker, testdatadir): + mocker.patch.object(Path, "exists", MagicMock(return_value=False)) + unlinkmock = mocker.patch.object(Path, "unlink", MagicMock()) + dh = get_datahandler(testdatadir, 'hdf5') + assert not dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', '') + assert not dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', candle_type='mark') + assert unlinkmock.call_count == 0 + + mocker.patch.object(Path, "exists", MagicMock(return_value=True)) + assert dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', '') + assert dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', candle_type='mark') + assert unlinkmock.call_count == 2 + + +def test_gethandlerclass(): + cl = get_datahandlerclass('json') + assert cl == JsonDataHandler + assert issubclass(cl, IDataHandler) + + cl = get_datahandlerclass('jsongz') + assert cl == JsonGzDataHandler + assert issubclass(cl, IDataHandler) + assert issubclass(cl, JsonDataHandler) + + cl = get_datahandlerclass('hdf5') + assert cl == HDF5DataHandler + assert issubclass(cl, IDataHandler) + + cl = get_datahandlerclass('feather') + assert cl == FeatherDataHandler + assert issubclass(cl, IDataHandler) + + cl = get_datahandlerclass('parquet') + assert cl == ParquetDataHandler + assert issubclass(cl, IDataHandler) + + with pytest.raises(ValueError, match=r"No datahandler for .*"): + get_datahandlerclass('DeadBeef') + + +def test_get_datahandler(testdatadir): + dh = get_datahandler(testdatadir, 'json') + assert type(dh) == JsonDataHandler + dh = get_datahandler(testdatadir, 'jsongz') + assert type(dh) == JsonGzDataHandler + dh1 = get_datahandler(testdatadir, 'jsongz', dh) + assert id(dh1) == id(dh) + + dh = get_datahandler(testdatadir, 'hdf5') + assert type(dh) == HDF5DataHandler diff --git a/tests/data/test_dataprovider.py b/tests/data/test_dataprovider.py index 49603feac..8500fa06c 100644 --- a/tests/data/test_dataprovider.py +++ b/tests/data/test_dataprovider.py @@ -144,6 +144,77 @@ def test_available_pairs(mocker, default_conf, ohlcv_history): assert dp.available_pairs == [("XRP/BTC", timeframe), ("UNITTEST/BTC", timeframe), ] +def test_producer_pairs(mocker, default_conf, ohlcv_history): + dataprovider = DataProvider(default_conf, None) + + producer = "default" + whitelist = ["XRP/BTC", "ETH/BTC"] + assert len(dataprovider.get_producer_pairs(producer)) == 0 + + dataprovider._set_producer_pairs(whitelist, producer) + assert len(dataprovider.get_producer_pairs(producer)) == 2 + + new_whitelist = ["BTC/USDT"] + dataprovider._set_producer_pairs(new_whitelist, producer) + assert dataprovider.get_producer_pairs(producer) == new_whitelist + + assert dataprovider.get_producer_pairs("bad") == [] + + +def test_get_producer_df(mocker, default_conf, ohlcv_history): + dataprovider = DataProvider(default_conf, None) + + pair = 'BTC/USDT' + timeframe = default_conf['timeframe'] + candle_type = CandleType.SPOT + + empty_la = datetime.fromtimestamp(0, tz=timezone.utc) + now = datetime.now(timezone.utc) + + # no data has been added, any request should return an empty dataframe + dataframe, la = dataprovider.get_producer_df(pair, timeframe, candle_type) + assert dataframe.empty + assert la == empty_la + + # the data is added, should return that added dataframe + dataprovider._add_external_df(pair, ohlcv_history, now, timeframe, candle_type) + dataframe, la = dataprovider.get_producer_df(pair, timeframe, candle_type) + assert len(dataframe) > 0 + assert la > empty_la + + # no data on this producer, should return empty dataframe + dataframe, la = dataprovider.get_producer_df(pair, producer_name='bad') + assert dataframe.empty + assert la == empty_la + + # non existent timeframe, empty dataframe + datframe, la = dataprovider.get_producer_df(pair, timeframe='1h') + assert dataframe.empty + assert la == empty_la + + +def test_emit_df(mocker, default_conf, ohlcv_history): + mocker.patch('freqtrade.rpc.rpc_manager.RPCManager.__init__', MagicMock()) + rpc_mock = mocker.patch('freqtrade.rpc.rpc_manager.RPCManager', MagicMock()) + send_mock = mocker.patch('freqtrade.rpc.rpc_manager.RPCManager.send_msg', MagicMock()) + + dataprovider = DataProvider(default_conf, exchange=None, rpc=rpc_mock) + dataprovider_no_rpc = DataProvider(default_conf, exchange=None) + + pair = "BTC/USDT" + + # No emit yet + assert send_mock.call_count == 0 + + # Rpc is added, we call emit, should call send_msg + dataprovider._emit_df(pair, ohlcv_history) + assert send_mock.call_count == 1 + + # No rpc added, emit called, should not call send_msg + dataprovider_no_rpc._emit_df(pair, ohlcv_history) + assert send_mock.call_count == 1 + + def test_refresh(mocker, default_conf, ohlcv_history): refresh_mock = MagicMock() mocker.patch("freqtrade.exchange.Exchange.refresh_latest_ohlcv", refresh_mock) diff --git a/tests/data/test_entryexitanalysis.py b/tests/data/test_entryexitanalysis.py index 09fbe9957..588220465 100755 --- a/tests/data/test_entryexitanalysis.py +++ b/tests/data/test_entryexitanalysis.py @@ -124,8 +124,8 @@ def test_backtest_analysis_nomock(default_conf, mocker, caplog, testdatadir, tmp 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 + assert '0.104411' in captured.out + assert '52.8292' in captured.out # test group 1 args = get_args(base_args + ['--analysis-groups', "1"]) diff --git a/tests/data/test_history.py b/tests/data/test_history.py index 8081e984f..e7e3d4063 100644 --- a/tests/data/test_history.py +++ b/tests/data/test_history.py @@ -1,7 +1,6 @@ # pragma pylint: disable=missing-docstring, protected-access, C0103 import json -import re import uuid from pathlib import Path from shutil import copyfile @@ -13,18 +12,17 @@ from pandas import DataFrame from pandas.testing import assert_frame_equal from freqtrade.configuration import TimeRange -from freqtrade.constants import AVAILABLE_DATAHANDLERS, DATETIME_PRINT_FORMAT +from freqtrade.constants import DATETIME_PRINT_FORMAT from freqtrade.data.converter import ohlcv_to_dataframe -from freqtrade.data.history.hdf5datahandler import HDF5DataHandler from freqtrade.data.history.history_utils import (_download_pair_history, _download_trades_history, _load_cached_data_for_updating, convert_trades_to_ohlcv, get_timerange, load_data, load_pair_history, refresh_backtest_ohlcv_data, refresh_backtest_trades_data, refresh_data, validate_backtest_data) -from freqtrade.data.history.idatahandler import IDataHandler, get_datahandler, get_datahandlerclass +from freqtrade.data.history.idatahandler import get_datahandler from freqtrade.data.history.jsondatahandler import JsonDataHandler, JsonGzDataHandler -from freqtrade.enums import CandleType, TradingMode +from freqtrade.enums import CandleType from freqtrade.exchange import timeframe_to_minutes from freqtrade.misc import file_dump_json from freqtrade.resolvers import StrategyResolver @@ -32,25 +30,6 @@ from tests.conftest import (CURRENT_TEST_STRATEGY, get_patched_exchange, log_has patch_exchange) -# Change this if modifying UNITTEST/BTC testdatafile -_BTC_UNITTEST_LENGTH = 13681 - - -def _backup_file(file: Path, copy_file: bool = False) -> None: - """ - Backup existing file to avoid deleting the user file - :param file: complete path to the file - :param copy_file: keep file in place too. - :return: None - """ - file_swp = str(file) + '.swp' - if file.is_file(): - file.rename(file_swp) - - if copy_file: - copyfile(file_swp, file) - - def _clean_test_file(file: Path) -> None: """ Backup existing file to avoid deleting the user file @@ -67,7 +46,7 @@ def _clean_test_file(file: Path) -> None: file_swp.rename(file) -def test_load_data_30min_timeframe(mocker, caplog, default_conf, testdatadir) -> None: +def test_load_data_30min_timeframe(caplog, testdatadir) -> None: ld = load_pair_history(pair='UNITTEST/BTC', timeframe='30m', datadir=testdatadir) assert isinstance(ld, DataFrame) assert not log_has( @@ -76,7 +55,7 @@ def test_load_data_30min_timeframe(mocker, caplog, default_conf, testdatadir) -> ) -def test_load_data_7min_timeframe(mocker, caplog, default_conf, testdatadir) -> None: +def test_load_data_7min_timeframe(caplog, testdatadir) -> None: ld = load_pair_history(pair='UNITTEST/BTC', timeframe='7m', datadir=testdatadir) assert isinstance(ld, DataFrame) assert ld.empty @@ -108,7 +87,7 @@ def test_load_data_mark(ohlcv_history, mocker, caplog, testdatadir) -> None: ) -def test_load_data_startup_candles(mocker, caplog, default_conf, testdatadir) -> None: +def test_load_data_startup_candles(mocker, testdatadir) -> None: ltfmock = mocker.patch( 'freqtrade.data.history.jsondatahandler.JsonDataHandler._ohlcv_load', MagicMock(return_value=DataFrame())) @@ -398,14 +377,14 @@ def test_load_partial_missing(testdatadir, caplog) -> None: td = ((end - start).total_seconds() // 60 // 5) + 1 assert td != len(data['UNITTEST/BTC']) - # Shift endtime with +5 - as last candle is dropped (partial candle) - end_real = arrow.get(data['UNITTEST/BTC'].iloc[-1, 0]).shift(minutes=5) + # Shift endtime with +5 + end_real = arrow.get(data['UNITTEST/BTC'].iloc[-1, 0]) assert log_has(f'UNITTEST/BTC, spot, 5m, ' f'data ends at {end_real.strftime(DATETIME_PRINT_FORMAT)}', caplog) -def test_init(default_conf, mocker) -> None: +def test_init(default_conf) -> None: assert {} == load_data( datadir=Path(''), pairs=[], @@ -468,7 +447,7 @@ def test_get_timerange(default_conf, mocker, testdatadir) -> None: ) min_date, max_date = get_timerange(data) assert min_date.isoformat() == '2017-11-04T23:02:00+00:00' - assert max_date.isoformat() == '2017-11-14T22:58:00+00:00' + assert max_date.isoformat() == '2017-11-14T22:59:00+00:00' def test_validate_backtest_data_warn(default_conf, mocker, caplog, testdatadir) -> None: @@ -491,7 +470,7 @@ def test_validate_backtest_data_warn(default_conf, mocker, caplog, testdatadir) min_date, max_date, timeframe_to_minutes('1m')) assert len(caplog.record_tuples) == 1 assert log_has( - "UNITTEST/BTC has missing frames: expected 14396, got 13680, that's 716 missing values", + "UNITTEST/BTC has missing frames: expected 14397, got 13681, that's 716 missing values", caplog) @@ -685,340 +664,3 @@ def test_convert_trades_to_ohlcv(testdatadir, tmpdir, caplog): convert_trades_to_ohlcv(['NoDatapair'], timeframes=['1m', '5m'], datadir=tmpdir1, timerange=tr, erase=True) assert log_has('Could not convert NoDatapair to OHLCV.', caplog) - - -def test_datahandler_ohlcv_get_pairs(testdatadir): - pairs = JsonDataHandler.ohlcv_get_pairs(testdatadir, '5m', candle_type=CandleType.SPOT) - # Convert to set to avoid failures due to sorting - assert set(pairs) == {'UNITTEST/BTC', 'XLM/BTC', 'ETH/BTC', 'TRX/BTC', 'LTC/BTC', - 'XMR/BTC', 'ZEC/BTC', 'ADA/BTC', 'ETC/BTC', 'NXT/BTC', - 'DASH/BTC', 'XRP/ETH'} - - pairs = JsonGzDataHandler.ohlcv_get_pairs(testdatadir, '8m', candle_type=CandleType.SPOT) - assert set(pairs) == {'UNITTEST/BTC'} - - pairs = HDF5DataHandler.ohlcv_get_pairs(testdatadir, '5m', candle_type=CandleType.SPOT) - assert set(pairs) == {'UNITTEST/BTC'} - - pairs = JsonDataHandler.ohlcv_get_pairs(testdatadir, '1h', candle_type=CandleType.MARK) - assert set(pairs) == {'UNITTEST/USDT', 'XRP/USDT'} - - pairs = JsonGzDataHandler.ohlcv_get_pairs(testdatadir, '1h', candle_type=CandleType.FUTURES) - assert set(pairs) == {'XRP/USDT'} - - pairs = HDF5DataHandler.ohlcv_get_pairs(testdatadir, '1h', candle_type=CandleType.MARK) - assert set(pairs) == {'UNITTEST/USDT:USDT'} - - -@pytest.mark.parametrize('filename,pair,timeframe,candletype', [ - ('XMR_BTC-5m.json', 'XMR_BTC', '5m', ''), - ('XMR_USDT-1h.h5', 'XMR_USDT', '1h', ''), - ('BTC-PERP-1h.h5', 'BTC-PERP', '1h', ''), - ('BTC_USDT-2h.jsongz', 'BTC_USDT', '2h', ''), - ('BTC_USDT-2h-mark.jsongz', 'BTC_USDT', '2h', 'mark'), - ('XMR_USDT-1h-mark.h5', 'XMR_USDT', '1h', 'mark'), - ('XMR_USDT-1h-random.h5', 'XMR_USDT', '1h', 'random'), - ('BTC-PERP-1h-index.h5', 'BTC-PERP', '1h', 'index'), - ('XMR_USDT_USDT-1h-mark.h5', 'XMR_USDT_USDT', '1h', 'mark'), -]) -def test_datahandler_ohlcv_regex(filename, pair, timeframe, candletype): - regex = JsonDataHandler._OHLCV_REGEX - - match = re.search(regex, filename) - assert len(match.groups()) > 1 - assert match[1] == pair - assert match[2] == timeframe - assert match[3] == candletype - - -@pytest.mark.parametrize('input,expected', [ - ('XMR_USDT', 'XMR/USDT'), - ('BTC_USDT', 'BTC/USDT'), - ('USDT_BUSD', 'USDT/BUSD'), - ('BTC_USDT_USDT', 'BTC/USDT:USDT'), # Futures - ('XRP_USDT_USDT', 'XRP/USDT:USDT'), # futures - ('BTC-PERP', 'BTC-PERP'), - ('BTC-PERP_USDT', 'BTC-PERP:USDT'), # potential FTX case - ('UNITTEST_USDT', 'UNITTEST/USDT'), -]) -def test_rebuild_pair_from_filename(input, expected): - - assert IDataHandler.rebuild_pair_from_filename(input) == expected - - -def test_datahandler_ohlcv_get_available_data(testdatadir): - paircombs = JsonDataHandler.ohlcv_get_available_data(testdatadir, TradingMode.SPOT) - # Convert to set to avoid failures due to sorting - assert set(paircombs) == { - ('UNITTEST/BTC', '5m', CandleType.SPOT), - ('ETH/BTC', '5m', CandleType.SPOT), - ('XLM/BTC', '5m', CandleType.SPOT), - ('TRX/BTC', '5m', CandleType.SPOT), - ('LTC/BTC', '5m', CandleType.SPOT), - ('XMR/BTC', '5m', CandleType.SPOT), - ('ZEC/BTC', '5m', CandleType.SPOT), - ('UNITTEST/BTC', '1m', CandleType.SPOT), - ('ADA/BTC', '5m', CandleType.SPOT), - ('ETC/BTC', '5m', CandleType.SPOT), - ('NXT/BTC', '5m', CandleType.SPOT), - ('DASH/BTC', '5m', CandleType.SPOT), - ('XRP/ETH', '1m', CandleType.SPOT), - ('XRP/ETH', '5m', CandleType.SPOT), - ('UNITTEST/BTC', '30m', CandleType.SPOT), - ('UNITTEST/BTC', '8m', CandleType.SPOT), - ('NOPAIR/XXX', '4m', CandleType.SPOT), - } - - paircombs = JsonDataHandler.ohlcv_get_available_data(testdatadir, TradingMode.FUTURES) - # Convert to set to avoid failures due to sorting - assert set(paircombs) == { - ('UNITTEST/USDT', '1h', 'mark'), - ('XRP/USDT', '1h', 'futures'), - ('XRP/USDT', '1h', 'mark'), - ('XRP/USDT', '8h', 'mark'), - ('XRP/USDT', '8h', 'funding_rate'), - } - - paircombs = JsonGzDataHandler.ohlcv_get_available_data(testdatadir, TradingMode.SPOT) - assert set(paircombs) == {('UNITTEST/BTC', '8m', CandleType.SPOT)} - paircombs = HDF5DataHandler.ohlcv_get_available_data(testdatadir, TradingMode.SPOT) - assert set(paircombs) == {('UNITTEST/BTC', '5m', CandleType.SPOT)} - - -def test_jsondatahandler_trades_get_pairs(testdatadir): - pairs = JsonGzDataHandler.trades_get_pairs(testdatadir) - # Convert to set to avoid failures due to sorting - assert set(pairs) == {'XRP/ETH', 'XRP/OLD'} - - -def test_jsondatahandler_ohlcv_purge(mocker, testdatadir): - mocker.patch.object(Path, "exists", MagicMock(return_value=False)) - unlinkmock = mocker.patch.object(Path, "unlink", MagicMock()) - dh = JsonGzDataHandler(testdatadir) - assert not dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', '') - assert not dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', candle_type='mark') - assert unlinkmock.call_count == 0 - - mocker.patch.object(Path, "exists", MagicMock(return_value=True)) - assert dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', '') - assert dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', candle_type='mark') - assert unlinkmock.call_count == 2 - - -def test_jsondatahandler_ohlcv_load(testdatadir, caplog): - dh = JsonDataHandler(testdatadir) - df = dh.ohlcv_load('XRP/ETH', '5m', 'spot') - assert len(df) == 711 - - df_mark = dh.ohlcv_load('UNITTEST/USDT', '1h', candle_type="mark") - assert len(df_mark) == 99 - - df_no_mark = dh.ohlcv_load('UNITTEST/USDT', '1h', 'spot') - assert len(df_no_mark) == 0 - - # Failure case (empty array) - df1 = dh.ohlcv_load('NOPAIR/XXX', '4m', 'spot') - assert len(df1) == 0 - assert log_has("Could not load data for NOPAIR/XXX.", caplog) - assert df.columns.equals(df1.columns) - - -def test_jsondatahandler_trades_load(testdatadir, caplog): - dh = JsonGzDataHandler(testdatadir) - logmsg = "Old trades format detected - converting" - dh.trades_load('XRP/ETH') - assert not log_has(logmsg, caplog) - - # Test conversation is happening - dh.trades_load('XRP/OLD') - assert log_has(logmsg, caplog) - - -def test_jsondatahandler_trades_purge(mocker, testdatadir): - mocker.patch.object(Path, "exists", MagicMock(return_value=False)) - unlinkmock = mocker.patch.object(Path, "unlink", MagicMock()) - dh = JsonGzDataHandler(testdatadir) - assert not dh.trades_purge('UNITTEST/NONEXIST') - assert unlinkmock.call_count == 0 - - mocker.patch.object(Path, "exists", MagicMock(return_value=True)) - assert dh.trades_purge('UNITTEST/NONEXIST') - assert unlinkmock.call_count == 1 - - -@pytest.mark.parametrize('datahandler', AVAILABLE_DATAHANDLERS) -def test_datahandler_ohlcv_append(datahandler, testdatadir, ): - dh = get_datahandler(testdatadir, datahandler) - with pytest.raises(NotImplementedError): - dh.ohlcv_append('UNITTEST/ETH', '5m', DataFrame(), CandleType.SPOT) - with pytest.raises(NotImplementedError): - dh.ohlcv_append('UNITTEST/ETH', '5m', DataFrame(), CandleType.MARK) - - -@pytest.mark.parametrize('datahandler', AVAILABLE_DATAHANDLERS) -def test_datahandler_trades_append(datahandler, testdatadir): - dh = get_datahandler(testdatadir, datahandler) - with pytest.raises(NotImplementedError): - dh.trades_append('UNITTEST/ETH', []) - - -def test_hdf5datahandler_trades_get_pairs(testdatadir): - pairs = HDF5DataHandler.trades_get_pairs(testdatadir) - # Convert to set to avoid failures due to sorting - assert set(pairs) == {'XRP/ETH'} - - -def test_hdf5datahandler_trades_load(testdatadir): - dh = HDF5DataHandler(testdatadir) - trades = dh.trades_load('XRP/ETH') - assert isinstance(trades, list) - - trades1 = dh.trades_load('UNITTEST/NONEXIST') - assert trades1 == [] - # data goes from 2019-10-11 - 2019-10-13 - timerange = TimeRange.parse_timerange('20191011-20191012') - - trades2 = dh._trades_load('XRP/ETH', timerange) - assert len(trades) > len(trades2) - # Check that ID is None (If it's nan, it's wrong) - assert trades2[0][2] is None - - # unfiltered load has trades before starttime - assert len([t for t in trades if t[0] < timerange.startts * 1000]) >= 0 - # filtered list does not have trades before starttime - assert len([t for t in trades2 if t[0] < timerange.startts * 1000]) == 0 - # unfiltered load has trades after endtime - assert len([t for t in trades if t[0] > timerange.stopts * 1000]) > 0 - # filtered list does not have trades after endtime - assert len([t for t in trades2 if t[0] > timerange.stopts * 1000]) == 0 - - -def test_hdf5datahandler_trades_store(testdatadir, tmpdir): - tmpdir1 = Path(tmpdir) - dh = HDF5DataHandler(testdatadir) - trades = dh.trades_load('XRP/ETH') - - dh1 = HDF5DataHandler(tmpdir1) - dh1.trades_store('XRP/NEW', trades) - file = tmpdir1 / 'XRP_NEW-trades.h5' - assert file.is_file() - # Load trades back - trades_new = dh1.trades_load('XRP/NEW') - - assert len(trades_new) == len(trades) - assert trades[0][0] == trades_new[0][0] - assert trades[0][1] == trades_new[0][1] - # assert trades[0][2] == trades_new[0][2] # This is nan - so comparison does not make sense - assert trades[0][3] == trades_new[0][3] - assert trades[0][4] == trades_new[0][4] - assert trades[0][5] == trades_new[0][5] - assert trades[0][6] == trades_new[0][6] - assert trades[-1][0] == trades_new[-1][0] - assert trades[-1][1] == trades_new[-1][1] - # assert trades[-1][2] == trades_new[-1][2] # This is nan - so comparison does not make sense - assert trades[-1][3] == trades_new[-1][3] - assert trades[-1][4] == trades_new[-1][4] - assert trades[-1][5] == trades_new[-1][5] - assert trades[-1][6] == trades_new[-1][6] - - -def test_hdf5datahandler_trades_purge(mocker, testdatadir): - mocker.patch.object(Path, "exists", MagicMock(return_value=False)) - unlinkmock = mocker.patch.object(Path, "unlink", MagicMock()) - dh = HDF5DataHandler(testdatadir) - assert not dh.trades_purge('UNITTEST/NONEXIST') - assert unlinkmock.call_count == 0 - - mocker.patch.object(Path, "exists", MagicMock(return_value=True)) - assert dh.trades_purge('UNITTEST/NONEXIST') - assert unlinkmock.call_count == 1 - - -@pytest.mark.parametrize('pair,timeframe,candle_type,candle_append,startdt,enddt', [ - # Data goes from 2018-01-10 - 2018-01-30 - ('UNITTEST/BTC', '5m', 'spot', '', '2018-01-15', '2018-01-19'), - # Mark data goes from to 2021-11-15 2021-11-19 - ('UNITTEST/USDT:USDT', '1h', 'mark', '-mark', '2021-11-16', '2021-11-18'), -]) -def test_hdf5datahandler_ohlcv_load_and_resave( - testdatadir, - tmpdir, - pair, - timeframe, - candle_type, - candle_append, - startdt, enddt -): - tmpdir1 = Path(tmpdir) - tmpdir2 = tmpdir1 - if candle_type not in ('', 'spot'): - tmpdir2 = tmpdir1 / 'futures' - tmpdir2.mkdir() - dh = HDF5DataHandler(testdatadir) - ohlcv = dh._ohlcv_load(pair, timeframe, None, candle_type=candle_type) - assert isinstance(ohlcv, DataFrame) - assert len(ohlcv) > 0 - - file = tmpdir2 / f"UNITTEST_NEW-{timeframe}{candle_append}.h5" - assert not file.is_file() - - dh1 = HDF5DataHandler(tmpdir1) - dh1.ohlcv_store('UNITTEST/NEW', timeframe, ohlcv, candle_type=candle_type) - assert file.is_file() - - assert not ohlcv[ohlcv['date'] < startdt].empty - - timerange = TimeRange.parse_timerange(f"{startdt.replace('-', '')}-{enddt.replace('-', '')}") - - # Call private function to ensure timerange is filtered in hdf5 - ohlcv = dh._ohlcv_load(pair, timeframe, timerange, candle_type=candle_type) - ohlcv1 = dh1._ohlcv_load('UNITTEST/NEW', timeframe, timerange, candle_type=candle_type) - assert len(ohlcv) == len(ohlcv1) - assert ohlcv.equals(ohlcv1) - assert ohlcv[ohlcv['date'] < startdt].empty - assert ohlcv[ohlcv['date'] > enddt].empty - - # Try loading inexisting file - ohlcv = dh.ohlcv_load('UNITTEST/NONEXIST', timeframe, candle_type=candle_type) - assert ohlcv.empty - - -def test_hdf5datahandler_ohlcv_purge(mocker, testdatadir): - mocker.patch.object(Path, "exists", MagicMock(return_value=False)) - unlinkmock = mocker.patch.object(Path, "unlink", MagicMock()) - dh = HDF5DataHandler(testdatadir) - assert not dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', '') - assert not dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', candle_type='mark') - assert unlinkmock.call_count == 0 - - mocker.patch.object(Path, "exists", MagicMock(return_value=True)) - assert dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', '') - assert dh.ohlcv_purge('UNITTEST/NONEXIST', '5m', candle_type='mark') - assert unlinkmock.call_count == 2 - - -def test_gethandlerclass(): - cl = get_datahandlerclass('json') - assert cl == JsonDataHandler - assert issubclass(cl, IDataHandler) - cl = get_datahandlerclass('jsongz') - assert cl == JsonGzDataHandler - assert issubclass(cl, IDataHandler) - assert issubclass(cl, JsonDataHandler) - cl = get_datahandlerclass('hdf5') - assert cl == HDF5DataHandler - assert issubclass(cl, IDataHandler) - with pytest.raises(ValueError, match=r"No datahandler for .*"): - get_datahandlerclass('DeadBeef') - - -def test_get_datahandler(testdatadir): - dh = get_datahandler(testdatadir, 'json') - assert type(dh) == JsonDataHandler - dh = get_datahandler(testdatadir, 'jsongz') - assert type(dh) == JsonGzDataHandler - dh1 = get_datahandler(testdatadir, 'jsongz', dh) - assert id(dh1) == id(dh) - - dh = get_datahandler(testdatadir, 'hdf5') - assert type(dh) == HDF5DataHandler diff --git a/tests/exchange/test_binance.py b/tests/exchange/test_binance.py index e9f4dfa8a..ef5cb1240 100644 --- a/tests/exchange/test_binance.py +++ b/tests/exchange/test_binance.py @@ -501,6 +501,24 @@ def test_fill_leverage_tiers_binance_dryrun(default_conf, mocker, leverage_tiers assert len(v) == len(value) +def test_additional_exchange_init_binance(default_conf, mocker): + api_mock = MagicMock() + api_mock.fapiPrivateGetPositionsideDual = MagicMock(return_value={"dualSidePosition": True}) + api_mock.fapiPrivateGetMultiAssetsMargin = MagicMock(return_value={"multiAssetsMargin": True}) + default_conf['dry_run'] = False + default_conf['trading_mode'] = TradingMode.FUTURES + default_conf['margin_mode'] = MarginMode.ISOLATED + with pytest.raises(OperationalException, + match=r"Hedge Mode is not supported.*\nMulti-Asset Mode is not supported.*"): + get_patched_exchange(mocker, default_conf, id="binance", api_mock=api_mock) + api_mock.fapiPrivateGetPositionsideDual = MagicMock(return_value={"dualSidePosition": False}) + api_mock.fapiPrivateGetMultiAssetsMargin = MagicMock(return_value={"multiAssetsMargin": False}) + exchange = get_patched_exchange(mocker, default_conf, id="binance", api_mock=api_mock) + assert exchange + ccxt_exceptionhandlers(mocker, default_conf, api_mock, 'binance', + "additional_exchange_init", "fapiPrivateGetPositionsideDual") + + def test__set_leverage_binance(mocker, default_conf): api_mock = MagicMock() diff --git a/tests/exchange/test_ccxt_compat.py b/tests/exchange/test_ccxt_compat.py index 82be6196a..6798cd2f7 100644 --- a/tests/exchange/test_ccxt_compat.py +++ b/tests/exchange/test_ccxt_compat.py @@ -137,6 +137,7 @@ def exchange_futures(request, exchange_conf, class_mocker): 'freqtrade.exchange.binance.Binance.fill_leverage_tiers') class_mocker.patch('freqtrade.exchange.exchange.Exchange.fetch_trading_fees') class_mocker.patch('freqtrade.exchange.okx.Okx.additional_exchange_init') + class_mocker.patch('freqtrade.exchange.binance.Binance.additional_exchange_init') class_mocker.patch('freqtrade.exchange.exchange.Exchange.load_cached_leverage_tiers', return_value=None) class_mocker.patch('freqtrade.exchange.exchange.Exchange.cache_leverage_tiers') diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 71690ecdf..37ba2ca97 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -20,6 +20,7 @@ from freqtrade.exchange import (Binance, Bittrex, Exchange, Kraken, amount_to_pr timeframe_to_prev_date, timeframe_to_seconds) from freqtrade.exchange.common import (API_FETCH_ORDER_RETRY_COUNT, API_RETRY_COUNT, calculate_backoff, remove_credentials) +from freqtrade.exchange.exchange import amount_to_contract_precision from freqtrade.resolvers.exchange_resolver import ExchangeResolver from tests.conftest import get_mock_coro, get_patched_exchange, log_has, log_has_re, num_log_has_re @@ -4470,6 +4471,7 @@ def test__amount_to_contracts( ('ADA/USDT:USDT', 10.4445555, 10.4, 10.444), ('LTC/ETH', 30, 30, 30), ('LTC/USD', 30, 30, 30), + ('ADA/USDT:USDT', 1.17, 1.1, 1.17), # contract size of 10 ('ETH/USDT:USDT', 10.111, 10.1, 10), ('ETH/USDT:USDT', 10.188, 10.1, 10), @@ -4497,6 +4499,20 @@ def test_amount_to_contract_precision( assert result_size == expected_fut +@pytest.mark.parametrize('amount,precision,precision_mode,contract_size,expected', [ + (1.17, 1.0, 4, 0.01, 1.17), # Tick size + (1.17, 1.0, 2, 0.01, 1.17), # + (1.16, 1.0, 4, 0.01, 1.16), # + (1.16, 1.0, 2, 0.01, 1.16), # + (1.13, 1.0, 2, 0.01, 1.13), # + (10.988, 1.0, 2, 10, 10), + (10.988, 1.0, 4, 10, 10), +]) +def test_amount_to_contract_precision2(amount, precision, precision_mode, contract_size, expected): + res = amount_to_contract_precision(amount, precision, precision_mode, contract_size) + assert pytest.approx(res) == expected + + @pytest.mark.parametrize('exchange_name,open_rate,is_short,trading_mode,margin_mode', [ # Bittrex ('bittrex', 2.0, False, 'spot', None), diff --git a/tests/freqai/test_freqai_backtesting.py b/tests/freqai/test_freqai_backtesting.py index ea127fa99..b1881b2f5 100644 --- a/tests/freqai/test_freqai_backtesting.py +++ b/tests/freqai/test_freqai_backtesting.py @@ -3,21 +3,21 @@ from datetime import datetime, timezone from pathlib import Path from unittest.mock import PropertyMock -import pytest - -from freqtrade.commands.optimize_commands import start_backtesting -from freqtrade.exceptions import OperationalException +from freqtrade.commands.optimize_commands import setup_optimize_configuration +from freqtrade.enums import RunMode from freqtrade.optimize.backtesting import Backtesting from tests.conftest import (CURRENT_TEST_STRATEGY, get_args, log_has_re, patch_exchange, patched_configuration_load_config_file) -def test_freqai_backtest_start_backtest_list(freqai_conf, mocker, testdatadir): +def test_freqai_backtest_start_backtest_list(freqai_conf, mocker, testdatadir, caplog): patch_exchange(mocker) + now = datetime.now(timezone.utc) mocker.patch('freqtrade.plugins.pairlistmanager.PairListManager.whitelist', PropertyMock(return_value=['HULUMULU/USDT', 'XRP/USDT'])) - # mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest', backtestmock) + mocker.patch('freqtrade.optimize.backtesting.history.load_data') + mocker.patch('freqtrade.optimize.backtesting.history.get_timerange', return_value=(now, now)) patched_configuration_load_config_file(mocker, freqai_conf) @@ -30,9 +30,11 @@ def test_freqai_backtest_start_backtest_list(freqai_conf, mocker, testdatadir): '--strategy-list', CURRENT_TEST_STRATEGY ] args = get_args(args) - with pytest.raises(OperationalException, - match=r"You can't use strategy_list and freqai at the same time\."): - start_backtesting(args) + bt_config = setup_optimize_configuration(args, RunMode.BACKTEST) + Backtesting(bt_config) + assert log_has_re('Using --strategy-list with FreqAI REQUIRES all strategies to have identical ' + 'populate_any_indicators.', caplog) + Backtesting.cleanup() def test_freqai_backtest_load_data(freqai_conf, mocker, caplog): diff --git a/tests/freqai/test_freqai_datakitchen.py b/tests/freqai/test_freqai_datakitchen.py index 74e8cc42f..4a0eadeb5 100644 --- a/tests/freqai/test_freqai_datakitchen.py +++ b/tests/freqai/test_freqai_datakitchen.py @@ -71,10 +71,7 @@ def test_use_DBSCAN_to_remove_outliers(mocker, freqai_conf, caplog): freqai = make_data_dictionary(mocker, freqai_conf) # freqai_conf['freqai']['feature_parameters'].update({"outlier_protection_percentage": 1}) freqai.dk.use_DBSCAN_to_remove_outliers(predict=False) - assert log_has_re( - "DBSCAN found eps of 1.75.", - caplog, - ) + assert log_has_re(r"DBSCAN found eps of 2\.3\d\.", caplog) def test_compute_distances(mocker, freqai_conf): @@ -89,7 +86,7 @@ def test_use_SVM_to_remove_outliers_and_outlier_protection(mocker, freqai_conf, freqai_conf['freqai']['feature_parameters'].update({"outlier_protection_percentage": 0.1}) freqai.dk.use_SVM_to_remove_outliers(predict=False) assert log_has_re( - "SVM detected 7.36%", + "SVM detected 8.66%", caplog, ) diff --git a/tests/optimize/conftest.py b/tests/optimize/conftest.py index 9eb3a88cc..3d50f37dd 100644 --- a/tests/optimize/conftest.py +++ b/tests/optimize/conftest.py @@ -6,6 +6,7 @@ import pandas as pd import pytest from freqtrade.enums import ExitType, RunMode +from freqtrade.optimize.backtesting import Backtesting from freqtrade.optimize.hyperopt import Hyperopt from tests.conftest import patch_exchange @@ -28,6 +29,13 @@ def hyperopt_conf(default_conf): return hyperconf +@pytest.fixture(autouse=True) +def backtesting_cleanup() -> None: + yield None + + Backtesting.cleanup() + + @pytest.fixture(scope='function') def hyperopt(hyperopt_conf, mocker): diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index 368e368c5..907e97fb7 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -52,13 +52,6 @@ def trim_dictlist(dict_list, num): return new -@pytest.fixture(autouse=True) -def backtesting_cleanup() -> None: - yield None - - Backtesting.cleanup() - - def load_data_test(what, testdatadir): timerange = TimeRange.parse_timerange('1510694220-1510700340') data = history.load_pair_history(pair='UNITTEST/BTC', datadir=testdatadir, @@ -87,7 +80,7 @@ def load_data_test(what, testdatadir): data.loc[:, 'close'] = np.sin(data.index * hz) / 1000 + base return {'UNITTEST/BTC': clean_ohlcv_dataframe(data, timeframe='1m', pair='UNITTEST/BTC', - fill_missing=True)} + fill_missing=True, drop_incomplete=True)} # FIX: fixturize this? @@ -330,7 +323,7 @@ def test_data_to_dataframe_bt(default_conf, mocker, testdatadir) -> None: backtesting = Backtesting(default_conf) backtesting._set_strategy(backtesting.strategylist[0]) processed = backtesting.strategy.advise_all_indicators(data) - assert len(processed['UNITTEST/BTC']) == 102 + assert len(processed['UNITTEST/BTC']) == 103 # Load strategy to compare the result between Backtesting function and strategy are the same strategy = StrategyResolver.load_strategy(default_conf) @@ -434,7 +427,7 @@ def test_backtesting_no_pair_left(default_conf, mocker, caplog, testdatadir) -> default_conf['pairlists'] = [{"method": "VolumePairList", "number_assets": 5}] with pytest.raises(OperationalException, - match=r'VolumePairList not allowed for backtesting\..*StaticPairlist.*'): + match=r'VolumePairList not allowed for backtesting\..*StaticPairList.*'): Backtesting(default_conf) default_conf.update({ @@ -467,7 +460,7 @@ def test_backtesting_pairlist_list(default_conf, mocker, caplog, testdatadir, ti default_conf['pairlists'] = [{"method": "VolumePairList", "number_assets": 5}] with pytest.raises(OperationalException, - match=r'VolumePairList not allowed for backtesting\..*StaticPairlist.*'): + match=r'VolumePairList not allowed for backtesting\..*StaticPairList.*'): Backtesting(default_conf) default_conf['pairlists'] = [{"method": "StaticPairList"}, {"method": "PerformanceFilter"}] @@ -846,7 +839,7 @@ def test_backtest_trim_no_data_left(default_conf, fee, mocker, testdatadir) -> N data = history.load_data(datadir=testdatadir, timeframe='5m', pairs=['UNITTEST/BTC'], timerange=timerange) df = data['UNITTEST/BTC'] - df.loc[:, 'date'] = df.loc[:, 'date'] - timedelta(days=1) + df['date'] = df.loc[:, 'date'] - timedelta(days=1) # Trimming 100 candles, so after 2nd trimming, no candle is left. df = df.iloc[:100] data['XRP/USDT'] = df @@ -1172,9 +1165,9 @@ def test_backtest_start_timerange(default_conf, mocker, caplog, testdatadir): 'Parameter --timerange detected: 1510694220-1510700340 ...', f'Using data directory: {testdatadir} ...', 'Loading data from 2017-11-14 20:57:00 ' - 'up to 2017-11-14 22:58:00 (0 days).', + 'up to 2017-11-14 22:59:00 (0 days).', 'Backtesting with data from 2017-11-14 21:17:00 ' - 'up to 2017-11-14 22:58:00 (0 days).', + 'up to 2017-11-14 22:59:00 (0 days).', 'Parameter --enable-position-stacking detected ...' ] @@ -1251,9 +1244,9 @@ def test_backtest_start_multi_strat(default_conf, mocker, caplog, testdatadir): 'Parameter --timerange detected: 1510694220-1510700340 ...', f'Using data directory: {testdatadir} ...', 'Loading data from 2017-11-14 20:57:00 ' - 'up to 2017-11-14 22:58:00 (0 days).', + 'up to 2017-11-14 22:59:00 (0 days).', 'Backtesting with data from 2017-11-14 21:17:00 ' - 'up to 2017-11-14 22:58:00 (0 days).', + 'up to 2017-11-14 22:59:00 (0 days).', 'Parameter --enable-position-stacking detected ...', f'Running backtesting for Strategy {CURRENT_TEST_STRATEGY}', 'Running backtesting for Strategy StrategyTestV2', @@ -1362,9 +1355,9 @@ def test_backtest_start_multi_strat_nomock(default_conf, mocker, caplog, testdat 'Parameter --timerange detected: 1510694220-1510700340 ...', f'Using data directory: {testdatadir} ...', 'Loading data from 2017-11-14 20:57:00 ' - 'up to 2017-11-14 22:58:00 (0 days).', + 'up to 2017-11-14 22:59:00 (0 days).', 'Backtesting with data from 2017-11-14 21:17:00 ' - 'up to 2017-11-14 22:58:00 (0 days).', + 'up to 2017-11-14 22:59:00 (0 days).', 'Parameter --enable-position-stacking detected ...', f'Running backtesting for Strategy {CURRENT_TEST_STRATEGY}', 'Running backtesting for Strategy StrategyTestV2', @@ -1378,7 +1371,7 @@ def test_backtest_start_multi_strat_nomock(default_conf, mocker, caplog, testdat assert 'EXIT REASON STATS' in captured.out assert 'DAY BREAKDOWN' in captured.out assert 'LEFT OPEN TRADES REPORT' in captured.out - assert '2017-11-14 21:17:00 -> 2017-11-14 22:58:00 | Max open trades : 1' in captured.out + assert '2017-11-14 21:17:00 -> 2017-11-14 22:59:00 | Max open trades : 1' in captured.out assert 'STRATEGY SUMMARY' in captured.out @@ -1510,9 +1503,9 @@ def test_backtest_start_nomock_futures(default_conf_usdt, mocker, 'Parameter -i/--timeframe detected ... Using timeframe: 1h ...', f'Using data directory: {testdatadir} ...', 'Loading data from 2021-11-17 01:00:00 ' - 'up to 2021-11-21 03:00:00 (4 days).', + 'up to 2021-11-21 04:00:00 (4 days).', 'Backtesting with data from 2021-11-17 21:00:00 ' - 'up to 2021-11-21 03:00:00 (3 days).', + 'up to 2021-11-21 04:00:00 (3 days).', 'XRP/USDT, funding_rate, 8h, data starts at 2021-11-18 00:00:00', 'XRP/USDT, mark, 8h, data starts at 2021-11-18 00:00:00', f'Running backtesting for Strategy {CURRENT_TEST_STRATEGY}', @@ -1623,9 +1616,9 @@ def test_backtest_start_multi_strat_nomock_detail(default_conf, mocker, 'Parameter --timeframe-detail detected, using 1m for intra-candle backtesting ...', f'Using data directory: {testdatadir} ...', 'Loading data from 2019-10-11 00:00:00 ' - 'up to 2019-10-13 11:10:00 (2 days).', + 'up to 2019-10-13 11:15:00 (2 days).', 'Backtesting with data from 2019-10-11 01:40:00 ' - 'up to 2019-10-13 11:10:00 (2 days).', + 'up to 2019-10-13 11:15:00 (2 days).', f'Running backtesting for Strategy {CURRENT_TEST_STRATEGY}', ] @@ -1726,7 +1719,7 @@ def test_backtest_start_multi_strat_caching(default_conf, mocker, caplog, testda 'Parameter --timerange detected: 1510694220-1510700340 ...', f'Using data directory: {testdatadir} ...', 'Loading data from 2017-11-14 20:57:00 ' - 'up to 2017-11-14 22:58:00 (0 days).', + 'up to 2017-11-14 22:59:00 (0 days).', 'Parameter --enable-position-stacking detected ...', ] @@ -1739,7 +1732,7 @@ def test_backtest_start_multi_strat_caching(default_conf, mocker, caplog, testda 'Running backtesting for Strategy StrategyTestV2', 'Running backtesting for Strategy StrategyTestV3', 'Ignoring max_open_trades (--disable-max-market-positions was used) ...', - 'Backtesting with data from 2017-11-14 21:17:00 up to 2017-11-14 22:58:00 (0 days).', + 'Backtesting with data from 2017-11-14 21:17:00 up to 2017-11-14 22:59:00 (0 days).', ] elif run_id == '2' and min_backtest_date < start_time: assert backtestmock.call_count == 0 @@ -1752,7 +1745,7 @@ def test_backtest_start_multi_strat_caching(default_conf, mocker, caplog, testda 'Reusing result of previous backtest for StrategyTestV2', 'Running backtesting for Strategy StrategyTestV3', 'Ignoring max_open_trades (--disable-max-market-positions was used) ...', - 'Backtesting with data from 2017-11-14 21:17:00 up to 2017-11-14 22:58:00 (0 days).', + 'Backtesting with data from 2017-11-14 21:17:00 up to 2017-11-14 22:59:00 (0 days).', ] assert backtestmock.call_count == 1 diff --git a/tests/optimize/test_backtesting_adjust_position.py b/tests/optimize/test_backtesting_adjust_position.py index 71f8cdcea..99c160a40 100644 --- a/tests/optimize/test_backtesting_adjust_position.py +++ b/tests/optimize/test_backtesting_adjust_position.py @@ -93,11 +93,16 @@ def test_backtest_position_adjustment(default_conf, fee, mocker, testdatadir) -> t["close_rate"], 6) < round(ln.iloc[0]["high"], 6)) -def test_backtest_position_adjustment_detailed(default_conf, fee, mocker) -> None: +@pytest.mark.parametrize('leverage', [ + 1, 2 +]) +def test_backtest_position_adjustment_detailed(default_conf, fee, mocker, leverage) -> None: default_conf['use_exit_signal'] = False mocker.patch('freqtrade.exchange.Exchange.get_fee', fee) mocker.patch("freqtrade.exchange.Exchange.get_min_pair_stake_amount", return_value=10) mocker.patch("freqtrade.exchange.Exchange.get_max_pair_stake_amount", return_value=float('inf')) + mocker.patch("freqtrade.exchange.Exchange.get_max_leverage", return_value=10) + patch_exchange(mocker) default_conf.update({ "stake_amount": 100.0, @@ -105,6 +110,7 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker) -> Non "strategy": "StrategyTestV3" }) backtesting = Backtesting(default_conf) + backtesting._can_short = True backtesting._set_strategy(backtesting.strategylist[0]) pair = 'XRP/USDT' row = [ @@ -120,18 +126,19 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker) -> Non '', # enter_tag '', # exit_tag ] + backtesting.strategy.leverage = MagicMock(return_value=leverage) trade = backtesting._enter_trade(pair, row=row, direction='long') trade.orders[0].close_bt_order(row[0], trade) assert trade assert pytest.approx(trade.stake_amount) == 100.0 - assert pytest.approx(trade.amount) == 47.61904762 + assert pytest.approx(trade.amount) == 47.61904762 * leverage assert len(trade.orders) == 1 backtesting.strategy.adjust_trade_position = MagicMock(return_value=None) trade = backtesting._get_adjust_trade_entry_for_candle(trade, row) assert trade assert pytest.approx(trade.stake_amount) == 100.0 - assert pytest.approx(trade.amount) == 47.61904762 + assert pytest.approx(trade.amount) == 47.61904762 * leverage assert len(trade.orders) == 1 # Increase position by 100 backtesting.strategy.adjust_trade_position = MagicMock(return_value=100) @@ -140,7 +147,7 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker) -> Non assert trade assert pytest.approx(trade.stake_amount) == 200.0 - assert pytest.approx(trade.amount) == 95.23809524 + assert pytest.approx(trade.amount) == 95.23809524 * leverage assert len(trade.orders) == 2 # Reduce by more than amount - no change to trade. @@ -150,7 +157,7 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker) -> Non assert trade assert pytest.approx(trade.stake_amount) == 200.0 - assert pytest.approx(trade.amount) == 95.23809524 + assert pytest.approx(trade.amount) == 95.23809524 * leverage assert len(trade.orders) == 2 assert trade.nr_of_successful_entries == 2 @@ -160,7 +167,7 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker) -> Non assert trade assert pytest.approx(trade.stake_amount) == 100.0 - assert pytest.approx(trade.amount) == 47.61904762 + assert pytest.approx(trade.amount) == 47.61904762 * leverage assert len(trade.orders) == 3 assert trade.nr_of_successful_entries == 2 assert trade.nr_of_successful_exits == 1 @@ -171,7 +178,7 @@ def test_backtest_position_adjustment_detailed(default_conf, fee, mocker) -> Non assert trade assert pytest.approx(trade.stake_amount) == 100.0 - assert pytest.approx(trade.amount) == 47.61904762 + assert pytest.approx(trade.amount) == 47.61904762 * leverage assert len(trade.orders) == 3 assert trade.nr_of_successful_entries == 2 assert trade.nr_of_successful_exits == 1 diff --git a/tests/optimize/test_optimize_reports.py b/tests/optimize/test_optimize_reports.py index 5095f2fde..403075795 100644 --- a/tests/optimize/test_optimize_reports.py +++ b/tests/optimize/test_optimize_reports.py @@ -1,6 +1,7 @@ import re from datetime import timedelta from pathlib import Path +from shutil import copyfile import joblib import pandas as pd @@ -25,7 +26,22 @@ from freqtrade.optimize.optimize_reports import (_get_resample_from_period, gene text_table_exit_reason, text_table_strategy) from freqtrade.resolvers.strategy_resolver import StrategyResolver from tests.conftest import CURRENT_TEST_STRATEGY -from tests.data.test_history import _backup_file, _clean_test_file +from tests.data.test_history import _clean_test_file + + +def _backup_file(file: Path, copy_file: bool = False) -> None: + """ + Backup existing file to avoid deleting the user file + :param file: complete path to the file + :param copy_file: keep file in place too. + :return: None + """ + file_swp = str(file) + '.swp' + if file.is_file(): + file.rename(file_swp) + + if copy_file: + copyfile(file_swp, file) def test_text_table_bt_results(): diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 538751251..f0b983063 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -9,6 +9,7 @@ import pytest import time_machine from freqtrade.constants import AVAILABLE_PAIRLISTS +from freqtrade.data.dataprovider import DataProvider from freqtrade.enums import CandleType, RunMode from freqtrade.exceptions import OperationalException from freqtrade.persistence import Trade @@ -40,6 +41,12 @@ def whitelist_conf(default_conf): "sort_key": "quoteVolume", }, ] + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [], + } + }) return default_conf @@ -126,7 +133,7 @@ def test_log_cached(mocker, static_pl_conf, markets, tickers): def test_load_pairlist_noexist(mocker, markets, default_conf): freqtrade = get_patched_freqtradebot(mocker, default_conf) mocker.patch('freqtrade.exchange.Exchange.markets', PropertyMock(return_value=markets)) - plm = PairListManager(freqtrade.exchange, default_conf) + plm = PairListManager(freqtrade.exchange, default_conf, MagicMock()) with pytest.raises(OperationalException, match=r"Impossible to load Pairlist 'NonexistingPairList'. " r"This class does not exist or contains Python code errors."): @@ -137,7 +144,7 @@ def test_load_pairlist_noexist(mocker, markets, default_conf): def test_load_pairlist_verify_multi(mocker, markets_static, default_conf): freqtrade = get_patched_freqtradebot(mocker, default_conf) mocker.patch('freqtrade.exchange.Exchange.markets', PropertyMock(return_value=markets_static)) - plm = PairListManager(freqtrade.exchange, default_conf) + plm = PairListManager(freqtrade.exchange, default_conf, MagicMock()) # Call different versions one after the other, should always consider what was passed in # and have no side-effects (therefore the same check multiple times) assert plm.verify_whitelist(['ETH/BTC', 'XRP/BTC', ], print) == ['ETH/BTC', 'XRP/BTC'] @@ -269,7 +276,7 @@ def test_refresh_pairlist_dynamic(mocker, shitcoinmarkets, tickers, whitelist_co with pytest.raises(OperationalException, match=r'`number_assets` not specified. Please check your configuration ' r'for "pairlist.config.number_assets"'): - PairListManager(freqtrade.exchange, whitelist_conf) + PairListManager(freqtrade.exchange, whitelist_conf, MagicMock()) def test_refresh_pairlist_dynamic_2(mocker, shitcoinmarkets, tickers, whitelist_conf_2): @@ -622,10 +629,10 @@ def test_VolumePairList_range(mocker, whitelist_conf, shitcoinmarkets, tickers, # create candles for high volume with all candles high volume, but very low price. ohlcv_history_high_volume = ohlcv_history.copy() - ohlcv_history_high_volume.loc[:, 'volume'] = 10 - ohlcv_history_high_volume.loc[:, 'low'] = ohlcv_history_high_volume.loc[:, 'low'] * 0.01 - ohlcv_history_high_volume.loc[:, 'high'] = ohlcv_history_high_volume.loc[:, 'high'] * 0.01 - ohlcv_history_high_volume.loc[:, 'close'] = ohlcv_history_high_volume.loc[:, 'close'] * 0.01 + ohlcv_history_high_volume['volume'] = 10 + ohlcv_history_high_volume['low'] = ohlcv_history_high_volume.loc[:, 'low'] * 0.01 + ohlcv_history_high_volume['high'] = ohlcv_history_high_volume.loc[:, 'high'] * 0.01 + ohlcv_history_high_volume['close'] = ohlcv_history_high_volume.loc[:, 'close'] * 0.01 mocker.patch('freqtrade.exchange.ftx.Ftx.market_is_tradable', return_value=True) @@ -694,7 +701,7 @@ def test_PrecisionFilter_error(mocker, whitelist_conf) -> None: with pytest.raises(OperationalException, match=r"PrecisionFilter can only work with stoploss defined\..*"): - PairListManager(MagicMock, whitelist_conf) + PairListManager(MagicMock, whitelist_conf, MagicMock()) def test_PerformanceFilter_error(mocker, whitelist_conf, caplog) -> None: @@ -703,7 +710,7 @@ def test_PerformanceFilter_error(mocker, whitelist_conf, caplog) -> None: del Trade.query mocker.patch('freqtrade.exchange.Exchange.exchange_has', MagicMock(return_value=True)) exchange = get_patched_exchange(mocker, whitelist_conf) - pm = PairListManager(exchange, whitelist_conf) + pm = PairListManager(exchange, whitelist_conf, MagicMock()) pm.refresh_pairlist() assert log_has("PerformanceFilter is not available in this mode.", caplog) @@ -1167,6 +1174,10 @@ def test_spreadfilter_invalid_data(mocker, default_conf, markets, tickers, caplo "[{'OffsetFilter': 'OffsetFilter - Taking 10 Pairs, starting from 5.'}]", None ), + ({"method": "ProducerPairList"}, + "[{'ProducerPairList': 'ProducerPairList - default'}]", + None + ), ]) def test_pricefilter_desc(mocker, whitelist_conf, markets, pairlistconfig, desc_expected, exception_expected): @@ -1341,3 +1352,77 @@ def test_expand_pairlist_keep_invalid(wildcardlist, pairs, expected): expand_pairlist(wildcardlist, pairs, keep_invalid=True) else: assert sorted(expand_pairlist(wildcardlist, pairs, keep_invalid=True)) == sorted(expected) + + +def test_ProducerPairlist_no_emc(mocker, whitelist_conf): + mocker.patch('freqtrade.exchange.Exchange.exchange_has', MagicMock(return_value=True)) + + whitelist_conf['pairlists'] = [ + { + "method": "ProducerPairList", + "number_assets": 10, + "producer_name": "hello_world", + } + ] + del whitelist_conf['external_message_consumer'] + + with pytest.raises(OperationalException, + match=r"ProducerPairList requires external_message_consumer to be enabled."): + get_patched_freqtradebot(mocker, whitelist_conf) + + +def test_ProducerPairlist(mocker, whitelist_conf, markets): + mocker.patch('freqtrade.exchange.Exchange.exchange_has', MagicMock(return_value=True)) + mocker.patch.multiple('freqtrade.exchange.Exchange', + markets=PropertyMock(return_value=markets), + exchange_has=MagicMock(return_value=True), + ) + whitelist_conf['pairlists'] = [ + { + "method": "ProducerPairList", + "number_assets": 2, + "producer_name": "hello_world", + } + ] + whitelist_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "hello_world", + "host": "null", + "port": 9891, + "ws_token": "dummy", + } + ] + } + }) + + exchange = get_patched_exchange(mocker, whitelist_conf) + dp = DataProvider(whitelist_conf, exchange, None) + pairs = ['ETH/BTC', 'LTC/BTC', 'XRP/BTC'] + # different producer + dp._set_producer_pairs(pairs + ['MEEP/USDT'], 'default') + pm = PairListManager(exchange, whitelist_conf, dp) + pm.refresh_pairlist() + assert pm.whitelist == [] + # proper producer + dp._set_producer_pairs(pairs, 'hello_world') + pm.refresh_pairlist() + + # Pairlist reduced to 2 + assert pm.whitelist == pairs[:2] + assert len(pm.whitelist) == 2 + whitelist_conf['exchange']['pair_whitelist'] = ['TKN/BTC'] + + whitelist_conf['pairlists'] = [ + {"method": "StaticPairList"}, + { + "method": "ProducerPairList", + "producer_name": "hello_world", + } + ] + pm = PairListManager(exchange, whitelist_conf, dp) + pm.refresh_pairlist() + assert len(pm.whitelist) == 4 + assert pm.whitelist == ['TKN/BTC'] + pairs diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index 8bbf75a32..54a4cbe9a 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -45,7 +45,6 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: freqtradebot.enter_positions() trades = Trade.get_open_trades() - trades[0].open_order_id = None freqtradebot.exit_positions(trades) results = rpc._rpc_trade_status() @@ -1031,6 +1030,7 @@ def test_rpc_count(mocker, default_conf, ticker, fee) -> None: def test_rpc_force_entry(mocker, default_conf, ticker, fee, limit_buy_order_open) -> None: default_conf['force_entry_enable'] = True + default_conf['max_open_trades'] = 0 mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) buy_mm = MagicMock(return_value=limit_buy_order_open) mocker.patch.multiple( @@ -1045,6 +1045,10 @@ def test_rpc_force_entry(mocker, default_conf, ticker, fee, limit_buy_order_open patch_get_signal(freqtradebot) rpc = RPC(freqtradebot) pair = 'ETH/BTC' + with pytest.raises(RPCException, match='Maximum number of trades is reached.'): + rpc._rpc_force_entry(pair, None) + freqtradebot.config['max_open_trades'] = 5 + trade = rpc._rpc_force_entry(pair, None) assert isinstance(trade, Trade) assert trade.pair == pair diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 898ab4767..c0837755a 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -3,6 +3,8 @@ Unit test file for rpc/api_server.py """ import json +import logging +import time from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import ANY, MagicMock, PropertyMock @@ -10,7 +12,7 @@ from unittest.mock import ANY, MagicMock, PropertyMock import pandas as pd import pytest import uvicorn -from fastapi import FastAPI +from fastapi import FastAPI, WebSocketDisconnect from fastapi.exceptions import HTTPException from fastapi.testclient import TestClient from requests.auth import _basic_auth_str @@ -31,6 +33,7 @@ from tests.conftest import (CURRENT_TEST_STRATEGY, create_mock_trades, get_mock_ BASE_URI = "/api/v1" _TEST_USER = "FreqTrader" _TEST_PASS = "SuperSecurePassword1!" +_TEST_WS_TOKEN = "secret_Ws_t0ken" @pytest.fixture @@ -44,17 +47,21 @@ def botclient(default_conf, mocker): "CORS_origins": ['http://example.com'], "username": _TEST_USER, "password": _TEST_PASS, + "ws_token": _TEST_WS_TOKEN }}) ftbot = get_patched_freqtradebot(mocker, default_conf) rpc = RPC(ftbot) mocker.patch('freqtrade.rpc.api_server.ApiServer.start_api', MagicMock()) + apiserver = None try: apiserver = ApiServer(default_conf) apiserver.add_rpc_handler(rpc) yield ftbot, TestClient(apiserver.app) # Cleanup ... ? finally: + if apiserver: + apiserver.cleanup() ApiServer.shutdown() @@ -154,6 +161,25 @@ def test_api_auth(): get_user_from_token(b'not_a_token', 'secret1234') +def test_api_ws_auth(botclient): + ftbot, client = botclient + def url(token): return f"/api/v1/message/ws?token={token}" + + bad_token = "bad-ws_token" + with pytest.raises(WebSocketDisconnect): + with client.websocket_connect(url(bad_token)) as websocket: + websocket.receive() + + good_token = _TEST_WS_TOKEN + with client.websocket_connect(url(good_token)) as websocket: + pass + + jwt_secret = ftbot.config['api_server'].get('jwt_secret_key', 'super-secret') + jwt_token = create_token({'identity': {'u': 'Freqtrade'}}, jwt_secret) + with client.websocket_connect(url(jwt_token)) as websocket: + pass + + def test_api_unauthorized(botclient): ftbot, client = botclient rc = client.get(f"{BASE_URI}/ping") @@ -261,6 +287,7 @@ def test_api__init__(default_conf, mocker): with pytest.raises(OperationalException, match="RPC Handler already attached."): apiserver.add_rpc_handler(RPC(get_patched_freqtradebot(mocker, default_conf))) + apiserver.cleanup() ApiServer.shutdown() @@ -388,6 +415,7 @@ def test_api_run(default_conf, mocker, caplog): MagicMock(side_effect=Exception)) apiserver.start_api() assert log_has("Api server failed to start.", caplog) + apiserver.cleanup() ApiServer.shutdown() @@ -410,6 +438,7 @@ def test_api_cleanup(default_conf, mocker, caplog): apiserver.cleanup() assert apiserver._server.cleanup.call_count == 1 assert log_has("Stopping API Server", caplog) + assert log_has("Stopping API Server background tasks", caplog) ApiServer.shutdown() @@ -1449,6 +1478,10 @@ def test_api_strategy(botclient): rc = client_get(client, f"{BASE_URI}/strategy/NoStrat") assert_response(rc, 404) + # Disallow base64 strategies + rc = client_get(client, f"{BASE_URI}/strategy/xx:cHJpbnQoImhlbGxvIHdvcmxkIik=") + assert_response(rc, 500) + def test_list_available_pairs(botclient): ftbot, client = botclient @@ -1622,6 +1655,11 @@ def test_api_backtesting(botclient, mocker, fee, caplog, tmpdir): assert not result['running'] assert result['status_msg'] == 'Backtest reset' + # Disallow base64 strategies + data['strategy'] = "xx:cHJpbnQoImhlbGxvIHdvcmxkIik=" + rc = client_post(client, f"{BASE_URI}/backtest", data=json.dumps(data)) + assert_response(rc, 500) + def test_api_backtest_history(botclient, mocker, testdatadir): ftbot, client = botclient @@ -1664,3 +1702,93 @@ def test_health(botclient): ret = rc.json() assert ret['last_process_ts'] == 0 assert ret['last_process'] == '1970-01-01T00:00:00+00:00' + + +def test_api_ws_subscribe(botclient, mocker): + ftbot, client = botclient + ws_url = f"/api/v1/message/ws?token={_TEST_WS_TOKEN}" + + sub_mock = mocker.patch('freqtrade.rpc.api_server.ws.WebSocketChannel.set_subscriptions') + + with client.websocket_connect(ws_url) as ws: + ws.send_json({'type': 'subscribe', 'data': ['whitelist']}) + + # Check call count is now 1 as we sent a valid subscribe request + assert sub_mock.call_count == 1 + + with client.websocket_connect(ws_url) as ws: + ws.send_json({'type': 'subscribe', 'data': 'whitelist'}) + + # Call count hasn't changed as the subscribe request was invalid + assert sub_mock.call_count == 1 + + +def test_api_ws_requests(botclient, mocker, caplog): + caplog.set_level(logging.DEBUG) + + ftbot, client = botclient + ws_url = f"/api/v1/message/ws?token={_TEST_WS_TOKEN}" + + # Test whitelist request + with client.websocket_connect(ws_url) as ws: + ws.send_json({"type": "whitelist", "data": None}) + response = ws.receive_json() + + assert log_has_re(r"Request of type whitelist from.+", caplog) + assert response['type'] == "whitelist" + + # Test analyzed_df request + with client.websocket_connect(ws_url) as ws: + ws.send_json({"type": "analyzed_df", "data": {}}) + response = ws.receive_json() + + assert log_has_re(r"Request of type analyzed_df from.+", caplog) + assert response['type'] == "analyzed_df" + + caplog.clear() + # Test analyzed_df request with data + with client.websocket_connect(ws_url) as ws: + ws.send_json({"type": "analyzed_df", "data": {"limit": 100}}) + response = ws.receive_json() + + assert log_has_re(r"Request of type analyzed_df from.+", caplog) + assert response['type'] == "analyzed_df" + + +def test_api_ws_send_msg(default_conf, mocker, caplog): + try: + caplog.set_level(logging.DEBUG) + + default_conf.update({"api_server": {"enabled": True, + "listen_ip_address": "127.0.0.1", + "listen_port": 8080, + "CORS_origins": ['http://example.com'], + "username": _TEST_USER, + "password": _TEST_PASS, + "ws_token": _TEST_WS_TOKEN + }}) + mocker.patch('freqtrade.rpc.telegram.Updater') + mocker.patch('freqtrade.rpc.api_server.ApiServer.start_api') + apiserver = ApiServer(default_conf) + apiserver.add_rpc_handler(RPC(get_patched_freqtradebot(mocker, default_conf))) + apiserver.start_message_queue() + # Give the queue thread time to start + time.sleep(0.2) + + # Test message_queue coro receives the message + test_message = {"type": "status", "data": "test"} + apiserver.send_msg(test_message) + time.sleep(0.1) # Not sure how else to wait for the coro to receive the data + assert log_has("Found message of type: status", caplog) + + # Test if exception logged when error occurs in sending + mocker.patch('freqtrade.rpc.api_server.ws.channel.ChannelManager.broadcast', + side_effect=Exception) + + apiserver.send_msg(test_message) + time.sleep(0.1) # Not sure how else to wait for the coro to receive the data + assert log_has_re(r"Exception happened in background task.*", caplog) + + finally: + apiserver.cleanup() + ApiServer.shutdown() diff --git a/tests/rpc/test_rpc_emc.py b/tests/rpc/test_rpc_emc.py new file mode 100644 index 000000000..28adc66b9 --- /dev/null +++ b/tests/rpc/test_rpc_emc.py @@ -0,0 +1,467 @@ +""" +Unit test file for rpc/external_message_consumer.py +""" +import asyncio +import functools +import logging +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest +import websockets + +from freqtrade.data.dataprovider import DataProvider +from freqtrade.rpc.external_message_consumer import ExternalMessageConsumer +from tests.conftest import log_has, log_has_re, log_has_when + + +_TEST_WS_TOKEN = "secret_Ws_t0ken" +_TEST_WS_HOST = "127.0.0.1" +_TEST_WS_PORT = 9989 + + +@pytest.fixture +def patched_emc(default_conf, mocker): + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "host": "null", + "port": 9891, + "ws_token": _TEST_WS_TOKEN + } + ] + } + }) + dataprovider = DataProvider(default_conf, None, None, None) + emc = ExternalMessageConsumer(default_conf, dataprovider) + + try: + yield emc + finally: + emc.shutdown() + + +def test_emc_start(patched_emc, caplog): + # Test if the message was printed + assert log_has_when("Starting ExternalMessageConsumer", caplog, "setup") + # Test if the thread and loop objects were created + assert patched_emc._thread and patched_emc._loop + + # Test we call start again nothing happens + prev_thread = patched_emc._thread + patched_emc.start() + assert prev_thread == patched_emc._thread + + +def test_emc_shutdown(patched_emc, caplog): + patched_emc.shutdown() + + assert log_has("Stopping ExternalMessageConsumer", caplog) + # Test the loop has stopped + assert patched_emc._loop is None + # Test if the thread has stopped + assert patched_emc._thread is None + + caplog.clear() + patched_emc.shutdown() + + # Test func didn't run again as it was called once already + assert not log_has("Stopping ExternalMessageConsumer", caplog) + + +def test_emc_init(patched_emc): + # Test the settings were set correctly + assert patched_emc.initial_candle_limit <= 1500 + assert patched_emc.wait_timeout > 0 + assert patched_emc.sleep_time > 0 + + +# Parametrize this? +def test_emc_handle_producer_message(patched_emc, caplog, ohlcv_history): + test_producer = {"name": "test", "url": "ws://test", "ws_token": "test"} + producer_name = test_producer['name'] + + caplog.set_level(logging.DEBUG) + + # Test handle whitelist message + whitelist_message = {"type": "whitelist", "data": ["BTC/USDT"]} + patched_emc.handle_producer_message(test_producer, whitelist_message) + + assert log_has(f"Received message of type `whitelist` from `{producer_name}`", caplog) + assert log_has( + f"Consumed message from `{producer_name}` of type `RPCMessageType.WHITELIST`", caplog) + + # Test handle analyzed_df message + df_message = { + "type": "analyzed_df", + "data": { + "key": ("BTC/USDT", "5m", "spot"), + "df": ohlcv_history, + "la": datetime.now(timezone.utc) + } + } + patched_emc.handle_producer_message(test_producer, df_message) + + assert log_has(f"Received message of type `analyzed_df` from `{producer_name}`", caplog) + assert log_has( + f"Consumed message from `{producer_name}` of type `RPCMessageType.ANALYZED_DF`", caplog) + + # Test unhandled message + unhandled_message = {"type": "status", "data": "RUNNING"} + patched_emc.handle_producer_message(test_producer, unhandled_message) + + assert log_has_re(r"Received unhandled message\: .*", caplog) + + # Test malformed messages + caplog.clear() + malformed_message = {"type": "whitelist", "data": {"pair": "BTC/USDT"}} + patched_emc.handle_producer_message(test_producer, malformed_message) + + assert log_has_re(r"Invalid message .+", caplog) + + malformed_message = { + "type": "analyzed_df", + "data": { + "key": "BTC/USDT", + "df": ohlcv_history, + "la": datetime.now(timezone.utc) + } + } + patched_emc.handle_producer_message(test_producer, malformed_message) + + assert log_has(f"Received message of type `analyzed_df` from `{producer_name}`", caplog) + assert log_has_re(r"Invalid message .+", caplog) + + caplog.clear() + malformed_message = {"some": "stuff"} + patched_emc.handle_producer_message(test_producer, malformed_message) + + assert log_has_re(r"Invalid message .+", caplog) + + caplog.clear() + malformed_message = {"type": "whitelist", "data": None} + patched_emc.handle_producer_message(test_producer, malformed_message) + + assert log_has_re(r"Empty message .+", caplog) + + +async def test_emc_create_connection_success(default_conf, caplog, mocker): + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "host": _TEST_WS_HOST, + "port": _TEST_WS_PORT, + "ws_token": _TEST_WS_TOKEN + } + ], + "wait_timeout": 60, + "ping_timeout": 60, + "sleep_timeout": 60 + } + }) + + mocker.patch('freqtrade.rpc.external_message_consumer.ExternalMessageConsumer.start', + MagicMock()) + dp = DataProvider(default_conf, None, None, None) + emc = ExternalMessageConsumer(default_conf, dp) + + test_producer = default_conf['external_message_consumer']['producers'][0] + lock = asyncio.Lock() + + emc._running = True + + async def eat(websocket): + emc._running = False + + try: + async with websockets.serve(eat, _TEST_WS_HOST, _TEST_WS_PORT): + await emc._create_connection(test_producer, lock) + + assert log_has_re(r"Producer connection success.+", caplog) + finally: + emc.shutdown() + + +async def test_emc_create_connection_invalid_port(default_conf, caplog, mocker): + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "host": _TEST_WS_HOST, + "port": -1, + "ws_token": _TEST_WS_TOKEN + } + ], + "wait_timeout": 60, + "ping_timeout": 60, + "sleep_timeout": 60 + } + }) + + dp = DataProvider(default_conf, None, None, None) + emc = ExternalMessageConsumer(default_conf, dp) + + try: + await asyncio.sleep(0.01) + assert log_has_re(r".+ is an invalid WebSocket URL .+", caplog) + finally: + emc.shutdown() + + +async def test_emc_create_connection_invalid_host(default_conf, caplog, mocker): + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "host": "10000.1241..2121/", + "port": _TEST_WS_PORT, + "ws_token": _TEST_WS_TOKEN + } + ], + "wait_timeout": 60, + "ping_timeout": 60, + "sleep_timeout": 60 + } + }) + + dp = DataProvider(default_conf, None, None, None) + emc = ExternalMessageConsumer(default_conf, dp) + + try: + await asyncio.sleep(0.01) + assert log_has_re(r".+ is an invalid WebSocket URL .+", caplog) + finally: + emc.shutdown() + + +async def test_emc_create_connection_error(default_conf, caplog, mocker): + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "host": _TEST_WS_HOST, + "port": _TEST_WS_PORT, + "ws_token": _TEST_WS_TOKEN + } + ], + "wait_timeout": 60, + "ping_timeout": 60, + "sleep_timeout": 60 + } + }) + + # Test unexpected error + mocker.patch('websockets.connect', side_effect=RuntimeError) + + dp = DataProvider(default_conf, None, None, None) + emc = ExternalMessageConsumer(default_conf, dp) + + try: + await asyncio.sleep(0.01) + assert log_has("Unexpected error has occurred:", caplog) + finally: + emc.shutdown() + + +async def test_emc_receive_messages_valid(default_conf, caplog, mocker): + caplog.set_level(logging.DEBUG) + + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "host": _TEST_WS_HOST, + "port": _TEST_WS_PORT, + "ws_token": _TEST_WS_TOKEN + } + ], + "wait_timeout": 1, + "ping_timeout": 60, + "sleep_time": 60 + } + }) + + mocker.patch('freqtrade.rpc.external_message_consumer.ExternalMessageConsumer.start', + MagicMock()) + + lock = asyncio.Lock() + test_producer = default_conf['external_message_consumer']['producers'][0] + + dp = DataProvider(default_conf, None, None, None) + emc = ExternalMessageConsumer(default_conf, dp) + + loop = asyncio.get_event_loop() + def change_running(emc): emc._running = not emc._running + + class TestChannel: + async def recv(self, *args, **kwargs): + return {"type": "whitelist", "data": ["BTC/USDT"]} + + async def ping(self, *args, **kwargs): + return asyncio.Future() + + try: + change_running(emc) + loop.call_soon(functools.partial(change_running, emc=emc)) + await emc._receive_messages(TestChannel(), test_producer, lock) + + assert log_has_re(r"Received message of type `whitelist`.+", caplog) + finally: + emc.shutdown() + + +async def test_emc_receive_messages_invalid(default_conf, caplog, mocker): + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "host": _TEST_WS_HOST, + "port": _TEST_WS_PORT, + "ws_token": _TEST_WS_TOKEN + } + ], + "wait_timeout": 1, + "ping_timeout": 60, + "sleep_time": 60 + } + }) + + mocker.patch('freqtrade.rpc.external_message_consumer.ExternalMessageConsumer.start', + MagicMock()) + + lock = asyncio.Lock() + test_producer = default_conf['external_message_consumer']['producers'][0] + + dp = DataProvider(default_conf, None, None, None) + emc = ExternalMessageConsumer(default_conf, dp) + + loop = asyncio.get_event_loop() + def change_running(emc): emc._running = not emc._running + + class TestChannel: + async def recv(self, *args, **kwargs): + return {"type": ["BTC/USDT"]} + + async def ping(self, *args, **kwargs): + return asyncio.Future() + + try: + change_running(emc) + loop.call_soon(functools.partial(change_running, emc=emc)) + await emc._receive_messages(TestChannel(), test_producer, lock) + + assert log_has_re(r"Invalid message from.+", caplog) + finally: + emc.shutdown() + + +async def test_emc_receive_messages_timeout(default_conf, caplog, mocker): + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "host": _TEST_WS_HOST, + "port": _TEST_WS_PORT, + "ws_token": _TEST_WS_TOKEN + } + ], + "wait_timeout": 0.1, + "ping_timeout": 1, + "sleep_time": 1 + } + }) + + mocker.patch('freqtrade.rpc.external_message_consumer.ExternalMessageConsumer.start', + MagicMock()) + + lock = asyncio.Lock() + test_producer = default_conf['external_message_consumer']['producers'][0] + + dp = DataProvider(default_conf, None, None, None) + emc = ExternalMessageConsumer(default_conf, dp) + + loop = asyncio.get_event_loop() + def change_running(emc): emc._running = not emc._running + + class TestChannel: + async def recv(self, *args, **kwargs): + await asyncio.sleep(0.2) + + async def ping(self, *args, **kwargs): + return asyncio.Future() + + try: + change_running(emc) + loop.call_soon(functools.partial(change_running, emc=emc)) + await emc._receive_messages(TestChannel(), test_producer, lock) + + assert log_has_re(r"Ping error.+", caplog) + finally: + emc.shutdown() + + +async def test_emc_receive_messages_handle_error(default_conf, caplog, mocker): + default_conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "host": _TEST_WS_HOST, + "port": _TEST_WS_PORT, + "ws_token": _TEST_WS_TOKEN + } + ], + "wait_timeout": 1, + "ping_timeout": 1, + "sleep_time": 1 + } + }) + + mocker.patch('freqtrade.rpc.external_message_consumer.ExternalMessageConsumer.start', + MagicMock()) + + lock = asyncio.Lock() + test_producer = default_conf['external_message_consumer']['producers'][0] + + dp = DataProvider(default_conf, None, None, None) + emc = ExternalMessageConsumer(default_conf, dp) + + emc.handle_producer_message = MagicMock(side_effect=Exception) + + loop = asyncio.get_event_loop() + def change_running(emc): emc._running = not emc._running + + class TestChannel: + async def recv(self, *args, **kwargs): + return {"type": "whitelist", "data": ["BTC/USDT"]} + + async def ping(self, *args, **kwargs): + return asyncio.Future() + + try: + change_running(emc) + loop.call_soon(functools.partial(change_running, emc=emc)) + await emc._receive_messages(TestChannel(), test_producer, lock) + + assert log_has_re(r"Error handling producer message.+", caplog) + finally: + emc.shutdown() diff --git a/tests/rpc/test_rpc_webhook.py b/tests/rpc/test_rpc_webhook.py index 4d65b4966..3bbb85d54 100644 --- a/tests/rpc/test_rpc_webhook.py +++ b/tests/rpc/test_rpc_webhook.py @@ -365,6 +365,14 @@ def test_exception_send_msg(default_conf, mocker, caplog): with pytest.raises(NotImplementedError): webhook.send_msg(msg) + # Test no failure for not implemented but known messagetypes + for e in RPCMessageType: + msg = { + 'type': e, + 'status': 'whatever' + } + webhook.send_msg(msg) + def test__send_msg(default_conf, mocker, caplog): default_conf["webhook"] = get_webhook_dict() diff --git a/tests/strategy/test_interface.py b/tests/strategy/test_interface.py index 070e78b1d..294021c83 100644 --- a/tests/strategy/test_interface.py +++ b/tests/strategy/test_interface.py @@ -288,7 +288,7 @@ def test_advise_all_indicators(default_conf, testdatadir) -> None: data = load_data(testdatadir, '1m', ['UNITTEST/BTC'], timerange=timerange, fill_up_missing=True) processed = strategy.advise_all_indicators(data) - assert len(processed['UNITTEST/BTC']) == 102 # partial candle was removed + assert len(processed['UNITTEST/BTC']) == 103 def test_populate_any_indicators(default_conf, testdatadir) -> None: @@ -300,7 +300,7 @@ def test_populate_any_indicators(default_conf, testdatadir) -> None: processed = strategy.populate_any_indicators('UNITTEST/BTC', data, '5m') assert processed == data assert id(processed) == id(data) - assert len(processed['UNITTEST/BTC']) == 102 # partial candle was removed + assert len(processed['UNITTEST/BTC']) == 103 def test_freqai_not_initialized(default_conf) -> None: diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 2825ede5c..99edf0233 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -1089,6 +1089,58 @@ def test__validate_pricing_rules(default_conf, caplog) -> None: validate_config_consistency(conf) +def test__validate_consumers(default_conf, caplog) -> None: + conf = deepcopy(default_conf) + conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [] + } + }) + with pytest.raises(OperationalException, + match="You must specify at least 1 Producer to connect to."): + validate_config_consistency(conf) + + conf = deepcopy(default_conf) + conf.update({ + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "host": "127.0.0.1", + "port": 8081, + "ws_token": "secret_ws_t0ken." + }, { + "name": "default", + "host": "127.0.0.1", + "port": 8080, + "ws_token": "secret_ws_t0ken." + } + ]} + }) + with pytest.raises(OperationalException, + match="Producer names must be unique. Duplicate: default"): + validate_config_consistency(conf) + + conf = deepcopy(default_conf) + conf.update({ + "process_only_new_candles": True, + "external_message_consumer": { + "enabled": True, + "producers": [ + { + "name": "default", + "host": "127.0.0.1", + "port": 8081, + "ws_token": "secret_ws_t0ken." + } + ]} + }) + validate_config_consistency(conf) + assert log_has_re("To receive best performance with external data.*", caplog) + + def test_load_config_test_comments() -> None: """ Load config with comments diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 7851a73f8..0f1a05ab4 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -1319,9 +1319,9 @@ def test_create_stoploss_order_invalid_order( assert create_order_mock.call_args[1]['amount'] == trade.amount # Rpc is sending first buy, then sell - assert rpc_mock.call_count == 2 - assert rpc_mock.call_args_list[1][0][0]['sell_reason'] == ExitType.EMERGENCY_EXIT.value - assert rpc_mock.call_args_list[1][0][0]['order_type'] == 'market' + assert rpc_mock.call_count == 3 + assert rpc_mock.call_args_list[2][0][0]['sell_reason'] == ExitType.EMERGENCY_EXIT.value + assert rpc_mock.call_args_list[2][0][0]['order_type'] == 'market' @pytest.mark.parametrize("is_short", [False, True]) @@ -2439,7 +2439,7 @@ def test_manage_open_orders_entry_usercustom( # Trade should be closed since the function returns true freqtrade.manage_open_orders() assert cancel_order_wr_mock.call_count == 1 - assert rpc_mock.call_count == 1 + assert rpc_mock.call_count == 2 trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all() nb_trades = len(trades) assert nb_trades == 0 @@ -2478,7 +2478,7 @@ def test_manage_open_orders_entry( # check it does cancel buy orders over the time limit freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 1 - assert rpc_mock.call_count == 1 + assert rpc_mock.call_count == 2 trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all() nb_trades = len(trades) assert nb_trades == 0 @@ -2608,7 +2608,7 @@ def test_check_handle_cancelled_buy( # check it does cancel buy orders over the time limit freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 0 - assert rpc_mock.call_count == 1 + assert rpc_mock.call_count == 2 trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all() assert len(trades) == 0 assert log_has_re( @@ -2639,7 +2639,7 @@ def test_manage_open_orders_buy_exception( # check it does cancel buy orders over the time limit freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 0 - assert rpc_mock.call_count == 0 + assert rpc_mock.call_count == 1 trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all() nb_trades = len(trades) assert nb_trades == 1 @@ -2661,6 +2661,7 @@ def test_manage_open_orders_exit_usercustom( rpc_mock = patch_RPCManager(mocker) cancel_order_mock = MagicMock() patch_exchange(mocker) + mocker.patch('freqtrade.exchange.Exchange.get_min_pair_stake_amount', return_value=0.0) et_mock = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.execute_trade_exit') mocker.patch.multiple( 'freqtrade.exchange.Exchange', @@ -2673,7 +2674,6 @@ def test_manage_open_orders_exit_usercustom( open_trade_usdt.open_date = arrow.utcnow().shift(hours=-5).datetime open_trade_usdt.close_date = arrow.utcnow().shift(minutes=-601).datetime open_trade_usdt.close_profit_abs = 0.001 - open_trade_usdt.is_open = False Trade.query.session.add(open_trade_usdt) Trade.commit() @@ -2686,8 +2686,7 @@ def test_manage_open_orders_exit_usercustom( # Return false - No impact freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 0 - assert rpc_mock.call_count == 0 - assert open_trade_usdt.is_open is False + assert rpc_mock.call_count == 1 assert freqtrade.strategy.check_exit_timeout.call_count == 1 assert freqtrade.strategy.check_entry_timeout.call_count == 0 @@ -2696,8 +2695,7 @@ def test_manage_open_orders_exit_usercustom( # Return Error - No impact freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 0 - assert rpc_mock.call_count == 0 - assert open_trade_usdt.is_open is False + assert rpc_mock.call_count == 1 assert freqtrade.strategy.check_exit_timeout.call_count == 1 assert freqtrade.strategy.check_entry_timeout.call_count == 0 @@ -2706,8 +2704,7 @@ def test_manage_open_orders_exit_usercustom( freqtrade.strategy.check_entry_timeout = MagicMock(return_value=True) freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 1 - assert rpc_mock.call_count == 1 - assert open_trade_usdt.is_open is True + assert rpc_mock.call_count == 2 assert freqtrade.strategy.check_exit_timeout.call_count == 1 assert freqtrade.strategy.check_entry_timeout.call_count == 0 @@ -2748,14 +2745,14 @@ def test_manage_open_orders_exit( 'freqtrade.exchange.Exchange', fetch_ticker=ticker_usdt, fetch_order=MagicMock(return_value=limit_sell_order_old), - cancel_order=cancel_order_mock + cancel_order=cancel_order_mock, + get_min_pair_stake_amount=MagicMock(return_value=0), ) freqtrade = FreqtradeBot(default_conf_usdt) open_trade_usdt.open_date = arrow.utcnow().shift(hours=-5).datetime open_trade_usdt.close_date = arrow.utcnow().shift(minutes=-601).datetime open_trade_usdt.close_profit_abs = 0.001 - open_trade_usdt.is_open = False open_trade_usdt.is_short = is_short Trade.query.session.add(open_trade_usdt) @@ -2766,7 +2763,7 @@ def test_manage_open_orders_exit( # check it does cancel sell orders over the time limit freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 1 - assert rpc_mock.call_count == 1 + assert rpc_mock.call_count == 2 assert open_trade_usdt.is_open is True # Custom user sell-timeout is never called assert freqtrade.strategy.check_exit_timeout.call_count == 0 @@ -2796,7 +2793,6 @@ def test_check_handle_cancelled_exit( open_trade_usdt.open_date = arrow.utcnow().shift(hours=-5).datetime open_trade_usdt.close_date = arrow.utcnow().shift(minutes=-601).datetime - open_trade_usdt.is_open = False open_trade_usdt.is_short = is_short Trade.query.session.add(open_trade_usdt) @@ -2805,7 +2801,7 @@ def test_check_handle_cancelled_exit( # check it does cancel sell orders over the time limit freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 0 - assert rpc_mock.call_count == 1 + assert rpc_mock.call_count == 2 assert open_trade_usdt.is_open is True exit_name = 'Buy' if is_short else 'Sell' assert log_has_re(f"{exit_name} order cancelled on exchange for Trade.*", caplog) @@ -2843,7 +2839,7 @@ def test_manage_open_orders_partial( # note this is for a partially-complete buy order freqtrade.manage_open_orders() assert cancel_order_mock.call_count == 1 - assert rpc_mock.call_count == 2 + assert rpc_mock.call_count == 3 trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all() assert len(trades) == 1 assert trades[0].amount == 23.0 @@ -2890,7 +2886,7 @@ def test_manage_open_orders_partial_fee( assert log_has_re(r"Applying fee on amount for Trade.*", caplog) assert cancel_order_mock.call_count == 1 - assert rpc_mock.call_count == 2 + assert rpc_mock.call_count == 3 trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all() assert len(trades) == 1 # Verify that trade has been updated @@ -2940,7 +2936,7 @@ def test_manage_open_orders_partial_except( assert log_has_re(r"Could not update trade amount: .*", caplog) assert cancel_order_mock.call_count == 1 - assert rpc_mock.call_count == 2 + assert rpc_mock.call_count == 3 trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all() assert len(trades) == 1 # Verify that trade has been updated @@ -3004,6 +3000,7 @@ def test_handle_cancel_enter(mocker, caplog, default_conf_usdt, limit_order, is_ trade.open_rate = 200 trade.is_short = False trade.entry_side = "buy" + trade.amount = 100 l_order['filled'] = 0.0 l_order['status'] = 'open' trade.nr_of_successful_entries = 0 @@ -3092,6 +3089,7 @@ def test_handle_cancel_enter_corder_empty(mocker, default_conf_usdt, limit_order trade.entry_side = "buy" trade.open_order_id = "open_order_noop" trade.nr_of_successful_entries = 0 + trade.amount = 100 l_order['filled'] = 0.0 l_order['status'] = 'open' reason = CANCEL_REASON['TIMEOUT'] @@ -3121,20 +3119,21 @@ def test_handle_cancel_exit_limit(mocker, default_conf_usdt, fee) -> None: amount=2, exchange='binance', open_rate=0.245441, - open_order_id="123456", + open_order_id="sell_123456", open_date=arrow.utcnow().shift(days=-2).datetime, fee_open=fee.return_value, fee_close=fee.return_value, close_rate=0.555, close_date=arrow.utcnow().datetime, exit_reason="sell_reason_whatever", + stake_amount=0.245441 * 2, ) trade.orders = [ - Order( + Order( ft_order_side='buy', ft_pair=trade.pair, - ft_is_open=True, - order_id='123456', + ft_is_open=False, + order_id='buy_123456', status="closed", symbol=trade.pair, order_type="market", @@ -3147,12 +3146,30 @@ def test_handle_cancel_exit_limit(mocker, default_conf_usdt, fee) -> None: order_date=trade.open_date, order_filled_date=trade.open_date, ), + Order( + ft_order_side='sell', + ft_pair=trade.pair, + ft_is_open=True, + order_id='sell_123456', + status="open", + symbol=trade.pair, + order_type="limit", + side="sell", + price=trade.open_rate, + average=trade.open_rate, + filled=0.0, + remaining=trade.amount, + cost=trade.open_rate * trade.amount, + order_date=trade.open_date, + order_filled_date=trade.open_date, + ), ] - order = {'id': "123456", + order = {'id': "sell_123456", 'remaining': 1, 'amount': 1, 'status': "open"} reason = CANCEL_REASON['TIMEOUT'] + send_msg_mock.reset_mock() assert freqtrade.handle_cancel_exit(trade, order, reason) assert cancel_order_mock.call_count == 1 assert send_msg_mock.call_count == 1 @@ -3181,8 +3198,9 @@ def test_handle_cancel_exit_limit(mocker, default_conf_usdt, fee) -> None: def test_handle_cancel_exit_cancel_exception(mocker, default_conf_usdt) -> None: patch_RPCManager(mocker) patch_exchange(mocker) - mocker.patch( - 'freqtrade.exchange.Exchange.cancel_order_with_result', side_effect=InvalidOrderException()) + mocker.patch('freqtrade.exchange.Exchange.get_min_pair_stake_amount', return_value=0.0) + mocker.patch('freqtrade.exchange.Exchange.cancel_order_with_result', + side_effect=InvalidOrderException()) freqtrade = FreqtradeBot(default_conf_usdt) @@ -3592,7 +3610,7 @@ def test_execute_trade_exit_with_stoploss_on_exchange( trade.is_short = is_short assert trade assert cancel_order.call_count == 1 - assert rpc_mock.call_count == 3 + assert rpc_mock.call_count == 4 @pytest.mark.parametrize("is_short", [False, True]) @@ -3662,11 +3680,11 @@ def test_may_execute_trade_exit_after_stoploss_on_exchange_hit( assert trade.stoploss_order_id is None assert trade.is_open is False assert trade.exit_reason == ExitType.STOPLOSS_ON_EXCHANGE.value - assert rpc_mock.call_count == 3 - assert rpc_mock.call_args_list[0][0][0]['type'] == RPCMessageType.ENTRY - assert rpc_mock.call_args_list[0][0][0]['amount'] > 20 - assert rpc_mock.call_args_list[1][0][0]['type'] == RPCMessageType.ENTRY_FILL - assert rpc_mock.call_args_list[2][0][0]['type'] == RPCMessageType.EXIT_FILL + assert rpc_mock.call_count == 4 + assert rpc_mock.call_args_list[1][0][0]['type'] == RPCMessageType.ENTRY + assert rpc_mock.call_args_list[1][0][0]['amount'] > 20 + assert rpc_mock.call_args_list[2][0][0]['type'] == RPCMessageType.ENTRY_FILL + assert rpc_mock.call_args_list[3][0][0]['type'] == RPCMessageType.EXIT_FILL @pytest.mark.parametrize( diff --git a/tests/test_integration.py b/tests/test_integration.py index a7b4fbdd3..a848de5d3 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock import pytest -from freqtrade.enums import ExitCheckTuple, ExitType +from freqtrade.enums import ExitCheckTuple, ExitType, TradingMode from freqtrade.persistence import Trade from freqtrade.persistence.models import Order from freqtrade.rpc.rpc import RPC @@ -455,10 +455,12 @@ def test_dca_order_adjust(default_conf_usdt, ticker_usdt, fee, mocker) -> None: assert pytest.approx(trade.orders[-1].amount) == 61.538461232 -def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog) -> None: +@pytest.mark.parametrize('leverage', [1, 2]) +def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog, leverage) -> None: default_conf_usdt['position_adjustment_enable'] = True freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) + freqtrade.trading_mode = TradingMode.FUTURES mocker.patch.multiple( 'freqtrade.exchange.Exchange', fetch_ticker=ticker_usdt, @@ -467,15 +469,17 @@ def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog) -> Non price_to_precision=lambda s, x, y: y, get_min_pair_stake_amount=MagicMock(return_value=10), ) + mocker.patch("freqtrade.exchange.Exchange.get_max_leverage", return_value=10) patch_get_signal(freqtrade) + freqtrade.strategy.leverage = MagicMock(return_value=leverage) freqtrade.enter_positions() assert len(Trade.get_trades().all()) == 1 trade = Trade.get_trades().first() assert len(trade.orders) == 1 assert pytest.approx(trade.stake_amount) == 60 - assert pytest.approx(trade.amount) == 30.0 + assert pytest.approx(trade.amount) == 30.0 * leverage assert trade.open_rate == 2.0 # Too small size @@ -484,8 +488,9 @@ def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog) -> Non trade = Trade.get_trades().first() assert len(trade.orders) == 1 assert pytest.approx(trade.stake_amount) == 60 - assert pytest.approx(trade.amount) == 30.0 - assert log_has_re("Remaining amount of 1.6.* would be smaller than the minimum of 10.", caplog) + assert pytest.approx(trade.amount) == 30.0 * leverage + assert log_has_re( + r"Remaining amount of \d\.\d+.* would be smaller than the minimum of 10.", caplog) freqtrade.strategy.adjust_trade_position = MagicMock(return_value=-20) @@ -494,7 +499,7 @@ def test_dca_exiting(default_conf_usdt, ticker_usdt, fee, mocker, caplog) -> Non assert len(trade.orders) == 2 assert trade.orders[-1].ft_order_side == 'sell' assert pytest.approx(trade.stake_amount) == 40.198 - assert pytest.approx(trade.amount) == 20.099 + assert pytest.approx(trade.amount) == 20.099 * leverage assert trade.open_rate == 2.0 assert trade.is_open caplog.clear() diff --git a/tests/test_misc.py b/tests/test_misc.py index 4b52079bf..2da45bad9 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -7,10 +7,11 @@ from unittest.mock import MagicMock import pytest -from freqtrade.misc import (decimals_per_coin, deep_merge_dicts, file_dump_json, file_load_json, - format_ms_time, pair_to_filename, parse_db_uri_for_logging, plural, - render_template, render_template_with_fallback, round_coin_value, - safe_value_fallback, safe_value_fallback2, shorten_date) +from freqtrade.misc import (dataframe_to_json, decimals_per_coin, deep_merge_dicts, file_dump_json, + file_load_json, format_ms_time, json_to_dataframe, pair_to_filename, + parse_db_uri_for_logging, plural, render_template, + render_template_with_fallback, round_coin_value, safe_value_fallback, + safe_value_fallback2, shorten_date) def test_decimals_per_coin(): @@ -219,3 +220,14 @@ def test_deep_merge_dicts(): res2['first']['rows']['test'] = 'asdf' assert deep_merge_dicts(a, deepcopy(b), allow_null_overrides=False) == res2 + + +def test_dataframe_json(ohlcv_history): + from pandas.testing import assert_frame_equal + json = dataframe_to_json(ohlcv_history) + dataframe = json_to_dataframe(json) + + assert list(ohlcv_history.columns) == list(dataframe.columns) + assert len(ohlcv_history) == len(dataframe) + + assert_frame_equal(ohlcv_history, dataframe)