diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 6389a50df..748efc4d9 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,11 +1,12 @@ -FROM freqtradeorg/freqtrade:develop +FROM freqtradeorg/freqtrade:develop_freqairl USER root # Install dependencies COPY requirements-dev.txt /freqtrade/ RUN apt-get update \ - && apt-get -y install git mercurial sudo vim build-essential \ + && apt-get -y install --no-install-recommends apt-utils dialog \ + && apt-get -y install --no-install-recommends git sudo vim build-essential \ && apt-get clean \ && mkdir -p /home/ftuser/.vscode-server /home/ftuser/.vscode-server-insiders /home/ftuser/commandhistory \ && echo "export PROMPT_COMMAND='history -a'" >> /home/ftuser/.bashrc \ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8fb643e8f..08b8240b9 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -19,23 +19,24 @@ "postCreateCommand": "freqtrade create-userdir --userdir user_data/", "workspaceFolder": "/workspaces/freqtrade", - - "settings": { - "terminal.integrated.shell.linux": "/bin/bash", - "editor.insertSpaces": true, - "files.trimTrailingWhitespace": true, - "[markdown]": { - "files.trimTrailingWhitespace": false, + "customizations": { + "settings": { + "terminal.integrated.shell.linux": "/bin/bash", + "editor.insertSpaces": true, + "files.trimTrailingWhitespace": true, + "[markdown]": { + "files.trimTrailingWhitespace": false, + }, + "python.pythonPath": "/usr/local/bin/python", }, - "python.pythonPath": "/usr/local/bin/python", - }, - // Add the IDs of extensions you want installed when the container is created. - "extensions": [ - "ms-python.python", - "ms-python.vscode-pylance", - "davidanson.vscode-markdownlint", - "ms-azuretools.vscode-docker", - "vscode-icons-team.vscode-icons", - ], + // Add the IDs of extensions you want installed when the container is created. + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "davidanson.vscode-markdownlint", + "ms-azuretools.vscode-docker", + "vscode-icons-team.vscode-icons", + ], + } } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21d6f9ef4..8ceac4a7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ on: - cron: '0 5 * * 4' concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: "${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}" cancel-in-progress: true permissions: repository-projects: read @@ -57,7 +57,7 @@ jobs: - name: Installation - *nix if: runner.os == 'Linux' run: | - python -m pip install --upgrade pip==23.0.1 wheel + python -m pip install --upgrade pip wheel export LD_LIBRARY_PATH=${HOME}/dependencies/lib:$LD_LIBRARY_PATH export TA_LIBRARY_PATH=${HOME}/dependencies/lib export TA_INCLUDE_PATH=${HOME}/dependencies/include @@ -77,6 +77,17 @@ jobs: # Allow failure for coveralls coveralls || true + - name: Check for repository changes + run: | + if [ -n "$(git status --porcelain)" ]; then + echo "Repository is dirty, changes detected:" + git status + git diff + exit 1 + else + echo "Repository is clean, no changes detected." + fi + - name: Backtesting (multi) run: | cp config_examples/config_bittrex.example.json config.json @@ -125,6 +136,7 @@ jobs: uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} + check-latest: true - name: Cache_dependencies uses: actions/cache@v3 @@ -148,7 +160,8 @@ jobs: - name: Installation - macOS if: runner.os == 'macOS' run: | - brew update + # brew update + # TODO: Should be the brew upgrade # homebrew fails to update python due to unlinking failures # https://github.com/actions/runner-images/issues/6817 rm /usr/local/bin/2to3 || true @@ -163,7 +176,7 @@ jobs: rm /usr/local/bin/python3.11-config || true brew install hdf5 c-blosc - python -m pip install --upgrade pip==23.0.1 wheel + python -m pip install --upgrade pip wheel export LD_LIBRARY_PATH=${HOME}/dependencies/lib:$LD_LIBRARY_PATH export TA_LIBRARY_PATH=${HOME}/dependencies/lib export TA_INCLUDE_PATH=${HOME}/dependencies/include @@ -174,6 +187,17 @@ jobs: run: | pytest --random-order + - name: Check for repository changes + run: | + if [ -n "$(git status --porcelain)" ]; then + echo "Repository is dirty, changes detected:" + git status + git diff + exit 1 + else + echo "Repository is clean, no changes detected." + fi + - name: Backtesting run: | cp config_examples/config_bittrex.example.json config.json @@ -237,6 +261,18 @@ jobs: run: | pytest --random-order + - name: Check for repository changes + run: | + if (git status --porcelain) { + Write-Host "Repository is dirty, changes detected:" + git status + git diff + exit 1 + } + else { + Write-Host "Repository is clean, no changes detected." + } + - name: Backtesting run: | cp config_examples/config_bittrex.example.json config.json @@ -302,7 +338,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v4 with: - python-version: "3.10" + python-version: "3.11" - name: Documentation build run: | @@ -352,7 +388,7 @@ jobs: - name: Installation - *nix if: runner.os == 'Linux' run: | - python -m pip install --upgrade pip==23.0.1 wheel + python -m pip install --upgrade pip wheel export LD_LIBRARY_PATH=${HOME}/dependencies/lib:$LD_LIBRARY_PATH export TA_LIBRARY_PATH=${HOME}/dependencies/lib export TA_INCLUDE_PATH=${HOME}/dependencies/include @@ -425,7 +461,7 @@ jobs: python setup.py sdist bdist_wheel - name: Publish to PyPI (Test) - uses: pypa/gh-action-pypi-publish@v1.8.5 + uses: pypa/gh-action-pypi-publish@v1.8.7 if: (github.event_name == 'release') with: user: __token__ @@ -433,7 +469,7 @@ jobs: repository_url: https://test.pypi.org/legacy/ - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@v1.8.5 + uses: pypa/gh-action-pypi-publish@v1.8.7 if: (github.event_name == 'release') with: user: __token__ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 89370eacc..a9ca80c21 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,17 +8,17 @@ repos: # stages: [push] - repo: https://github.com/pre-commit/mirrors-mypy - rev: "v1.0.1" + rev: "v1.3.0" hooks: - id: mypy exclude: build_helpers additional_dependencies: - types-cachetools==5.3.0.5 - types-filelock==3.2.7 - - types-requests==2.28.11.17 + - types-requests==2.31.0.1 - types-tabulate==0.9.0.2 - - types-python-dateutil==2.8.19.12 - - SQLAlchemy==2.0.9 + - types-python-dateutil==2.8.19.13 + - SQLAlchemy==2.0.18 # stages: [push] - repo: https://github.com/pycqa/isort @@ -30,7 +30,7 @@ repos: - repo: https://github.com/charliermarsh/ruff-pre-commit # Ruff version. - rev: 'v0.0.255' + rev: 'v0.0.270' hooks: - id: ruff diff --git a/Dockerfile b/Dockerfile index 422caecaf..b5f6f5d5e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.10.11-slim-bullseye as base +FROM python:3.11.4-slim-bullseye as base # Setup env ENV LANG C.UTF-8 @@ -25,7 +25,7 @@ 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==23.0.1 + && pip install --upgrade pip wheel # Install TA-lib COPY build_helpers/* /tmp/ diff --git a/build_helpers/install_windows.ps1 b/build_helpers/install_windows.ps1 index cf6fbdf07..2fc21d317 100644 --- a/build_helpers/install_windows.ps1 +++ b/build_helpers/install_windows.ps1 @@ -1,7 +1,7 @@ # Downloads don't work automatically, since the URL is regenerated via javascript. # Downloaded from https://www.lfd.uci.edu/~gohlke/pythonlibs/#ta-lib -python -m pip install --upgrade pip==23.0.1 wheel +python -m pip install --upgrade pip wheel $pyv = python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" diff --git a/build_helpers/pyarrow-11.0.0-cp39-cp39-linux_armv7l.whl b/build_helpers/pyarrow-12.0.1-cp39-cp39-linux_armv7l.whl similarity index 59% rename from build_helpers/pyarrow-11.0.0-cp39-cp39-linux_armv7l.whl rename to build_helpers/pyarrow-12.0.1-cp39-cp39-linux_armv7l.whl index a7ad80bdf..55211ca01 100644 Binary files a/build_helpers/pyarrow-11.0.0-cp39-cp39-linux_armv7l.whl and b/build_helpers/pyarrow-12.0.1-cp39-cp39-linux_armv7l.whl differ diff --git a/docker-compose.yml b/docker-compose.yml index 445fbaea0..3b6f45bfc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,6 +6,15 @@ services: # image: freqtradeorg/freqtrade:develop # Use plotting image # image: freqtradeorg/freqtrade:develop_plot + # # Enable GPU Image and GPU Resources (only relevant for freqAI) + # # Make sure to uncomment the whole deploy section + # deploy: + # resources: + # reservations: + # devices: + # - driver: nvidia + # count: 1 + # capabilities: [gpu] # Build step - only needed when additional dependencies are needed # build: # context: . @@ -16,7 +25,7 @@ services: - "./user_data:/freqtrade/user_data" # Expose api on port 8080 (localhost only) # Please read the https://www.freqtrade.io/en/stable/rest-api/ documentation - # before enabling this. + # for more information. ports: - "127.0.0.1:8080:8080" # Default command used when running `docker compose up` diff --git a/docker/docker-compose-freqai.yml b/docker/docker-compose-freqai.yml new file mode 100644 index 000000000..6edf41238 --- /dev/null +++ b/docker/docker-compose-freqai.yml @@ -0,0 +1,36 @@ +--- +version: '3' +services: + freqtrade: + image: freqtradeorg/freqtrade:stable_freqaitorch + # # Enable GPU Image and GPU Resources + # # Make sure to uncomment the whole deploy section + # deploy: + # resources: + # reservations: + # devices: + # - driver: nvidia + # count: 1 + # capabilities: [gpu] + + # Build step - only needed when additional dependencies are needed + # build: + # context: . + # dockerfile: "./docker/Dockerfile.custom" + restart: unless-stopped + container_name: freqtrade + volumes: + - "./user_data:/freqtrade/user_data" + # Expose api on port 8080 (localhost only) + # Please read the https://www.freqtrade.io/en/stable/rest-api/ documentation + # for more information. + ports: + - "127.0.0.1:8080:8080" + # Default command used when running `docker compose up` + command: > + trade + --logfile /freqtrade/user_data/logs/freqtrade.log + --db-url sqlite:////freqtrade/user_data/tradesv3.sqlite + --config /freqtrade/user_data/config.json + --freqai-model XGBoostClassifier + --strategy SampleStrategy diff --git a/docs/advanced-backtesting.md b/docs/advanced-backtesting.md index be9099df8..b587c4157 100644 --- a/docs/advanced-backtesting.md +++ b/docs/advanced-backtesting.md @@ -29,7 +29,7 @@ If all goes well, you should now see a `backtest-result-{timestamp}_signals.pkl` `user_data/backtest_results` folder. To analyze the entry/exit tags, we now need to use the `freqtrade backtesting-analysis` command -with `--analysis-groups` option provided with space-separated arguments (default `0 1 2`): +with `--analysis-groups` option provided with space-separated arguments: ``` bash freqtrade backtesting-analysis -c --analysis-groups 0 1 2 3 4 5 @@ -39,6 +39,7 @@ This command will read from the last backtesting results. The `--analysis-groups used to specify the various tabular outputs showing the profit fo each group or trade, ranging from the simplest (0) to the most detailed per pair, per buy and per sell tag (4): +* 0: overall winrate and profit summary by enter_tag * 1: profit summaries grouped by enter_tag * 2: profit summaries grouped by enter_tag and exit_tag * 3: profit summaries grouped by pair and enter_tag @@ -115,3 +116,38 @@ For example, if your backtest timerange was `20220101-20221231` but you only wan ```bash freqtrade backtesting-analysis -c --timerange 20220101-20220201 ``` + +### Printing out rejected signals + +Use the `--rejected-signals` option to print out rejected signals. + +```bash +freqtrade backtesting-analysis -c --rejected-signals +``` + +### Writing tables to CSV + +Some of the tabular outputs can become large, so printing them out to the terminal is not preferable. +Use the `--analysis-to-csv` option to disable printing out of tables to standard out and write them to CSV files. + +```bash +freqtrade backtesting-analysis -c --analysis-to-csv +``` + +By default this will write one file per output table you specified in the `backtesting-analysis` command, e.g. + +```bash +freqtrade backtesting-analysis -c --analysis-to-csv --rejected-signals --analysis-groups 0 1 +``` + +This will write to `user_data/backtest_results`: + +* rejected_signals.csv +* group_0.csv +* group_1.csv + +To override where the files will be written, also specify the `--analysis-csv-path` option. + +```bash +freqtrade backtesting-analysis -c --analysis-to-csv --analysis-csv-path another/data/path/ +``` diff --git a/docs/advanced-hyperopt.md b/docs/advanced-hyperopt.md index ff0521f4f..eb8bf3f84 100644 --- a/docs/advanced-hyperopt.md +++ b/docs/advanced-hyperopt.md @@ -136,7 +136,7 @@ class MyAwesomeStrategy(IStrategy): ### Dynamic parameters -Parameters can also be defined dynamically, but must be available to the instance once the * [`bot_start()` callback](strategy-callbacks.md#bot-start) has been called. +Parameters can also be defined dynamically, but must be available to the instance once the [`bot_start()` callback](strategy-callbacks.md#bot-start) has been called. ``` python diff --git a/docs/configuration.md b/docs/configuration.md index 8a1aeb40e..6eb245706 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -138,7 +138,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `stake_currency` | **Required.** Crypto-currency used for trading.
**Datatype:** String | `stake_amount` | **Required.** Amount of crypto-currency your bot will use for each trade. Set it to `"unlimited"` to allow the bot to use all available balance. [More information below](#configuring-amount-per-trade).
**Datatype:** Positive float or `"unlimited"`. | `tradable_balance_ratio` | Ratio of the total account balance the bot is allowed to trade. [More information below](#configuring-amount-per-trade).
*Defaults to `0.99` 99%).*
**Datatype:** Positive float between `0.1` and `1.0`. -| `available_capital` | Available starting capital for the bot. Useful when running multiple bots on the same exchange account.[More information below](#configuring-amount-per-trade).
**Datatype:** Positive float. +| `available_capital` | Available starting capital for the bot. Useful when running multiple bots on the same exchange account. [More information below](#configuring-amount-per-trade).
**Datatype:** Positive float. | `amend_last_stake_amount` | Use reduced last stake amount if necessary. [More information below](#configuring-amount-per-trade).
*Defaults to `false`.*
**Datatype:** Boolean | `last_stake_amount_min_ratio` | Defines minimum stake amount that has to be left and executed. Applies only to the last stake amount when it's amended to a reduced value (i.e. if `amend_last_stake_amount` is set to `true`). [More information below](#configuring-amount-per-trade).
*Defaults to `0.5`.*
**Datatype:** Float (as ratio) | `amount_reserve_percent` | Reserve some amount in min pair stake amount. The bot will reserve `amount_reserve_percent` + stoploss value when calculating min pair stake amount in order to avoid possible trade refusals.
*Defaults to `0.05` (5%).*
**Datatype:** Positive Float as ratio. @@ -155,25 +155,25 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `trailing_stop_positive_offset` | Offset on when to apply `trailing_stop_positive`. Percentage value which should be positive. More details in the [stoploss documentation](stoploss.md#trailing-stop-loss-only-once-the-trade-has-reached-a-certain-offset). [Strategy Override](#parameters-in-the-strategy).
*Defaults to `0.0` (no offset).*
**Datatype:** Float | `trailing_only_offset_is_reached` | Only apply trailing stoploss when the offset is reached. [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-the-strategy).
*Defaults to `false`.*
**Datatype:** Boolean | `fee` | Fee used during backtesting / dry-runs. Should normally not be configured, which has freqtrade fall back to the exchange default fee. Set as ratio (e.g. 0.001 = 0.1%). Fee is applied twice for each trade, once when buying, once when selling.
**Datatype:** Float (as ratio) -| `futures_funding_rate` | User-specified funding rate to be used when historical funding rates are not available from the exchange. This does not overwrite real historical rates. It is recommended that this be set to 0 unless you are testing a specific coin and you understand how the funding rate will affect freqtrade's profit calculations. [More information here](leverage.md#unavailable-funding-rates)
*Defaults to None.*
**Datatype:** Float +| `futures_funding_rate` | User-specified funding rate to be used when historical funding rates are not available from the exchange. This does not overwrite real historical rates. It is recommended that this be set to 0 unless you are testing a specific coin and you understand how the funding rate will affect freqtrade's profit calculations. [More information here](leverage.md#unavailable-funding-rates)
*Defaults to `None`.*
**Datatype:** Float | `trading_mode` | Specifies if you want to trade regularly, trade with leverage, or trade contracts whose prices are derived from matching cryptocurrency prices. [leverage documentation](leverage.md).
*Defaults to `"spot"`.*
**Datatype:** String | `margin_mode` | When trading with leverage, this determines if the collateral owned by the trader will be shared or isolated to each trading pair [leverage documentation](leverage.md).
**Datatype:** String | `liquidation_buffer` | A ratio specifying how large of a safety net to place between the liquidation price and the stoploss to prevent a position from reaching the liquidation price [leverage documentation](leverage.md).
*Defaults to `0.05`.*
**Datatype:** Float | | **Unfilled timeout** | `unfilledtimeout.entry` | **Required.** How long (in minutes or seconds) the bot will wait for an unfilled entry order to complete, after which the order will be cancelled and repeated at current (new) price, as long as there is a signal. [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Integer | `unfilledtimeout.exit` | **Required.** How long (in minutes or seconds) the bot will wait for an unfilled exit order to complete, after which the order will be cancelled and repeated at current (new) price, as long as there is a signal. [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Integer -| `unfilledtimeout.unit` | Unit to use in unfilledtimeout setting. Note: If you set unfilledtimeout.unit to "seconds", "internals.process_throttle_secs" must be inferior or equal to timeout [Strategy Override](#parameters-in-the-strategy).
*Defaults to `minutes`.*
**Datatype:** String +| `unfilledtimeout.unit` | Unit to use in unfilledtimeout setting. Note: If you set unfilledtimeout.unit to "seconds", "internals.process_throttle_secs" must be inferior or equal to timeout [Strategy Override](#parameters-in-the-strategy).
*Defaults to `"minutes"`.*
**Datatype:** String | `unfilledtimeout.exit_timeout_count` | How many times can exit orders time out. Once this number of timeouts is reached, an emergency exit is triggered. 0 to disable and allow unlimited order cancels. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `0`.*
**Datatype:** Integer | | **Pricing** -| `entry_pricing.price_side` | Select the side of the spread the bot should look at to get the entry rate. [More information below](#buy-price-side).
*Defaults to `same`.*
**Datatype:** String (either `ask`, `bid`, `same` or `other`). +| `entry_pricing.price_side` | Select the side of the spread the bot should look at to get the entry rate. [More information below](#entry-price).
*Defaults to `"same"`.*
**Datatype:** String (either `ask`, `bid`, `same` or `other`). | `entry_pricing.price_last_balance` | **Required.** Interpolate the bidding price. More information [below](#entry-price-without-orderbook-enabled). -| `entry_pricing.use_order_book` | Enable entering using the rates in [Order Book Entry](#entry-price-with-orderbook-enabled).
*Defaults to `True`.*
**Datatype:** Boolean +| `entry_pricing.use_order_book` | Enable entering using the rates in [Order Book Entry](#entry-price-with-orderbook-enabled).
*Defaults to `true`.*
**Datatype:** Boolean | `entry_pricing.order_book_top` | Bot will use the top N rate in Order Book "price_side" to enter a trade. I.e. a value of 2 will allow the bot to pick the 2nd entry in [Order Book Entry](#entry-price-with-orderbook-enabled).
*Defaults to `1`.*
**Datatype:** Positive Integer | `entry_pricing. check_depth_of_market.enabled` | Do not enter if the difference of buy orders and sell orders is met in Order Book. [Check market depth](#check-depth-of-market).
*Defaults to `false`.*
**Datatype:** Boolean | `entry_pricing. check_depth_of_market.bids_to_ask_delta` | The difference ratio of buy orders and sell orders found in Order Book. A value below 1 means sell order size is greater, while value greater than 1 means buy order size is higher. [Check market depth](#check-depth-of-market)
*Defaults to `0`.*
**Datatype:** Float (as ratio) -| `exit_pricing.price_side` | Select the side of the spread the bot should look at to get the exit rate. [More information below](#exit-price-side).
*Defaults to `same`.*
**Datatype:** String (either `ask`, `bid`, `same` or `other`). +| `exit_pricing.price_side` | Select the side of the spread the bot should look at to get the exit rate. [More information below](#exit-price-side).
*Defaults to `"same"`.*
**Datatype:** String (either `ask`, `bid`, `same` or `other`). | `exit_pricing.price_last_balance` | Interpolate the exiting price. More information [below](#exit-price-without-orderbook-enabled). -| `exit_pricing.use_order_book` | Enable exiting of open trades using [Order Book Exit](#exit-price-with-orderbook-enabled).
*Defaults to `True`.*
**Datatype:** Boolean +| `exit_pricing.use_order_book` | Enable exiting of open trades using [Order Book Exit](#exit-price-with-orderbook-enabled).
*Defaults to `true`.*
**Datatype:** Boolean | `exit_pricing.order_book_top` | Bot will use the top N rate in Order Book "price_side" to exit. I.e. a value of 2 will allow the bot to pick the 2nd ask rate in [Order Book Exit](#exit-price-with-orderbook-enabled)
*Defaults to `1`.*
**Datatype:** Positive Integer | `custom_price_max_distance_ratio` | Configure maximum distance ratio between current and custom entry or exit price.
*Defaults to `0.02` 2%).*
**Datatype:** Positive float | | **TODO** @@ -199,10 +199,10 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `exchange.ccxt_sync_config` | Additional CCXT parameters passed to the regular (sync) ccxt instance. Parameters may differ from exchange to exchange and are documented in the [ccxt documentation](https://ccxt.readthedocs.io/en/latest/manual.html#instantiation)
**Datatype:** Dict | `exchange.ccxt_async_config` | Additional CCXT parameters passed to the async ccxt instance. Parameters may differ from exchange to exchange and are documented in the [ccxt documentation](https://ccxt.readthedocs.io/en/latest/manual.html#instantiation)
**Datatype:** Dict | `exchange.markets_refresh_interval` | The interval in minutes in which markets are reloaded.
*Defaults to `60` minutes.*
**Datatype:** Positive Integer -| `exchange.skip_pair_validation` | Skip pairlist validation on startup.
*Defaults to `false`
**Datatype:** Boolean -| `exchange.skip_open_order_update` | Skips open order updates on startup should the exchange cause problems. Only relevant in live conditions.
*Defaults to `false`
**Datatype:** Boolean +| `exchange.skip_pair_validation` | Skip pairlist validation on startup.
*Defaults to `false`*
**Datatype:** Boolean +| `exchange.skip_open_order_update` | Skips open order updates on startup should the exchange cause problems. Only relevant in live conditions.
*Defaults to `false`*
**Datatype:** Boolean | `exchange.unknown_fee_rate` | Fallback value to use when calculating trading fees. This can be useful for exchanges which have fees in non-tradable currencies. The value provided here will be multiplied with the "fee cost".
*Defaults to `None`
**Datatype:** float -| `exchange.log_responses` | Log relevant exchange responses. For debug mode only - use with care.
*Defaults to `false`
**Datatype:** Boolean +| `exchange.log_responses` | Log relevant exchange responses. For debug mode only - use with care.
*Defaults to `false`*
**Datatype:** Boolean | `experimental.block_bad_exchanges` | Block exchanges known to not work with freqtrade. Leave on default unless you want to test if that exchange works now.
*Defaults to `true`.*
**Datatype:** Boolean | | **Plugins** | `edge.*` | Please refer to [edge configuration document](edge.md) for detailed explanation of all possible configuration options. @@ -213,7 +213,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `telegram.token` | Your Telegram bot token. Only required if `telegram.enabled` is `true`.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `telegram.chat_id` | Your personal Telegram account id. Only required if `telegram.enabled` is `true`.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `telegram.balance_dust_level` | Dust-level (in stake currency) - currencies with a balance below this will not be shown by `/balance`.
**Datatype:** float -| `telegram.reload` | Allow "reload" buttons on telegram messages.
*Defaults to `True`.
**Datatype:** boolean +| `telegram.reload` | Allow "reload" buttons on telegram messages.
*Defaults to `true`.
**Datatype:** boolean | `telegram.notification_settings.*` | Detailed notification settings. Refer to the [telegram documentation](telegram-usage.md) for details.
**Datatype:** dictionary | `telegram.allow_custom_messages` | Enable the sending of Telegram messages from strategies via the dataprovider.send_msg() function.
**Datatype:** Boolean | | **Webhook** @@ -682,16 +682,14 @@ To use a proxy for exchange connections - you will have to define the proxies as { "exchange": { "ccxt_config": { - "aiohttp_proxy": "http://addr:port", - "proxies": { - "http": "http://addr:port", - "https": "http://addr:port" - }, + "httpsProxy": "http://addr:port", } } } ``` +For more information on available proxy types, please consult the [ccxt proxy documentation](https://docs.ccxt.com/#/README?id=proxy). + ## Next step Now you have configured your config.json, the next step is to [start your bot](bot-usage.md). diff --git a/docs/data-download.md b/docs/data-download.md index a7b1987aa..d45c7ef63 100644 --- a/docs/data-download.md +++ b/docs/data-download.md @@ -6,7 +6,7 @@ To download data (candles / OHLCV) needed for backtesting and hyperoptimization If no additional parameter is specified, freqtrade will download data for `"1m"` and `"5m"` timeframes for the last 30 days. Exchange and pairs will come from `config.json` (if specified using `-c/--config`). -Otherwise `--exchange` becomes mandatory. +Without provided configuration, `--exchange` becomes mandatory. You can use a relative timerange (`--days 20`) or an absolute starting point (`--timerange 20200101-`). For incremental downloads, the relative approach should be used. @@ -83,40 +83,47 @@ Common arguments: ``` +!!! Tip "Downloading all data for one quote currency" + Often, you'll want to download data for all pairs of a specific quote-currency. In such cases, you can use the following shorthand: + `freqtrade download-data --exchange binance --pairs .*/USDT <...>`. The provided "pairs" string will be expanded to contain all active pairs on the exchange. + To also download data for inactive (delisted) pairs, add `--include-inactive-pairs` to the command. + !!! Note "Startup period" `download-data` is a strategy-independent command. The idea is to download a big chunk of data once, and then iteratively increase the amount of data stored. For that reason, `download-data` does not care about the "startup-period" defined in a strategy. It's up to the user to download additional days if the backtest should start at a specific point in time (while respecting startup period). -### Pairs file +### Start download -In alternative to the whitelist from `config.json`, a `pairs.json` file can be used. -If you are using Binance for example: - -- create a directory `user_data/data/binance` and copy or create the `pairs.json` file in that directory. -- update the `pairs.json` file to contain the currency pairs you are interested in. +A very simple command (assuming an available `config.json` file) can look as follows. ```bash -mkdir -p user_data/data/binance -touch user_data/data/binance/pairs.json +freqtrade download-data --exchange binance ``` -The format of the `pairs.json` file is a simple json list. -Mixing different stake-currencies is allowed for this file, since it's only used for downloading. +This will download historical candle (OHLCV) data for all the currency pairs defined in the configuration. -``` json -[ - "ETH/BTC", - "ETH/USDT", - "BTC/USDT", - "XRP/ETH" -] +Alternatively, specify the pairs directly + +```bash +freqtrade download-data --exchange binance --pairs ETH/USDT XRP/USDT BTC/USDT ``` -!!! Tip "Downloading all data for one quote currency" - Often, you'll want to download data for all pairs of a specific quote-currency. In such cases, you can use the following shorthand: - `freqtrade download-data --exchange binance --pairs .*/USDT <...>`. The provided "pairs" string will be expanded to contain all active pairs on the exchange. - To also download data for inactive (delisted) pairs, add `--include-inactive-pairs` to the command. +or as regex (in this case, to download all active USDT pairs) + +```bash +freqtrade download-data --exchange binance --pairs .*/USDT +``` + +### Other Notes + +* To use a different directory than the exchange specific default, use `--datadir user_data/data/some_directory`. +* To change the exchange used to download the historical data from, please use a different configuration file (you'll probably need to adjust rate limits etc.) +* To use `pairs.json` from some other directory, use `--pairs-file some_other_dir/pairs.json`. +* To download historical candle (OHLCV) data for only 10 days, use `--days 10` (defaults to 30 days). +* To download historical candle (OHLCV) data from a fixed starting point, use `--timerange 20200101-` - which will download all data from January 1st, 2020. +* Use `--timeframes` to specify what timeframe download the historical candle (OHLCV) data for. Default is `--timeframes 1m 5m` which will download 1-minute and 5-minute data. +* To use exchange, timeframe and list of pairs as defined in your configuration file, use the `-c/--config` option. With this, the script uses the whitelist defined in the config as the list of currency pairs to download data for and does not require the pairs.json file. You can combine `-c/--config` with most other options. ??? Note "Permission denied errors" If your configuration directory `user_data` was made by docker, you may get the following error: @@ -131,39 +138,7 @@ Mixing different stake-currencies is allowed for this file, since it's only used sudo chown -R $UID:$GID user_data ``` -### Start download - -Then run: - -```bash -freqtrade download-data --exchange binance -``` - -This will download historical candle (OHLCV) data for all the currency pairs you defined in `pairs.json`. - -Alternatively, specify the pairs directly - -```bash -freqtrade download-data --exchange binance --pairs ETH/USDT XRP/USDT BTC/USDT -``` - -or as regex (to download all active USDT pairs) - -```bash -freqtrade download-data --exchange binance --pairs .*/USDT -``` - -### Other Notes - -- To use a different directory than the exchange specific default, use `--datadir user_data/data/some_directory`. -- To change the exchange used to download the historical data from, please use a different configuration file (you'll probably need to adjust rate limits etc.) -- To use `pairs.json` from some other directory, use `--pairs-file some_other_dir/pairs.json`. -- To download historical candle (OHLCV) data for only 10 days, use `--days 10` (defaults to 30 days). -- To download historical candle (OHLCV) data from a fixed starting point, use `--timerange 20200101-` - which will download all data from January 1st, 2020. -- Use `--timeframes` to specify what timeframe download the historical candle (OHLCV) data for. Default is `--timeframes 1m 5m` which will download 1-minute and 5-minute data. -- To use exchange, timeframe and list of pairs as defined in your configuration file, use the `-c/--config` option. With this, the script uses the whitelist defined in the config as the list of currency pairs to download data for and does not require the pairs.json file. You can combine `-c/--config` with most other options. - -#### Download additional data before the current timerange +### Download additional data before the current timerange Assuming you downloaded all data from 2022 (`--timerange 20220101-`) - but you'd now like to also backtest with earlier data. You can do so by using the `--prepend` flag, combined with `--timerange` - specifying an end-date. @@ -238,7 +213,36 @@ Size has been taken from the BTC/USDT 1m spot combination for the timerange spec To have a best performance/size mix, we recommend the use of either feather or parquet. -#### Sub-command convert data +### Pairs file + +In alternative to the whitelist from `config.json`, a `pairs.json` file can be used. +If you are using Binance for example: + +* create a directory `user_data/data/binance` and copy or create the `pairs.json` file in that directory. +* update the `pairs.json` file to contain the currency pairs you are interested in. + +```bash +mkdir -p user_data/data/binance +touch user_data/data/binance/pairs.json +``` + +The format of the `pairs.json` file is a simple json list. +Mixing different stake-currencies is allowed for this file, since it's only used for downloading. + +``` json +[ + "ETH/BTC", + "ETH/USDT", + "BTC/USDT", + "XRP/ETH" +] +``` + +!!! Note + The `pairs.json` file is only used when no configuration is loaded (implicitly by naming, or via `--config` flag). + You can force the usage of this file via `--pairs-file pairs.json` - however we recommend to use the pairlist from within the configuration, either via `exchange.pair_whitelist` or `pairs` setting in the configuration. + +## Sub-command convert data ``` usage: freqtrade convert-data [-h] [-v] [--logfile FILE] [-V] [-c PATH] @@ -290,7 +294,7 @@ Common arguments: ``` -##### Example converting data +### Example converting data The following command will convert all candle (OHLCV) data available in `~/.freqtrade/data/binance` from json to jsongz, saving diskspace in the process. It'll also remove original json data files (`--erase` parameter). @@ -299,7 +303,7 @@ It'll also remove original json data files (`--erase` parameter). freqtrade convert-data --format-from json --format-to jsongz --datadir ~/.freqtrade/data/binance -t 5m 15m --erase ``` -#### Sub-command convert trade data +## Sub-command convert trade data ``` usage: freqtrade convert-trade-data [-h] [-v] [--logfile FILE] [-V] [-c PATH] @@ -342,7 +346,7 @@ Common arguments: ``` -##### Example converting trades +### Example converting trades The following command will convert all available trade-data in `~/.freqtrade/data/kraken` from jsongz to json. It'll also remove original jsongz data files (`--erase` parameter). @@ -351,7 +355,7 @@ It'll also remove original jsongz data files (`--erase` parameter). freqtrade convert-trade-data --format-from jsongz --format-to json --datadir ~/.freqtrade/data/kraken --erase ``` -### Sub-command trades to ohlcv +## Sub-command trades to ohlcv When you need to use `--dl-trades` (kraken only) to download data, conversion of trades data to ohlcv data is the last step. This command will allow you to repeat this last step for additional timeframes without re-downloading the data. @@ -400,13 +404,13 @@ Common arguments: ``` -#### Example trade-to-ohlcv conversion +### Example trade-to-ohlcv conversion ``` bash freqtrade trades-to-ohlcv --exchange kraken -t 5m 1h 1d --pairs BTC/EUR ETH/EUR ``` -### Sub-command list-data +## Sub-command list-data You can get a list of downloaded data using the `list-data` sub-command. @@ -451,7 +455,7 @@ Common arguments: ``` -#### Example list-data +### Example list-data ```bash > freqtrade list-data --userdir ~/.freqtrade/user_data/ @@ -465,7 +469,7 @@ ETH/BTC 5m, 15m, 30m, 1h, 2h, 4h, 6h, 12h, 1d ETH/USDT 5m, 15m, 30m, 1h, 2h, 4h ``` -### Trades (tick) data +## Trades (tick) data By default, `download-data` sub-command downloads Candles (OHLCV) data. Some exchanges also provide historic trade-data via their API. This data can be useful if you need many different timeframes, since it is only downloaded once, and then resampled locally to the desired timeframes. diff --git a/docs/developer.md b/docs/developer.md index 1bc75551f..4784e5352 100644 --- a/docs/developer.md +++ b/docs/developer.md @@ -327,18 +327,18 @@ To check how the new exchange behaves, you can use the following snippet: ``` python import ccxt -from datetime import datetime +from datetime import datetime, timezone from freqtrade.data.converter import ohlcv_to_dataframe -ct = ccxt.binance() +ct = ccxt.binance() # Use the exchange you're testing timeframe = "1d" -pair = "XLM/BTC" # Make sure to use a pair that exists on that exchange! +pair = "BTC/USDT" # Make sure to use a pair that exists on that exchange! raw = ct.fetch_ohlcv(pair, timeframe=timeframe) # convert to dataframe df1 = ohlcv_to_dataframe(raw, timeframe, pair=pair, drop_incomplete=False) print(df1.tail(1)) -print(datetime.utcnow()) +print(datetime.now(timezone.utc)) ``` ``` output @@ -453,7 +453,13 @@ Once the PR against stable is merged (best right after merging): * Use the button "Draft a new release" in the Github UI (subsection releases). * Use the version-number specified as tag. * Use "stable" as reference (this step comes after the above PR is merged). -* Use the above changelog as release comment (as codeblock) +* Use the above changelog as release comment (as codeblock). +* Use the below snippet for the new release + +??? Tip "Release template" + ```` + --8<-- "includes/release_template.md" + ```` ## Releases diff --git a/docs/faq.md b/docs/faq.md index b52a77c6b..7b8cc2580 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -142,6 +142,13 @@ To fix this, redefine order types in the strategy to use "limit" instead of "mar The same fix should be applied in the configuration file, if order types are defined in your custom config rather than in the strategy. +### I'm trying to start the bot live, but get an API permission error + +Errors like `Invalid API-key, IP, or permissions for action` mean exactly what they actually say. +Your API key is either invalid (copy/paste error? check for leading/trailing spaces in the config), expired, or the IP you're running the bot from is not enabled in the Exchange's API console. +Usually, the permission "Spot Trading" (or the equivalent in the exchange you use) will be necessary. +Futures will usually have to be enabled specifically. + ### How do I search the bot logs for something? By default, the bot writes its log into stderr stream. This is implemented this way so that you can easily separate the bot's diagnostics messages from Backtesting, Edge and Hyperopt results, output from other various Freqtrade utility sub-commands, as well as from the output of your custom `print()`'s you may have inserted into your strategy. So if you need to search the log messages with the grep utility, you need to redirect stderr to stdout and disregard stdout. diff --git a/docs/freqai-configuration.md b/docs/freqai-configuration.md index 233edf2c5..090fa8415 100644 --- a/docs/freqai-configuration.md +++ b/docs/freqai-configuration.md @@ -43,16 +43,16 @@ The FreqAI strategy requires including the following lines of code in the standa def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: - # the model will return all labels created by user in `set_freqai_labels()` + # the model will return all labels created by user in `set_freqai_targets()` # (& 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 - # `feature_engineering_*` for each training period. + # `set_freqai_targets()` for each training period. dataframe = self.freqai.start(dataframe, metadata, self) return dataframe - def feature_engineering_expand_all(self, dataframe, period, **kwargs): + def feature_engineering_expand_all(self, dataframe: DataFrame, period, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* This function will automatically expand the defined features on the config defined @@ -77,7 +77,7 @@ The FreqAI strategy requires including the following lines of code in the standa return dataframe - def feature_engineering_expand_basic(self, dataframe, **kwargs): + def feature_engineering_expand_basic(self, dataframe: DataFrame, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* This function will automatically expand the defined features on the config defined @@ -101,7 +101,7 @@ The FreqAI strategy requires including the following lines of code in the standa dataframe["%-raw_price"] = dataframe["close"] return dataframe - def feature_engineering_standard(self, dataframe, **kwargs): + def feature_engineering_standard(self, dataframe: DataFrame, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* This optional function will be called once with the dataframe of the base timeframe. @@ -122,7 +122,7 @@ The FreqAI strategy requires including the following lines of code in the standa dataframe["%-hour_of_day"] = (dataframe["date"].dt.hour + 1) / 25 return dataframe - def set_freqai_targets(self, dataframe, **kwargs): + def set_freqai_targets(self, dataframe: DataFrame, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* Required function to set the targets for the model. @@ -139,6 +139,7 @@ The FreqAI strategy requires including the following lines of code in the standa / dataframe["close"] - 1 ) + return dataframe ``` Notice how the `feature_engineering_*()` is where [features](freqai-feature-engineering.md#feature-engineering) are added. Meanwhile `set_freqai_targets()` adds the labels/targets. A full example strategy is available in `templates/FreqaiExampleStrategy.py`. @@ -159,7 +160,7 @@ Below are the values you can expect to include/use inside a typical strategy dat |------------|-------------| | `df['&*']` | Any dataframe column prepended with `&` in `set_freqai_targets()` is treated as a training target (label) inside FreqAI (typically following the naming convention `&-s*`). For example, to predict the close price 40 candles into the future, you would set `df['&-s_close'] = df['close'].shift(-self.freqai_info["feature_parameters"]["label_period_candles"])` with `"label_period_candles": 40` in the config. 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 -2 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`. As with the SVM, if `use_DBSCAN_to_remove_outliers` is active, DBSCAN (see details [here](freqai-feature-engineering.md#identifying-outliers-with-dbscan)) may also detect outliers and subtract 1 from `do_predict`. Hence, if both the SVM and DBSCAN are active and identify a datapoint that was above the DI threshold as an outlier, the result will be `do_predict==-2`. A particular case is when `do_predict == 2`, which means that the model has expired due to exceeding `expired_hours`.
**Datatype:** Integer between -2 and 2. +| `df['do_predict']` | Indication of an outlier data point. The return value is integer between -2 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`. As with the SVM, if `use_DBSCAN_to_remove_outliers` is active, DBSCAN (see details [here](freqai-feature-engineering.md#identifying-outliers-with-dbscan)) may also detect outliers and subtract 1 from `do_predict`. Hence, if both the SVM and DBSCAN are active and identify a datapoint that was above the DI threshold as an outlier, the result will be `do_predict==-2`. A particular case is when `do_predict == 2`, which means that the model has expired due to exceeding `expired_hours`.
**Datatype:** Integer between -2 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 `feature_engineering_*()` 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 are easily engineered using the multiplictative functionality of, e.g., `include_shifted_candles` and `include_timeframes` as described in the [parameter table](freqai-parameter-table.md)), these features are removed from the dataframe that is returned from FreqAI to the strategy. To keep a particular type of feature for plotting purposes, you would prepend it with `%%`.
**Datatype:** Depends on the output of the model. @@ -247,9 +248,11 @@ The easiest way to quickly run a pytorch model is with the following command (fo freqtrade trade --config config_examples/config_freqai.example.json --strategy FreqaiExampleStrategy --freqaimodel PyTorchMLPRegressor --strategy-path freqtrade/templates ``` -!!! note "Installation/docker" +!!! Note "Installation/docker" The PyTorch module requires large packages such as `torch`, which should be explicitly requested during `./setup.sh -i` by answering "y" to the question "Do you also want dependencies for freqai-rl or PyTorch (~700mb additional space required) [y/N]?". Users who prefer docker should ensure they use the docker image appended with `_freqaitorch`. + We do provide an explicit docker-compose file for this in `docker/docker-compose-freqai.yml` - which can be used via `docker compose -f docker/docker-compose-freqai.yml run ...` - or can be copied to replace the original docker file. + This docker-compose file also contains a (disabled) section to enable GPU resources within docker containers. This obviously assumes the system has GPU resources available. ### Structure @@ -386,7 +389,7 @@ Here we create a `PyTorchMLPRegressor` class that implements the `fit` method. T For example, if you are using a binary classifier to predict price movements as up or down, you can set the class names as follows: ```python - def set_freqai_targets(self, dataframe: DataFrame, metadata: Dict, **kwargs): + def set_freqai_targets(self, dataframe: DataFrame, metadata: Dict, **kwargs) -> DataFrame: self.freqai.class_names = ["down", "up"] dataframe['&s-up_or_down'] = np.where(dataframe["close"].shift(-100) > dataframe["close"], 'up', 'down') @@ -394,3 +397,21 @@ Here we create a `PyTorchMLPRegressor` class that implements the `fit` method. T return dataframe ``` To see a full example, you can refer to the [classifier test strategy class](https://github.com/freqtrade/freqtrade/blob/develop/tests/strategy/strats/freqai_test_classifier.py). + + +#### Improving performance with `torch.compile()` + +Torch provides a `torch.compile()` method that can be used to improve performance for specific GPU hardware. More details can be found [here](https://pytorch.org/tutorials/intermediate/torch_compile_tutorial.html). In brief, you simply wrap your `model` in `torch.compile()`: + + +```python + model = PyTorchMLPModel( + input_dim=n_features, + output_dim=1, + **self.model_kwargs + ) + model.to(self.device) + model = torch.compile(model) +``` + +Then proceed to use the model as normal. Keep in mind that doing this will remove eager execution, which means errors and tracebacks will not be informative. diff --git a/docs/freqai-feature-engineering.md b/docs/freqai-feature-engineering.md index 05c6db523..daf645339 100644 --- a/docs/freqai-feature-engineering.md +++ b/docs/freqai-feature-engineering.md @@ -16,7 +16,7 @@ Meanwhile, high level feature engineering is handled within `"feature_parameters It is advisable to start from the template `feature_engineering_*` functions 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 feature_engineering_expand_all(self, dataframe, period, metadata, **kwargs): + def feature_engineering_expand_all(self, dataframe: DataFrame, period, metadata, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* This function will automatically expand the defined features on the config defined @@ -67,7 +67,7 @@ It is advisable to start from the template `feature_engineering_*` functions in return dataframe - def feature_engineering_expand_basic(self, dataframe, metadata, **kwargs): + def feature_engineering_expand_basic(self, dataframe: DataFrame, metadata, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* This function will automatically expand the defined features on the config defined @@ -96,7 +96,7 @@ It is advisable to start from the template `feature_engineering_*` functions in dataframe["%-raw_price"] = dataframe["close"] return dataframe - def feature_engineering_standard(self, dataframe, metadata, **kwargs): + def feature_engineering_standard(self, dataframe: DataFrame, metadata, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* This optional function will be called once with the dataframe of the base timeframe. @@ -122,7 +122,7 @@ It is advisable to start from the template `feature_engineering_*` functions in dataframe["%-hour_of_day"] = (dataframe["date"].dt.hour + 1) / 25 return dataframe - def set_freqai_targets(self, dataframe, metadata, **kwargs): + def set_freqai_targets(self, dataframe: DataFrame, metadata, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* Required function to set the targets for the model. @@ -180,16 +180,18 @@ You can ask for each of the defined features to be included also for informative In total, the number of features the user of the presented example strat has created is: length of `include_timeframes` * no. features in `feature_engineering_expand_*()` * length of `include_corr_pairlist` * no. `include_shifted_candles` * length of `indicator_periods_candles` $= 3 * 3 * 3 * 2 * 2 = 108$. - + + !!! note "Learn more about creative feature engineering" + Check out our [medium article](https://emergentmethods.medium.com/freqai-from-price-to-prediction-6fadac18b665) geared toward helping users learn how to creatively engineer features. ### Gain finer control over `feature_engineering_*` functions with `metadata` - All `feature_engineering_*` and `set_freqai_targets()` functions are passed a `metadata` dictionary which contains information about the `pair`, `tf` (timeframe), and `period` that FreqAI is automating for feature building. As such, a user can use `metadata` inside `feature_engineering_*` functions as criteria for blocking/reserving features for certain timeframes, periods, pairs etc. +All `feature_engineering_*` and `set_freqai_targets()` functions are passed a `metadata` dictionary which contains information about the `pair`, `tf` (timeframe), and `period` that FreqAI is automating for feature building. As such, a user can use `metadata` inside `feature_engineering_*` functions as criteria for blocking/reserving features for certain timeframes, periods, pairs etc. - ```python -def feature_engineering_expand_all(self, dataframe, period, metadata, **kwargs): - if metadata["tf"] == "1h": - dataframe["%-roc-period"] = ta.ROC(dataframe, timeperiod=period) +```python +def feature_engineering_expand_all(self, dataframe: DataFrame, period, metadata, **kwargs) -> DataFrame: + if metadata["tf"] == "1h": + dataframe["%-roc-period"] = ta.ROC(dataframe, timeperiod=period) ``` This will block `ta.ROC()` from being added to any timeframes other than `"1h"`. @@ -210,41 +212,7 @@ Another example, where the user wants to use live metrics from the trade databas 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 pre-set 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 the features of a data point are to the most recent historical 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 +### 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: @@ -254,13 +222,103 @@ where $W_i$ is the weight of data point $i$ in a total set of $n$ data points. B ![weight-factor](assets/freqai_weight-factor.jpg) +## Building the data pipeline + +By default, FreqAI builds a dynamic pipeline based on user congfiguration settings. The default settings are robust and designed to work with a variety of methods. These two steps are a `MinMaxScaler(-1,1)` and a `VarianceThreshold` which removes any column that has 0 variance. Users can activate other steps with more configuration parameters. For example if users add `use_SVM_to_remove_outliers: true` to the `freqai` config, then FreqAI will automatically add the [`SVMOutlierExtractor`](#identifying-outliers-using-a-support-vector-machine-svm) to the pipeline. Likewise, users can add `principal_component_analysis: true` to the `freqai` config to activate PCA. The [DissimilarityIndex](#identifying-outliers-with-the-dissimilarity-index-di) is activated with `DI_threshold: 1`. Finally, noise can also be added to the data with `noise_standard_deviation: 0.1`. Finally, users can add [DBSCAN](#identifying-outliers-with-dbscan) outlier removal with `use_DBSCAN_to_remove_outliers: true`. + +!!! note "More information available" + Please review the [parameter table](freqai-parameter-table.md) for more information on these parameters. + + +### Customizing the pipeline + +Users are encouraged to customize the data pipeline to their needs by building their own data pipeline. This can be done by simply setting `dk.feature_pipeline` to their desired `Pipeline` object inside their `IFreqaiModel` `train()` function, or if they prefer not to touch the `train()` function, they can override `define_data_pipeline`/`define_label_pipeline` functions in their `IFreqaiModel`: + +!!! note "More information available" + FreqAI uses the the [`DataSieve`](https://github.com/emergentmethods/datasieve) pipeline, which follows the SKlearn pipeline API, but adds, among other features, coherence between the X, y, and sample_weight vector point removals, feature removal, feature name following. + +```python +from datasieve.transforms import SKLearnWrapper, DissimilarityIndex +from datasieve.pipeline import Pipeline +from sklearn.preprocessing import QuantileTransformer, StandardScaler +from freqai.base_models import BaseRegressionModel + + +class MyFreqaiModel(BaseRegressionModel): + """ + Some cool custom model + """ + def fit(self, data_dictionary: Dict, dk: FreqaiDataKitchen, **kwargs) -> Any: + """ + My custom fit function + """ + model = cool_model.fit() + return model + + def define_data_pipeline(self) -> Pipeline: + """ + User defines their custom feature pipeline here (if they wish) + """ + feature_pipeline = Pipeline([ + ('qt', SKLearnWrapper(QuantileTransformer(output_distribution='normal'))), + ('di', ds.DissimilarityIndex(di_threshold=1) + ]) + + return feature_pipeline + + def define_label_pipeline(self) -> Pipeline: + """ + User defines their custom label pipeline here (if they wish) + """ + label_pipeline = Pipeline([ + ('qt', SKLearnWrapper(StandardScaler())), + ]) + + return label_pipeline +``` + +Here, you are defining the exact pipeline that will be used for your feature set during training and prediction. You can use *most* SKLearn transformation steps by wrapping them in the `SKLearnWrapper` class as shown above. In addition, you can use any of the transformations available in the [`DataSieve` library](https://github.com/emergentmethods/datasieve). + +You can easily add your own transformation by creating a class that inherits from the datasieve `BaseTransform` and implementing your `fit()`, `transform()` and `inverse_transform()` methods: + +```python +from datasieve.transforms.base_transform import BaseTransform +# import whatever else you need + +class MyCoolTransform(BaseTransform): + def __init__(self, **kwargs): + self.param1 = kwargs.get('param1', 1) + + def fit(self, X, y=None, sample_weight=None, feature_list=None, **kwargs): + # do something with X, y, sample_weight, or/and feature_list + return X, y, sample_weight, feature_list + + def transform(self, X, y=None, sample_weight=None, + feature_list=None, outlier_check=False, **kwargs): + # do something with X, y, sample_weight, or/and feature_list + return X, y, sample_weight, feature_list + + def inverse_transform(self, X, y=None, sample_weight=None, feature_list=None, **kwargs): + # do/dont do something with X, y, sample_weight, or/and feature_list + return X, y, sample_weight, feature_list +``` + +!!! note "Hint" + You can define this custom class in the same file as your `IFreqaiModel`. + +### Migrating a custom `IFreqaiModel` to the new Pipeline + +If you have created your own custom `IFreqaiModel` with a custom `train()`/`predict()` function, *and* you still rely on `data_cleaning_train/predict()`, then you will need to migrate to the new pipeline. If your model does *not* rely on `data_cleaning_train/predict()`, then you do not need to worry about this migration. + +More details about the migration can be found [here](strategy_migration.md#freqai---new-data-pipeline). + ## 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. +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: @@ -272,7 +330,7 @@ You can tell FreqAI to remove outlier data points from the training/test data se } ``` - 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: +Which will add `DissimilarityIndex` step to your `feature_pipeline` and set the threshold to 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} $$ @@ -306,9 +364,9 @@ You can tell FreqAI to remove outlier data points from the training/test data se } ``` -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. +Which will add `SVMOutlierExtractor` step to your `feature_pipeline`. 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`. +You can elect to provide additional parameters for the SVM, such as `shuffle`, and `nu` via the `feature_parameters.svm_params` dictionary in the config. 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. @@ -326,7 +384,7 @@ You can configure FreqAI to use DBSCAN to cluster and remove outliers from the t } ``` -DBSCAN is an unsupervised machine learning algorithm that clusters data without needing to know how many clusters there should be. +Which will add the `DataSieveDBSCAN` step to your `feature_pipeline`. This 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$. diff --git a/docs/freqai-parameter-table.md b/docs/freqai-parameter-table.md index 1487b92c2..cc92c2457 100644 --- a/docs/freqai-parameter-table.md +++ b/docs/freqai-parameter-table.md @@ -18,9 +18,10 @@ Mandatory parameters are marked as **Required** and have to be set in one of the | `purge_old_models` | Number of models to keep on disk (not relevant to backtesting). Default is 2, which means that dry/live runs will keep the latest 2 models on disk. Setting to 0 keeps all models. This parameter also accepts a boolean to maintain backwards compatibility.
**Datatype:** Integer.
Default: `2`. | `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. -| `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`. +| `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)). Beware that this is currently a naive approach to incremental learning, and it has a high probability of overfitting/getting stuck in local minima while the market moves away from your model. We have the connections here primarily for experimental purposes and so that it is ready for more mature approaches to continual learning in chaotic systems like the crypto market.
**Datatype:** Boolean.
Default: `False`. | `write_metrics_to_disk` | Collect train timings, inference timings and cpu usage in json file.
**Datatype:** Boolean.
Default: `False` | `data_kitchen_thread_count` |
Designate the number of threads you want to use for data processing (outlier methods, normalization, etc.). This has no impact on the number of threads used for training. If user does not set it (default), FreqAI will use max number of threads - 2 (leaving 1 physical core available for Freqtrade bot and FreqUI)
**Datatype:** Positive integer. +| `activate_tensorboard` |
Indicate whether or not to activate tensorboard for the tensorboard enabled modules (currently Reinforcment Learning, XGBoost, Catboost, and PyTorch). Tensorboard needs Torch installed, which means you will need the torch/RL docker image or you need to answer "yes" to the install question about whether or not you wish to install Torch.
**Datatype:** Boolean.
Default: `True`. ### Feature parameters @@ -114,5 +115,5 @@ Mandatory parameters are marked as **Required** and have to be set in one of the |------------|-------------| | | **Extraneous parameters** | `freqai.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`. -| `freqai.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`. +| `freqai.conv_width` | The width of a 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`. | `freqai.reduce_df_footprint` | Recast all numeric columns to float32/int32, with the objective of reducing ram/disk usage and decreasing train/inference timing. This parameter is set in the main level of the Freqtrade configuration file (not inside FreqAI).
**Datatype:** Boolean.
Default: `False`. diff --git a/docs/freqai-reinforcement-learning.md b/docs/freqai-reinforcement-learning.md index f298dbf4d..1c95409ae 100644 --- a/docs/freqai-reinforcement-learning.md +++ b/docs/freqai-reinforcement-learning.md @@ -37,7 +37,7 @@ freqtrade trade --freqaimodel ReinforcementLearner --strategy MyRLStrategy --con where `ReinforcementLearner` will use the templated `ReinforcementLearner` from `freqai/prediction_models/ReinforcementLearner` (or a custom user defined one located in `user_data/freqaimodels`). The strategy, on the other hand, follows the same base [feature engineering](freqai-feature-engineering.md) with `feature_engineering_*` as a typical Regressor. The difference lies in the creation of the targets, Reinforcement Learning doesn't require them. However, FreqAI requires a default (neutral) value to be set in the action column: ```python - def set_freqai_targets(self, dataframe, **kwargs): + def set_freqai_targets(self, dataframe, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* Required function to set the targets for the model. @@ -53,17 +53,19 @@ where `ReinforcementLearner` will use the templated `ReinforcementLearner` from # For RL, there are no direct targets to set. This is filler (neutral) # until the agent sends an action. dataframe["&-action"] = 0 + return dataframe ``` Most of the function remains the same as for typical Regressors, however, the function below shows how the strategy must pass the raw price data to the agent so that it has access to raw OHLCV in the training environment: ```python - def feature_engineering_standard(self, dataframe, **kwargs): + def feature_engineering_standard(self, dataframe: DataFrame, **kwargs) -> DataFrame: # The following features are necessary for RL models dataframe[f"%-raw_close"] = dataframe["close"] dataframe[f"%-raw_open"] = dataframe["open"] dataframe[f"%-raw_high"] = dataframe["high"] dataframe[f"%-raw_low"] = dataframe["low"] + return dataframe ``` Finally, there is no explicit "label" to make - instead it is necessary to assign the `&-action` column which will contain the agent's actions when accessed in `populate_entry/exit_trends()`. In the present example, the neutral action to 0. This value should align with the environment used. FreqAI provides two environments, both use 0 as the neutral action. @@ -133,92 +135,104 @@ Parameter details can be found [here](freqai-parameter-table.md), but in general ## Creating a custom reward function -As you begin to modify the strategy and the prediction model, you 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, you set the `calculate_reward()` function inside the `MyRLEnv` class (see below). A default `calculate_reward()` is provided inside `prediction_models/ReinforcementLearner.py` to demonstrate the necessary building blocks for creating rewards, but users are encouraged to create their own custom reinforcement learning model class (see below) and save it to `user_data/freqaimodels`. It is inside the `calculate_reward()` where creative theories about the market can be expressed. For example, you can reward your agent when it makes a winning trade, and penalize the agent when it makes a losing trade. Or perhaps, you wish to reward the agent for entering trades, and penalize the agent for sitting in trades too long. Below we show examples of how these rewards are all calculated: +!!! danger "Not for production" + Warning! + The reward function provided with the Freqtrade source code is a showcase of functionality designed to show/test as many possible environment control features as possible. It is also designed to run quickly on small computers. This is a benchmark, it is *not* for live production. Please beware that you will need to create your own custom_reward() function or use a template built by other users outside of the Freqtrade source code. + +As you begin to modify the strategy and the prediction model, you 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, you set the `calculate_reward()` function inside the `MyRLEnv` class (see below). A default `calculate_reward()` is provided inside `prediction_models/ReinforcementLearner.py` to demonstrate the necessary building blocks for creating rewards, but this is *not* designed for production. Users *must* create their own custom reinforcement learning model class or use a pre-built one from outside the Freqtrade source code and save it to `user_data/freqaimodels`. It is inside the `calculate_reward()` where creative theories about the market can be expressed. For example, you can reward your agent when it makes a winning trade, and penalize the agent when it makes a losing trade. Or perhaps, you wish to reward the agent for entering trades, and penalize the agent for sitting in trades too long. Below we show examples of how these rewards are all calculated: + +!!! note "Hint" + The best reward functions are ones that are continuously differentiable, and well scaled. In other words, adding a single large negative penalty to a rare event is not a good idea, and the neural net will not be able to learn that function. Instead, it is better to add a small negative penalty to a common event. This will help the agent learn faster. Not only this, but you can help improve the continuity of your rewards/penalties by having them scale with severity according to some linear/exponential functions. In other words, you'd slowly scale the penalty as the duration of the trade increases. This is better than a single large penalty occuring at a single point in time. ```python - from freqtrade.freqai.prediction_models.ReinforcementLearner import ReinforcementLearner - from freqtrade.freqai.RL.Base5ActionRLEnv import Actions, Base5ActionRLEnv, Positions +from freqtrade.freqai.prediction_models.ReinforcementLearner import ReinforcementLearner +from freqtrade.freqai.RL.Base5ActionRLEnv import Actions, Base5ActionRLEnv, Positions - class MyCoolRLModel(ReinforcementLearner): +class MyCoolRLModel(ReinforcementLearner): + """ + User created RL prediction model. + + Save this file to `freqtrade/user_data/freqaimodels` + + then use it with: + + freqtrade trade --freqaimodel MyCoolRLModel --config config.json --strategy SomeCoolStrat + + Here the users can override any of the functions + available in the `IFreqaiModel` inheritance tree. Most importantly for RL, this + is where the user overrides `MyRLEnv` (see below), to define custom + `calculate_reward()` function, or to override any other parts of the environment. + + This class also allows users to override any other part of the IFreqaiModel tree. + For example, the user can override `def fit()` or `def train()` or `def predict()` + to take fine-tuned control over these processes. + + Another common override may be `def data_cleaning_predict()` where the user can + take fine-tuned control over the data handling pipeline. + """ + class MyRLEnv(Base5ActionRLEnv): """ - User created RL prediction model. + 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. - Save this file to `freqtrade/user_data/freqaimodels` - - then use it with: - - freqtrade trade --freqaimodel MyCoolRLModel --config config.json --strategy SomeCoolStrat - - Here the users can override any of the functions - available in the `IFreqaiModel` inheritance tree. Most importantly for RL, this - is where the user overrides `MyRLEnv` (see below), to define custom - `calculate_reward()` function, or to override any other parts of the environment. - - This class also allows users to override any other part of the IFreqaiModel tree. - For example, the user can override `def fit()` or `def train()` or `def predict()` - to take fine-tuned control over these processes. - - Another common override may be `def data_cleaning_predict()` where the user can - take fine-tuned control over the data handling pipeline. + Warning! + This is function is a showcase of functionality designed to show as many possible + environment control features as possible. It is also designed to run quickly + on small computers. This is a benchmark, it is *not* for live production. """ - 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: int) -> float: - # first, penalize if the action is not valid - if not self._is_valid(action): - return -2 - pnl = self.get_unrealized_profit() + def calculate_reward(self, action: int) -> float: + # first, penalize if the action is not valid + if not self._is_valid(action): + return -2 + pnl = self.get_unrealized_profit() - factor = 100 + factor = 100 - pair = self.pair.replace(':', '') + pair = self.pair.replace(':', '') - # you can use feature values from dataframe - # Assumes the shifted RSI indicator has been generated in the strategy. - rsi_now = self.raw_features[f"%-rsi-period_10_shift-1_{pair}_" - f"{self.config['timeframe']}"].iloc[self._current_tick] + # you can use feature values from dataframe + # Assumes the shifted RSI indicator has been generated in the strategy. + rsi_now = self.raw_features[f"%-rsi-period_10_shift-1_{pair}_" + f"{self.config['timeframe']}"].iloc[self._current_tick] - # reward agent for entering trades - if (action in (Actions.Long_enter.value, Actions.Short_enter.value) - and self._position == Positions.Neutral): - if rsi_now < 40: - factor = 40 / rsi_now - else: - factor = 1 - return 25 * factor + # reward agent for entering trades + if (action in (Actions.Long_enter.value, Actions.Short_enter.value) + and self._position == Positions.Neutral): + if rsi_now < 40: + factor = 40 / rsi_now + else: + factor = 1 + return 25 * factor - # 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(pnl * 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(pnl * factor) - return 0. + # 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(pnl * 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(pnl * factor) + return 0. ``` -### Using Tensorboard +## 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. Tensorboard is activated via the following command: @@ -231,32 +245,30 @@ where `unique-id` is the `identifier` set in the `freqai` configuration file. Th ![tensorboard](assets/tensorboard.jpg) - -### Custom logging +## Custom logging FreqAI also provides a built in episodic summary logger called `self.tensorboard_log` for adding custom information to the Tensorboard log. By default, this function is already called once per step inside the environment to record the agent actions. All values accumulated for all steps in a single episode are reported at the conclusion of each episode, followed by a full reset of all metrics to 0 in preparation for the subsequent episode. - `self.tensorboard_log` can also be used anywhere inside the environment, for example, it can be added to the `calculate_reward` function to collect more detailed information about how often various parts of the reward were called: -```py - 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: int) -> float: - if not self._is_valid(action): - self.tensorboard_log("invalid") - return -2 +```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: int) -> float: + if not self._is_valid(action): + self.tensorboard_log("invalid") + return -2 ``` !!! Note The `self.tensorboard_log()` function is designed for tracking incremented objects only i.e. events, actions inside the training environment. If the event of interest is a float, the float can be passed as the second argument e.g. `self.tensorboard_log("float_metric1", 0.23)`. In this case the metric values are not incremented. -### Choosing a base environment +## Choosing a base environment FreqAI provides three base environments, `Base3ActionRLEnvironment`, `Base4ActionEnvironment` and `Base5ActionEnvironment`. As the names imply, the environments are customized for agents that can select from 3, 4 or 5 actions. The `Base3ActionEnvironment` is the simplest, the agent can select from hold, long, or short. This environment can also be used for long-only bots (it automatically follows the `can_short` flag from the strategy), where long is the enter condition and short is the exit condition. Meanwhile, in the `Base4ActionEnvironment`, the agent can enter long, enter short, hold neutral, or exit position. Finally, in the `Base5ActionEnvironment`, the agent has the same actions as Base4, but instead of a single exit action, it separates exit long and exit short. The main changes stemming from the environment selection include: diff --git a/docs/freqai-running.md b/docs/freqai-running.md index f3ccc546f..55f302d40 100644 --- a/docs/freqai-running.md +++ b/docs/freqai-running.md @@ -131,6 +131,9 @@ You can choose to adopt a continual learning scheme by setting `"continual_learn ???+ danger "Continual learning enforces a constant parameter space" Since `continual_learning` means that the model parameter space *cannot* change between trainings, `principal_component_analysis` is automatically disabled when `continual_learning` is enabled. Hint: PCA changes the parameter space and the number of features, learn more about PCA [here](freqai-feature-engineering.md#data-dimensionality-reduction-with-principal-component-analysis). +???+ danger "Experimental functionality" + Beware that this is currently a naive approach to incremental learning, and it has a high probability of overfitting/getting stuck in local minima while the market moves away from your model. We have the mechanics available in FreqAI primarily for experimental purposes and so that it is ready for more mature approaches to continual learning in chaotic systems like the crypto market. + ## Hyperopt You can hyperopt using the same command as for [typical Freqtrade hyperopt](hyperopt.md): @@ -158,7 +161,14 @@ This specific hyperopt would help you understand the appropriate `DI_values` for ## Using Tensorboard -CatBoost models benefit from tracking training metrics via Tensorboard. You can take advantage of the FreqAI integration to track training and evaluation performance across all coins and across all retrainings. Tensorboard is activated via the following command: +!!! note "Availability" + FreqAI includes tensorboard for a variety of models, including XGBoost, all PyTorch models, Reinforcement Learning, and Catboost. If you would like to see Tensorboard integrated into another model type, please open an issue on the [Freqtrade GitHub](https://github.com/freqtrade/freqtrade/issues) + +!!! danger "Requirements" + Tensorboard logging requires the FreqAI torch installation/docker image. + + +The easiest way to use tensorboard is to ensure `freqai.activate_tensorboard` is set to `True` (default setting) in your configuration file, run FreqAI, then open a separate shell and run: ```bash cd freqtrade @@ -168,3 +178,7 @@ tensorboard --logdir user_data/models/unique-id where `unique-id` is the `identifier` set in the `freqai` configuration file. This command must be run in a separate shell if you wish to view the output in your browser at 127.0.0.1:6060 (6060 is the default port used by Tensorboard). ![tensorboard](assets/tensorboard.jpg) + + +!!! note "Deactivate for improved performance" + Tensorboard logging can slow down training and should be deactivated for production use. diff --git a/docs/freqai.md b/docs/freqai.md index ef8efb840..820fb81f6 100644 --- a/docs/freqai.md +++ b/docs/freqai.md @@ -32,7 +32,10 @@ The easiest way to quickly test FreqAI is to run it in dry mode with the followi freqtrade trade --config config_examples/config_freqai.example.json --strategy FreqaiExampleStrategy --freqaimodel LightGBMRegressor --strategy-path freqtrade/templates ``` -You 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. + +!!! danger "Not for production" + The example strategy provided with the Freqtrade source code is designed for showcasing/testing a wide variety of FreqAI features. It is also designed to run on small computers so that it can be used as a benchmark between developers and users. It is *not* designed to be run in production. 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 @@ -69,15 +72,14 @@ pip install -r requirements-freqai.txt ``` !!! Note - Catboost will not be installed on arm devices (raspberry, Mac M1, ARM based VPS, ...), since it does not provide wheels for this platform. - -!!! Note "python 3.11" - Some dependencies (Catboost, Torch) currently don't support python 3.11. Freqtrade therefore only supports python 3.10 for these models/dependencies. - Tests involving these dependencies are skipped on 3.11. + Catboost will not be installed on low-powered arm devices (raspberry), since it does not provide wheels for this platform. ### Usage with docker -If you are using docker, a dedicated tag with FreqAI dependencies is available as `:freqai`. As such - you can replace the image line in your docker compose file with `image: freqtradeorg/freqtrade:develop_freqai`. This image contains the regular FreqAI dependencies. Similar to native installs, Catboost will not be available on ARM based devices. +If you are using docker, a dedicated tag with FreqAI dependencies is available as `:freqai`. As such - you can replace the image line in your docker compose file with `image: freqtradeorg/freqtrade:develop_freqai`. This image contains the regular FreqAI dependencies. Similar to native installs, Catboost will not be available on ARM based devices. If you would like to use PyTorch or Reinforcement learning, you should use the torch or RL tags, `image: freqtradeorg/freqtrade:develop_freqaitorch`, `image: freqtradeorg/freqtrade:develop_freqairl`. + +!!! note "docker-compose-freqai.yml" + We do provide an explicit docker-compose file for this in `docker/docker-compose-freqai.yml` - which can be used via `docker compose -f docker/docker-compose-freqai.yml run ...` - or can be copied to replace the original docker file. This docker-compose file also contains a (disabled) section to enable GPU resources within docker containers. This obviously assumes the system has GPU resources available. ### FreqAI position in open-source machine learning landscape @@ -105,6 +107,13 @@ This is for performance reasons - FreqAI relies on making quick predictions/retr 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). +## Additional learning materials + +Here we compile some external materials that provide deeper looks into various components of FreqAI: + +- [Real-time head-to-head: Adaptive modeling of financial market data using XGBoost and CatBoost](https://emergentmethods.medium.com/real-time-head-to-head-adaptive-modeling-of-financial-market-data-using-xgboost-and-catboost-995a115a7495) +- [FreqAI - from price to prediction](https://emergentmethods.medium.com/freqai-from-price-to-prediction-6fadac18b665) + ## Credits FreqAI is developed by a group of individuals who all contribute specific skillsets to the project. diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index 5fda038bd..1babeca1f 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -184,6 +184,8 @@ The RemotePairList is defined in the pairlists section of the configuration sett "pairlists": [ { "method": "RemotePairList", + "mode": "whitelist", + "processing_mode": "filter", "pairlist_url": "https://example.com/pairlist", "number_assets": 10, "refresh_period": 1800, @@ -194,6 +196,14 @@ The RemotePairList is defined in the pairlists section of the configuration sett ] ``` +The optional `mode` option specifies if the pairlist should be used as a `blacklist` or as a `whitelist`. The default value is "whitelist". + +The optional `processing_mode` option in the RemotePairList configuration determines how the retrieved pairlist is processed. It can have two values: "filter" or "append". + +In "filter" mode, the retrieved pairlist is used as a filter. Only the pairs present in both the original pairlist and the retrieved pairlist are included in the final pairlist. Other pairs are filtered out. + +In "append" mode, the retrieved pairlist is added to the original pairlist. All pairs from both lists are included in the final pairlist without any filtering. + The `pairlist_url` option specifies the URL of the remote server where the pairlist is located, or the path to a local file (if file:/// is prepended). This allows the user to use either a remote server or a local file as the source for the pairlist. The user is responsible for providing a server or local file that returns a JSON object with the following structure: diff --git a/docs/includes/release_template.md b/docs/includes/release_template.md new file mode 100644 index 000000000..87a3564da --- /dev/null +++ b/docs/includes/release_template.md @@ -0,0 +1,37 @@ +## Highlighted changes + +- ... + +### How to update + +As always, you can update your bot using one of the following commands: + +#### docker-compose + +```bash +docker-compose pull +docker-compose up -d +``` + +#### Installation via setup script + +``` +# Deactivate venv and run +./setup.sh --update +``` + +#### Plain native installation + +``` +git pull +pip install -U -r requirements.txt +``` + +
+Expand full changelog + +``` + +``` + +
diff --git a/docs/includes/showcase.md b/docs/includes/showcase.md new file mode 100644 index 000000000..789a91d47 --- /dev/null +++ b/docs/includes/showcase.md @@ -0,0 +1,11 @@ +This section will highlight a few projects from members of the community. +!!! Note + The projects below are for the most part not maintained by the freqtrade , therefore use your own caution before using them. + +- [Example freqtrade strategies](https://github.com/freqtrade/freqtrade-strategies/) +- [FrequentHippo - Grafana dashboard with dry/live runs and backtests](http://frequenthippo.ddns.net:3000/) (by hippocritical). +- [Online pairlist generator](http://pairlist.robot.co.network/) (by Blood4rc). +- [Freqtrade Backtesting Project](http://bt.robot.co.network/) (by Blood4rc). +- [Freqtrade analysis notebook](https://github.com/froggleston/freqtrade_analysis_notebook) (by Froggleston). +- [TUI for freqtrade](https://github.com/froggleston/freqtrade-frogtrade9000) (by Froggleston). +- [Bot Academy](https://botacademy.ddns.net/) (by stash86) - Blog about crypto bot projects. diff --git a/docs/index.md b/docs/index.md index c24d1f36b..77542ae78 100644 --- a/docs/index.md +++ b/docs/index.md @@ -63,6 +63,10 @@ Exchanges confirmed working by the community: - [X] [Bitvavo](https://bitvavo.com/) - [X] [Kucoin](https://www.kucoin.com/) +## Community showcase + +--8<-- "includes/showcase.md" + ## Requirements ### Hardware requirements diff --git a/docs/installation.md b/docs/installation.md index 11de20e83..a06968dba 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -30,12 +30,6 @@ The easiest way to install and run Freqtrade is to clone the bot Github reposito !!! Warning "Up-to-date clock" The clock on the system running the bot must be accurate, synchronized to a NTP server frequently enough to avoid problems with communication to the exchanges. -!!! Error "Running setup.py install for gym did not run successfully." - If you get an error related with gym we suggest you to downgrade setuptools it to version 65.5.0 you can do it with the following command: - ```bash - pip install setuptools==65.5.0 - ``` - ------ ## Requirements @@ -242,6 +236,7 @@ source .env/bin/activate ```bash python3 -m pip install --upgrade pip +python3 -m pip install -r requirements.txt python3 -m pip install -e . ``` diff --git a/docs/lookahead-analysis.md b/docs/lookahead-analysis.md new file mode 100644 index 000000000..9d57de779 --- /dev/null +++ b/docs/lookahead-analysis.md @@ -0,0 +1,100 @@ +# Lookahead analysis + +This page explains how to validate your strategy in terms of look ahead bias. + +Checking look ahead bias is the bane of any strategy since it is sometimes very easy to introduce backtest bias - +but very hard to detect. + +Backtesting initializes all timestamps at once and calculates all indicators in the beginning. +This means that if your indicators or entry/exit signals could look into future candles and falsify your backtest. + +Lookahead-analysis requires historic data to be available. +To learn how to get data for the pairs and exchange you're interested in, +head over to the [Data Downloading](data-download.md) section of the documentation. + +This command is built upon backtesting since it internally chains backtests and pokes at the strategy to provoke it to show look ahead bias. +This is done by not looking at the strategy itself - but at the results it returned. +The results are things like changed indicator-values and moved entries/exits compared to the full backtest. + +You can use commands of [Backtesting](backtesting.md). +It also supports the lookahead-analysis of freqai strategies. + +- `--cache` is forced to "none". +- `--max-open-trades` is forced to be at least equal to the number of pairs. +- `--dry-run-wallet` is forced to be basically infinite. + +## Lookahead-analysis command reference + +``` +usage: freqtrade lookahead-analysis [-h] [-v] [--logfile FILE] [-V] [-c PATH] + [-d PATH] [--userdir PATH] [-s NAME] + [--strategy-path PATH] + [--recursive-strategy-search] + [--freqaimodel NAME] + [--freqaimodel-path PATH] [-i TIMEFRAME] + [--timerange TIMERANGE] + [--data-format-ohlcv {json,jsongz,hdf5,feather,parquet}] + [--max-open-trades INT] + [--stake-amount STAKE_AMOUNT] + [--fee FLOAT] [-p PAIRS [PAIRS ...]] + [--enable-protections] + [--dry-run-wallet DRY_RUN_WALLET] + [--timeframe-detail TIMEFRAME_DETAIL] + [--strategy-list STRATEGY_LIST [STRATEGY_LIST ...]] + [--export {none,trades,signals}] + [--export-filename PATH] + [--breakdown {day,week,month} [{day,week,month} ...]] + [--cache {none,day,week,month}] + [--freqai-backtest-live-models] + [--minimum-trade-amount INT] + [--targeted-trade-amount INT] + [--lookahead-analysis-exportfilename LOOKAHEAD_ANALYSIS_EXPORTFILENAME] + +options: + --minimum-trade-amount INT + Minimum trade amount for lookahead-analysis + --targeted-trade-amount INT + Targeted trade amount for lookahead analysis + --lookahead-analysis-exportfilename LOOKAHEAD_ANALYSIS_EXPORTFILENAME + Use this csv-filename to store lookahead-analysis- + results +``` + +!!! Note "" + The above Output was reduced to options `lookahead-analysis` adds on top of regular backtesting commands. + +### Summary + +Checks a given strategy for look ahead bias via lookahead-analysis +Look ahead bias means that the backtest uses data from future candles thereby not making it viable beyond backtesting +and producing false hopes for the one backtesting. + +### Introduction + +Many strategies - without the programmer knowing - have fallen prey to look ahead bias. + +Any backtest will populate the full dataframe including all time stamps at the beginning. +If the programmer is not careful or oblivious how things work internally +(which sometimes can be really hard to find out) then it will just look into the future making the strategy amazing +but not realistic. + +This command is made to try to verify the validity in the form of the aforementioned look ahead bias. + +### How does the command work? + +It will start with a backtest of all pairs to generate a baseline for indicators and entries/exits. +After the backtest ran, it will look if the `minimum-trade-amount` is met +and if not cancel the lookahead-analysis for this strategy. + +After setting the baseline it will then do additional runs for every entry and exit separately. +When a verification-backtest is done, it will compare the indicators as the signal (either entry or exit) and report the bias. +After all signals have been verified or falsified a result-table will be generated for the user to see. + +### Caveats + +- `lookahead-analysis` can only verify / falsify the trades it calculated and verified. +If the strategy has many different signals / signal types, it's up to you to select appropriate parameters to ensure that all signals have triggered at least once. Not triggered signals will not have been verified. +This could lead to a false-negative (the strategy will then be reported as non-biased). +- `lookahead-analysis` has access to everything that backtesting has too. +Please don't provoke any configs like enabling position stacking. +If you decide to do so, then make doubly sure that you won't ever run out of `max_open_trades` amount and neither leftover money in your wallet. diff --git a/docs/producer-consumer.md b/docs/producer-consumer.md index c52279f26..0bd52ac93 100644 --- a/docs/producer-consumer.md +++ b/docs/producer-consumer.md @@ -49,7 +49,7 @@ Enable subscribing to an instance by adding the `external_message_consumer` sect | `wait_timeout` | Timeout until we ping again if no message is received.
*Defaults to `300`.*
**Datatype:** Integer - in seconds. | `ping_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 `False`.*
**Datatype:** Boolean. +| `remove_entry_exit_signals` | Remove signal columns from the dataframe (set them to 0) on dataframe receipt.
*Defaults to `false`.*
**Datatype:** Boolean. | `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. diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index 550c3b54c..9a784ba01 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,6 +1,6 @@ markdown==3.3.7 -mkdocs==1.4.2 -mkdocs-material==9.1.6 +mkdocs==1.4.3 +mkdocs-material==9.1.18 mdx_truly_sane_lists==1.3 -pymdown-extensions==9.11 +pymdown-extensions==10.0.1 jinja2==3.1.2 diff --git a/docs/rest-api.md b/docs/rest-api.md index 860a44499..5b33bfa6f 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -134,7 +134,9 @@ python3 scripts/rest_client.py --config rest_config.json [optional par | `reload_config` | Reloads the configuration file. | `trades` | List last trades. Limited to 500 trades per call. | `trade/` | Get specific trade. -| `delete_trade ` | Remove trade from the database. Tries to close open orders. Requires manual handling of this trade on the exchange. +| `trade/` | DELETE - Remove trade from the database. Tries to close open orders. Requires manual handling of this trade on the exchange. +| `trade//open-order` | DELETE - Cancel open order for this trade. +| `trade//reload` | GET - Reload a trade from the Exchange. Only works in live, and can potentially help recover a trade that was manually sold on the exchange. | `show_config` | Shows part of the current configuration with relevant settings to operation. | `logs` | Shows last log messages. | `status` | Lists all open trades. diff --git a/docs/stoploss.md b/docs/stoploss.md index d85902be0..8fc73be21 100644 --- a/docs/stoploss.md +++ b/docs/stoploss.md @@ -209,11 +209,6 @@ You can also keep a static stoploss until the offset is reached, and then trail If `trailing_only_offset_is_reached = True` then the trailing stoploss is only activated once the offset is reached. Until then, the stoploss remains at the configured `stoploss`. This option can be used with or without `trailing_stop_positive`, but uses `trailing_stop_positive_offset` as offset. -``` python - trailing_stop_positive_offset = 0.011 - trailing_only_offset_is_reached = True -``` - Configuration (offset is buy-price + 3%): ``` python diff --git a/docs/strategy-advanced.md b/docs/strategy-advanced.md index cbb71e810..2749d1281 100644 --- a/docs/strategy-advanced.md +++ b/docs/strategy-advanced.md @@ -1,21 +1,21 @@ # Advanced Strategies This page explains some advanced concepts available for strategies. -If you're just getting started, please be familiar with the methods described in the [Strategy Customization](strategy-customization.md) documentation and with the [Freqtrade basics](bot-basics.md) first. +If you're just getting started, please familiarize yourself with the [Freqtrade basics](bot-basics.md) and methods described in [Strategy Customization](strategy-customization.md) first. -[Freqtrade basics](bot-basics.md) describes in which sequence each method described below is called, which can be helpful to understand which method to use for your custom needs. +The call sequence of the methods described here is covered under [bot execution logic](bot-basics.md#bot-execution-logic). Those docs are also helpful in deciding which method is most suitable for your customisation needs. !!! Note - All callback methods described below should only be implemented in a strategy if they are actually used. + Callback methods should *only* be implemented if a strategy uses them. !!! Tip - You can get a strategy template containing all below methods by running `freqtrade new-strategy --strategy MyAwesomeStrategy --template advanced` + Start off with a strategy template containing all available callback methods by running `freqtrade new-strategy --strategy MyAwesomeStrategy --template advanced` ## Storing information Storing information can be accomplished by creating a new dictionary within the strategy class. -The name of the variable can be chosen at will, but should be prefixed with `cust_` to avoid naming collisions with predefined strategy variables. +The name of the variable can be chosen at will, but should be prefixed with `custom_` to avoid naming collisions with predefined strategy variables. ```python class AwesomeStrategy(IStrategy): @@ -227,8 +227,8 @@ for val in self.buy_ema_short.range: f'ema_short_{val}': ta.EMA(dataframe, timeperiod=val) })) -# Append columns to existing dataframe -merged_frame = pd.concat(frames, axis=1) +# Combine all dataframes, and reassign the original dataframe column +dataframe = pd.concat(frames, axis=1) ``` Freqtrade does however also counter this by running `dataframe.copy()` on the dataframe right after the `populate_indicators()` method - so performance implications of this should be low to non-existant. diff --git a/docs/strategy-callbacks.md b/docs/strategy-callbacks.md index 329908527..855f2353b 100644 --- a/docs/strategy-callbacks.md +++ b/docs/strategy-callbacks.md @@ -43,7 +43,7 @@ class AwesomeStrategy(IStrategy): if self.config['runmode'].value in ('live', 'dry_run'): # Assign this to the class by using self.* # can then be used by populate_* methods - self.cust_remote_data = requests.get('https://some_remote_source.example.com') + self.custom_remote_data = requests.get('https://some_remote_source.example.com') ``` @@ -352,7 +352,7 @@ class AwesomeStrategy(IStrategy): # Convert absolute price to percentage relative to current_rate if stoploss_price < current_rate: - return (stoploss_price / current_rate) - 1 + return stoploss_from_absolute(stoploss_price, current_rate, is_short=trade.is_short) # return maximum stoploss value, keeping current stoploss price unchanged return 1 diff --git a/docs/strategy-customization.md b/docs/strategy-customization.md index 8b6654c6c..8913d787b 100644 --- a/docs/strategy-customization.md +++ b/docs/strategy-customization.md @@ -342,16 +342,12 @@ The above configuration would therefore mean: The calculation does include fees. -To disable ROI completely, set it to an insanely high number: +To disable ROI completely, set it to an empty dictionary: ```python -minimal_roi = { - "0": 100 -} +minimal_roi = {} ``` -While technically not completely disabled, this would exit once the trade reaches 10000% Profit. - To use times based on candle duration (timeframe), the following snippet can be handy. This will allow you to change the timeframe for the strategy, and ROI times will still be set as candles (e.g. after 3 candles ...) diff --git a/docs/strategy_migration.md b/docs/strategy_migration.md index 22e3d2c22..d00349d1d 100644 --- a/docs/strategy_migration.md +++ b/docs/strategy_migration.md @@ -578,7 +578,7 @@ def populate_any_indicators( Features will now expand automatically. As such, the expansion loops, as well as the `{pair}` / `{timeframe}` parts will need to be removed. ``` python linenums="1" - def feature_engineering_expand_all(self, dataframe, period, **kwargs): + def feature_engineering_expand_all(self, dataframe, period, **kwargs) -> DataFrame:: """ *Only functional with FreqAI enabled strategies* This function will automatically expand the defined features on the config defined @@ -638,7 +638,7 @@ Features will now expand automatically. As such, the expansion loops, as well as Basic features. Make sure to remove the `{pair}` part from your features. ``` python linenums="1" - def feature_engineering_expand_basic(self, dataframe, **kwargs): + def feature_engineering_expand_basic(self, dataframe: DataFrame, **kwargs) -> DataFrame:: """ *Only functional with FreqAI enabled strategies* This function will automatically expand the defined features on the config defined @@ -673,7 +673,7 @@ Basic features. Make sure to remove the `{pair}` part from your features. ### FreqAI - feature engineering standard ``` python linenums="1" - def feature_engineering_standard(self, dataframe, **kwargs): + def feature_engineering_standard(self, dataframe: DataFrame, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* This optional function will be called once with the dataframe of the base timeframe. @@ -704,7 +704,7 @@ Basic features. Make sure to remove the `{pair}` part from your features. Targets now get their own, dedicated method. ``` python linenums="1" - def set_freqai_targets(self, dataframe, **kwargs): + def set_freqai_targets(self, dataframe: DataFrame, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* Required function to set the targets for the model. @@ -728,3 +728,86 @@ Targets now get their own, dedicated method. return dataframe ``` + + +### FreqAI - New data Pipeline + +If you have created your own custom `IFreqaiModel` with a custom `train()`/`predict()` function, *and* you still rely on `data_cleaning_train/predict()`, then you will need to migrate to the new pipeline. If your model does *not* rely on `data_cleaning_train/predict()`, then you do not need to worry about this migration. That means that this migration guide is relevant for a very small percentage of power-users. If you stumbled upon this guide by mistake, feel free to inquire in depth about your problem in the Freqtrade discord server. + +The conversion involves first removing `data_cleaning_train/predict()` and replacing them with a `define_data_pipeline()` and `define_label_pipeline()` function to your `IFreqaiModel` class: + +```python linenums="1" hl_lines="11-14 47-49 55-57" +class MyCoolFreqaiModel(BaseRegressionModel): + """ + Some cool custom IFreqaiModel you made before Freqtrade version 2023.6 + """ + def train( + self, unfiltered_df: DataFrame, pair: str, dk: FreqaiDataKitchen, **kwargs + ) -> Any: + + # ... your custom stuff + + # Remove these lines + # data_dictionary = dk.make_train_test_datasets(features_filtered, labels_filtered) + # self.data_cleaning_train(dk) + # data_dictionary = dk.normalize_data(data_dictionary) + # (1) + + # Add these lines. Now we control the pipeline fit/transform ourselves + dd = dk.make_train_test_datasets(features_filtered, labels_filtered) + dk.feature_pipeline = self.define_data_pipeline(threads=dk.thread_count) + dk.label_pipeline = self.define_label_pipeline(threads=dk.thread_count) + + (dd["train_features"], + dd["train_labels"], + dd["train_weights"]) = dk.feature_pipeline.fit_transform(dd["train_features"], + dd["train_labels"], + dd["train_weights"]) + + (dd["test_features"], + dd["test_labels"], + dd["test_weights"]) = dk.feature_pipeline.transform(dd["test_features"], + dd["test_labels"], + dd["test_weights"]) + + dd["train_labels"], _, _ = dk.label_pipeline.fit_transform(dd["train_labels"]) + dd["test_labels"], _, _ = dk.label_pipeline.transform(dd["test_labels"]) + + # ... your custom code + + return model + + def predict( + self, unfiltered_df: DataFrame, dk: FreqaiDataKitchen, **kwargs + ) -> Tuple[DataFrame, npt.NDArray[np.int_]]: + + # ... your custom stuff + + # Remove these lines: + # self.data_cleaning_predict(dk) + # (2) + + # Add these lines: + dk.data_dictionary["prediction_features"], outliers, _ = dk.feature_pipeline.transform( + dk.data_dictionary["prediction_features"], outlier_check=True) + + # Remove this line + # pred_df = dk.denormalize_labels_from_metadata(pred_df) + # (3) + + # Replace with these lines + pred_df, _, _ = dk.label_pipeline.inverse_transform(pred_df) + if self.freqai_info.get("DI_threshold", 0) > 0: + dk.DI_values = dk.feature_pipeline["di"].di_values + else: + dk.DI_values = np.zeros(outliers.shape[0]) + dk.do_predict = outliers + + # ... your custom code + return (pred_df, dk.do_predict) +``` + + +1. Data normalization and cleaning is now homogenized with the new pipeline definition. This is created in the new `define_data_pipeline()` and `define_label_pipeline()` functions. The `data_cleaning_train()` and `data_cleaning_predict()` functions are no longer used. You can override `define_data_pipeline()` to create your own custom pipeline if you wish. +2. Data normalization and cleaning is now homogenized with the new pipeline definition. This is created in the new `define_data_pipeline()` and `define_label_pipeline()` functions. The `data_cleaning_train()` and `data_cleaning_predict()` functions are no longer used. You can override `define_data_pipeline()` to create your own custom pipeline if you wish. +3. Data denormalization is done with the new pipeline. Replace this with the lines below. diff --git a/docs/telegram-usage.md b/docs/telegram-usage.md index fe990790a..1b36c60ad 100644 --- a/docs/telegram-usage.md +++ b/docs/telegram-usage.md @@ -187,11 +187,13 @@ official commands. You can ask at any moment for help with `/help`. | `/forcelong [rate]` | Instantly buys the given pair. Rate is optional and only applies to limit orders. (`force_entry_enable` must be set to True) | `/forceshort [rate]` | Instantly shorts the given pair. Rate is optional and only applies to limit orders. This will only work on non-spot markets. (`force_entry_enable` must be set to True) | `/delete ` | Delete a specific trade from the Database. Tries to close open orders. Requires manual handling of this trade on the exchange. +| `/reload_trade ` | Reload a trade from the Exchange. Only works in live, and can potentially help recover a trade that was manually sold on the exchange. | `/cancel_open_order | /coo ` | Cancel an open order for a trade. | **Metrics** | | `/profit []` | Display a summary of your profit/loss from close trades and some stats about your performance, over the last n days (all trades by default) | `/performance` | Show performance of each finished trade grouped by pair -| `/balance` | Show account balance per currency +| `/balance` | Show bot managed balance per currency +| `/balance full` | Show account balance per currency | `/daily ` | Shows profit or loss per day, over the last n days (n defaults to 7) | `/weekly ` | Shows profit or loss per week, over the last n weeks (n defaults to 8) | `/monthly ` | Shows profit or loss per month, over the last n months (n defaults to 6) @@ -202,7 +204,6 @@ official commands. You can ask at any moment for help with `/help`. | `/blacklist [pair]` | Show the current blacklist, or adds a pair to the blacklist. | `/edge` | Show validated pairs by Edge if it is enabled. - ## Telegram commands in action Below, example of Telegram message you will receive for each command. diff --git a/docs/utils.md b/docs/utils.md index eb675442f..900856af4 100644 --- a/docs/utils.md +++ b/docs/utils.md @@ -723,6 +723,9 @@ usage: freqtrade backtesting-analysis [-h] [-v] [--logfile FILE] [-V] [--exit-reason-list EXIT_REASON_LIST [EXIT_REASON_LIST ...]] [--indicator-list INDICATOR_LIST [INDICATOR_LIST ...]] [--timerange YYYYMMDD-[YYYYMMDD]] + [--rejected] + [--analysis-to-csv] + [--analysis-csv-path PATH] optional arguments: -h, --help show this help message and exit @@ -736,19 +739,27 @@ optional arguments: pair and enter_tag, 4: by pair, enter_ and exit_tag (this can get quite large) --enter-reason-list ENTER_REASON_LIST [ENTER_REASON_LIST ...] - Comma separated list of entry signals to analyse. - Default: all. e.g. 'entry_tag_a,entry_tag_b' + Space separated list of entry signals to analyse. + Default: all. e.g. 'entry_tag_a entry_tag_b' --exit-reason-list EXIT_REASON_LIST [EXIT_REASON_LIST ...] - Comma separated list of exit signals to analyse. + Space separated list of exit signals to analyse. Default: all. e.g. - 'exit_tag_a,roi,stop_loss,trailing_stop_loss' + 'exit_tag_a roi stop_loss trailing_stop_loss' --indicator-list INDICATOR_LIST [INDICATOR_LIST ...] - Comma separated list of indicators to analyse. e.g. - 'close,rsi,bb_lowerband,profit_abs' + Space separated list of indicators to analyse. e.g. + 'close rsi bb_lowerband profit_abs' --timerange YYYYMMDD-[YYYYMMDD] Timerange to filter trades for analysis, start inclusive, end exclusive. e.g. 20220101-20220201 + --rejected + Print out rejected trades table + --analysis-to-csv + Write out tables to individual CSVs, by default to + 'user_data/backtest_results' unless '--analysis-csv-path' is given. + --analysis-csv-path [PATH] + Optional path where individual CSVs will be written. If not used, + CSVs will be written to 'user_data/backtest_results'. Common arguments: -v, --verbose Verbose mode (-vv for more, -vvv to get all messages). diff --git a/environment.yml b/environment.yml deleted file mode 100644 index e69de29bb..000000000 diff --git a/freqtrade/__init__.py b/freqtrade/__init__.py index f8955b295..2c8dde56d 100644 --- a/freqtrade/__init__.py +++ b/freqtrade/__init__.py @@ -1,5 +1,5 @@ """ Freqtrade bot """ -__version__ = '2023.4.dev' +__version__ = '2023.7.dev' if 'dev' in __version__: from pathlib import Path diff --git a/freqtrade/commands/__init__.py b/freqtrade/commands/__init__.py index 66a9c995b..b9346fd5f 100644 --- a/freqtrade/commands/__init__.py +++ b/freqtrade/commands/__init__.py @@ -19,7 +19,8 @@ from freqtrade.commands.list_commands import (start_list_exchanges, start_list_f start_list_markets, start_list_strategies, start_list_timeframes, start_show_trades) from freqtrade.commands.optimize_commands import (start_backtesting, start_backtesting_show, - start_edge, start_hyperopt) + start_edge, start_hyperopt, + start_lookahead_analysis) from freqtrade.commands.pairlist_commands import start_test_pairlist from freqtrade.commands.plot_commands import start_plot_dataframe, start_plot_profit from freqtrade.commands.strategy_utils_commands import start_strategy_update diff --git a/freqtrade/commands/arguments.py b/freqtrade/commands/arguments.py old mode 100644 new mode 100755 index 47aa37fdf..5473e95e1 --- a/freqtrade/commands/arguments.py +++ b/freqtrade/commands/arguments.py @@ -46,7 +46,7 @@ ARGS_LIST_FREQAIMODELS = ["freqaimodel_path", "print_one_column", "print_coloriz ARGS_LIST_HYPEROPTS = ["hyperopt_path", "print_one_column", "print_colorized"] -ARGS_BACKTEST_SHOW = ["exportfilename", "backtest_show_pair_list"] +ARGS_BACKTEST_SHOW = ["exportfilename", "backtest_show_pair_list", "backtest_breakdown"] ARGS_LIST_EXCHANGES = ["print_one_column", "list_exchanges_all"] @@ -67,8 +67,7 @@ ARGS_BUILD_STRATEGY = ["user_data_dir", "strategy", "template"] ARGS_CONVERT_DATA = ["pairs", "format_from", "format_to", "erase", "exchange"] -ARGS_CONVERT_DATA_OHLCV = ARGS_CONVERT_DATA + ["timeframes", "trading_mode", - "candle_types"] +ARGS_CONVERT_DATA_OHLCV = ARGS_CONVERT_DATA + ["timeframes", "trading_mode", "candle_types"] ARGS_CONVERT_TRADES = ["pairs", "timeframes", "exchange", "dataformat_ohlcv", "dataformat_trades"] @@ -106,7 +105,8 @@ ARGS_HYPEROPT_SHOW = ["hyperopt_list_best", "hyperopt_list_profitable", "hyperop "disableparamexport", "backtest_breakdown"] ARGS_ANALYZE_ENTRIES_EXITS = ["exportfilename", "analysis_groups", "enter_reason_list", - "exit_reason_list", "indicator_list", "timerange"] + "exit_reason_list", "indicator_list", "timerange", + "analysis_rejected", "analysis_to_csv", "analysis_csv_path"] NO_CONF_REQURIED = ["convert-data", "convert-trade-data", "download-data", "list-timeframes", "list-markets", "list-pairs", "list-strategies", "list-freqaimodels", @@ -116,7 +116,11 @@ NO_CONF_REQURIED = ["convert-data", "convert-trade-data", "download-data", "list NO_CONF_ALLOWED = ["create-userdir", "list-exchanges", "new-strategy"] -ARGS_STRATEGY_UTILS = ["strategy_list", "strategy_path", "recursive_strategy_search"] +ARGS_STRATEGY_UPDATER = ["strategy_list", "strategy_path", "recursive_strategy_search"] + +ARGS_LOOKAHEAD_ANALYSIS = [ + a for a in ARGS_BACKTEST if a not in ("position_stacking", "use_max_market_positions", 'cache') + ] + ["minimum_trade_amount", "targeted_trade_amount", "lookahead_analysis_exportfilename"] class Arguments: @@ -200,8 +204,9 @@ class Arguments: start_install_ui, start_list_data, start_list_exchanges, start_list_freqAI_models, start_list_markets, start_list_strategies, start_list_timeframes, - start_new_config, start_new_strategy, start_plot_dataframe, - start_plot_profit, start_show_trades, start_strategy_update, + start_lookahead_analysis, start_new_config, + start_new_strategy, start_plot_dataframe, start_plot_profit, + start_show_trades, start_strategy_update, start_test_pairlist, start_trading, start_webserver) subparsers = self.parser.add_subparsers(dest='command', @@ -450,4 +455,15 @@ class Arguments: 'files to the current version', parents=[_common_parser]) strategy_updater_cmd.set_defaults(func=start_strategy_update) - self._build_args(optionlist=ARGS_STRATEGY_UTILS, parser=strategy_updater_cmd) + self._build_args(optionlist=ARGS_STRATEGY_UPDATER, parser=strategy_updater_cmd) + + # Add lookahead_analysis subcommand + lookahead_analayis_cmd = subparsers.add_parser( + 'lookahead-analysis', + help="Check for potential look ahead bias.", + parents=[_common_parser, _strategy_parser]) + + lookahead_analayis_cmd.set_defaults(func=start_lookahead_analysis) + + self._build_args(optionlist=ARGS_LOOKAHEAD_ANALYSIS, + parser=lookahead_analayis_cmd) diff --git a/freqtrade/commands/cli_options.py b/freqtrade/commands/cli_options.py old mode 100644 new mode 100755 index f1474ec69..aaf538835 --- a/freqtrade/commands/cli_options.py +++ b/freqtrade/commands/cli_options.py @@ -381,7 +381,7 @@ AVAILABLE_CLI_OPTIONS = { ), "candle_types": Arg( '--candle-types', - help='Select candle type to use', + help='Select candle type to convert. Defaults to all available types.', choices=[c.value for c in CandleType], nargs='+', ), @@ -450,14 +450,12 @@ AVAILABLE_CLI_OPTIONS = { ), "exchange": Arg( '--exchange', - help=f'Exchange name (default: `{constants.DEFAULT_EXCHANGE}`). ' - f'Only valid if no config is provided.', + help='Exchange name. Only valid if no config is provided.', ), "timeframes": Arg( '-t', '--timeframes', help='Specify which tickers to download. Space-separated list. ' 'Default: `1m 5m`.', - default=['1m', '5m'], nargs='+', ), "prepend_data": Arg( @@ -636,30 +634,45 @@ AVAILABLE_CLI_OPTIONS = { "4: by pair, enter_ and exit_tag (this can get quite large), " "5: by exit_tag"), nargs='+', - default=['0', '1', '2'], + default=[], choices=['0', '1', '2', '3', '4', '5'], ), "enter_reason_list": Arg( "--enter-reason-list", - help=("Comma separated list of entry signals to analyse. Default: all. " - "e.g. 'entry_tag_a,entry_tag_b'"), + help=("Space separated list of entry signals to analyse. Default: all. " + "e.g. 'entry_tag_a entry_tag_b'"), nargs='+', default=['all'], ), "exit_reason_list": Arg( "--exit-reason-list", - help=("Comma separated list of exit signals to analyse. Default: all. " - "e.g. 'exit_tag_a,roi,stop_loss,trailing_stop_loss'"), + help=("Space separated list of exit signals to analyse. Default: all. " + "e.g. 'exit_tag_a roi stop_loss trailing_stop_loss'"), nargs='+', default=['all'], ), "indicator_list": Arg( "--indicator-list", - help=("Comma separated list of indicators to analyse. " - "e.g. 'close,rsi,bb_lowerband,profit_abs'"), + help=("Space separated list of indicators to analyse. " + "e.g. 'close rsi bb_lowerband profit_abs'"), nargs='+', default=[], ), + "analysis_rejected": Arg( + '--rejected-signals', + help='Analyse rejected signals', + action='store_true', + ), + "analysis_to_csv": Arg( + '--analysis-to-csv', + help='Save selected analysis tables to individual CSVs', + action='store_true', + ), + "analysis_csv_path": Arg( + '--analysis-csv-path', + help=("Specify a path to save the analysis CSVs " + "if --analysis-to-csv is enabled. Default: user_data/basktesting_results/"), + ), "freqaimodel": Arg( '--freqaimodel', help='Specify a custom freqaimodels.', @@ -675,4 +688,21 @@ AVAILABLE_CLI_OPTIONS = { help='Run backtest with ready models.', action='store_true' ), + "minimum_trade_amount": Arg( + '--minimum-trade-amount', + help='Minimum trade amount for lookahead-analysis', + type=check_int_positive, + metavar='INT', + ), + "targeted_trade_amount": Arg( + '--targeted-trade-amount', + help='Targeted trade amount for lookahead analysis', + type=check_int_positive, + metavar='INT', + ), + "lookahead_analysis_exportfilename": Arg( + '--lookahead-analysis-exportfilename', + help="Use this csv-filename to store lookahead-analysis-results", + type=str + ), } diff --git a/freqtrade/commands/data_commands.py b/freqtrade/commands/data_commands.py index bcef1c252..fdfef6316 100644 --- a/freqtrade/commands/data_commands.py +++ b/freqtrade/commands/data_commands.py @@ -1,18 +1,16 @@ import logging import sys from collections import defaultdict -from datetime import datetime, timedelta -from typing import Any, Dict, List +from typing import Any, Dict from freqtrade.configuration import TimeRange, setup_utils_configuration -from freqtrade.constants import DATETIME_PRINT_FORMAT, Config +from freqtrade.constants import DATETIME_PRINT_FORMAT, DL_DATA_TIMEFRAMES, Config from freqtrade.data.converter import convert_ohlcv_format, convert_trades_format -from freqtrade.data.history import (convert_trades_to_ohlcv, refresh_backtest_ohlcv_data, - refresh_backtest_trades_data) -from freqtrade.enums import CandleType, RunMode, TradingMode +from freqtrade.data.history import convert_trades_to_ohlcv, download_data_main +from freqtrade.enums import RunMode, TradingMode from freqtrade.exceptions import OperationalException -from freqtrade.exchange import market_is_active, timeframe_to_minutes -from freqtrade.plugins.pairlist.pairlist_helpers import dynamic_expand_pairlist, expand_pairlist +from freqtrade.exchange import timeframe_to_minutes +from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist from freqtrade.resolvers import ExchangeResolver from freqtrade.util.binance_mig import migrate_binance_futures_data @@ -20,7 +18,7 @@ from freqtrade.util.binance_mig import migrate_binance_futures_data logger = logging.getLogger(__name__) -def _data_download_sanity(config: Config) -> None: +def _check_data_config_download_sanity(config: Config) -> None: if 'days' in config and 'timerange' in config: raise OperationalException("--days and --timerange are mutually exclusive. " "You can only specify one or the other.") @@ -37,78 +35,14 @@ def start_download_data(args: Dict[str, Any]) -> None: """ config = setup_utils_configuration(args, RunMode.UTIL_EXCHANGE) - _data_download_sanity(config) - timerange = TimeRange() - if 'days' in config: - time_since = (datetime.now() - timedelta(days=config['days'])).strftime("%Y%m%d") - timerange = TimeRange.parse_timerange(f'{time_since}-') - - if 'timerange' in config: - timerange = timerange.parse_timerange(config['timerange']) - - # Remove stake-currency to skip checks which are not relevant for datadownload - config['stake_currency'] = '' - - pairs_not_available: List[str] = [] - - # Init exchange - exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config, validate=False) - markets = [p for p, m in exchange.markets.items() if market_is_active(m) - or config.get('include_inactive')] - - expanded_pairs = dynamic_expand_pairlist(config, markets) - - # Manual validations of relevant settings - if not config['exchange'].get('skip_pair_validation', False): - exchange.validate_pairs(expanded_pairs) - logger.info(f"About to download pairs: {expanded_pairs}, " - f"intervals: {config['timeframes']} to {config['datadir']}") - - for timeframe in config['timeframes']: - exchange.validate_timeframes(timeframe) + _check_data_config_download_sanity(config) try: - - if config.get('download_trades'): - if config.get('trading_mode') == 'futures': - raise OperationalException("Trade download not supported for futures.") - pairs_not_available = refresh_backtest_trades_data( - exchange, pairs=expanded_pairs, datadir=config['datadir'], - timerange=timerange, new_pairs_days=config['new_pairs_days'], - erase=bool(config.get('erase')), data_format=config['dataformat_trades']) - - # Convert downloaded trade data to different timeframes - convert_trades_to_ohlcv( - pairs=expanded_pairs, timeframes=config['timeframes'], - datadir=config['datadir'], timerange=timerange, erase=bool(config.get('erase')), - data_format_ohlcv=config['dataformat_ohlcv'], - data_format_trades=config['dataformat_trades'], - ) - else: - if not exchange.get_option('ohlcv_has_history', True): - raise OperationalException( - f"Historic klines not available for {exchange.name}. " - "Please use `--dl-trades` instead for this exchange " - "(will unfortunately take a long time)." - ) - migrate_binance_futures_data(config) - pairs_not_available = refresh_backtest_ohlcv_data( - exchange, pairs=expanded_pairs, timeframes=config['timeframes'], - datadir=config['datadir'], timerange=timerange, - new_pairs_days=config['new_pairs_days'], - erase=bool(config.get('erase')), data_format=config['dataformat_ohlcv'], - trading_mode=config.get('trading_mode', 'spot'), - prepend=config.get('prepend_data', False) - ) + download_data_main(config) except KeyboardInterrupt: sys.exit("SIGINT received, aborting ...") - finally: - if pairs_not_available: - logger.info(f"Pairs [{','.join(pairs_not_available)}] not available " - f"on exchange {exchange.name}.") - def start_convert_trades(args: Dict[str, Any]) -> None: @@ -123,9 +57,11 @@ def start_convert_trades(args: Dict[str, Any]) -> None: raise OperationalException( "Downloading data requires a list of pairs. " "Please check the documentation on how to configure this.") + if 'timeframes' not in config: + config['timeframes'] = DL_DATA_TIMEFRAMES # Init exchange - exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config, validate=False) + exchange = ExchangeResolver.load_exchange(config, validate=False) # Manual validations of relevant settings if not config['exchange'].get('skip_pair_validation', False): exchange.validate_pairs(config['pairs']) @@ -152,11 +88,10 @@ def start_convert_data(args: Dict[str, Any], ohlcv: bool = True) -> None: config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE) if ohlcv: migrate_binance_futures_data(config) - candle_types = [CandleType.from_string(ct) for ct in config.get('candle_types', ['spot'])] - for candle_type in candle_types: - convert_ohlcv_format(config, - convert_from=args['format_from'], convert_to=args['format_to'], - erase=args['erase'], candle_type=candle_type) + convert_ohlcv_format(config, + convert_from=args['format_from'], + convert_to=args['format_to'], + erase=args['erase']) else: convert_trades_format(config, convert_from=args['format_from'], convert_to=args['format_to'], diff --git a/freqtrade/commands/list_commands.py b/freqtrade/commands/list_commands.py index 4e0623081..84f237f77 100644 --- a/freqtrade/commands/list_commands.py +++ b/freqtrade/commands/list_commands.py @@ -1,7 +1,7 @@ import csv import logging import sys -from typing import Any, Dict, List +from typing import Any, Dict, List, Union import rapidjson from colorama import Fore, Style @@ -11,9 +11,10 @@ from tabulate import tabulate from freqtrade.configuration import setup_utils_configuration from freqtrade.enums import RunMode from freqtrade.exceptions import OperationalException -from freqtrade.exchange import market_is_active, validate_exchanges +from freqtrade.exchange import list_available_exchanges, market_is_active from freqtrade.misc import parse_db_uri_for_logging, plural from freqtrade.resolvers import ExchangeResolver, StrategyResolver +from freqtrade.types import ValidExchangesType logger = logging.getLogger(__name__) @@ -25,18 +26,42 @@ def start_list_exchanges(args: Dict[str, Any]) -> None: :param args: Cli args from Arguments() :return: None """ - exchanges = validate_exchanges(args['list_exchanges_all']) + exchanges = list_available_exchanges(args['list_exchanges_all']) if args['print_one_column']: - print('\n'.join([e[0] for e in exchanges])) + print('\n'.join([e['name'] for e in exchanges])) else: + headers = { + 'name': 'Exchange name', + 'supported': 'Supported', + 'trade_modes': 'Markets', + 'comment': 'Reason', + } + headers.update({'valid': 'Valid'} if args['list_exchanges_all'] else {}) + + def build_entry(exchange: ValidExchangesType, valid: bool): + valid_entry = {'valid': exchange['valid']} if valid else {} + result: Dict[str, Union[str, bool]] = { + 'name': exchange['name'], + **valid_entry, + 'supported': 'Official' if exchange['supported'] else '', + 'trade_modes': ', '.join( + (f"{a['margin_mode']} " if a['margin_mode'] else '') + a['trading_mode'] + for a in exchange['trade_modes'] + ), + 'comment': exchange['comment'], + } + + return result + if args['list_exchanges_all']: print("All exchanges supported by the ccxt library:") + exchanges = [build_entry(e, True) for e in exchanges] else: print("Exchanges available for Freqtrade:") - exchanges = [e for e in exchanges if e[1] is not False] + exchanges = [build_entry(e, False) for e in exchanges if e['valid'] is not False] - print(tabulate(exchanges, headers=['Exchange name', 'Valid', 'reason'])) + print(tabulate(exchanges, headers=headers, )) def _print_objs_tabular(objs: List, print_colorized: bool) -> None: @@ -114,7 +139,7 @@ def start_list_timeframes(args: Dict[str, Any]) -> None: config['timeframe'] = None # Init exchange - exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config, validate=False) + exchange = ExchangeResolver.load_exchange(config, validate=False) if args['print_one_column']: print('\n'.join(exchange.timeframes)) @@ -133,7 +158,7 @@ def start_list_markets(args: Dict[str, Any], pairs_only: bool = False) -> None: config = setup_utils_configuration(args, RunMode.UTIL_EXCHANGE) # Init exchange - exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config, validate=False) + exchange = ExchangeResolver.load_exchange(config, validate=False) # By default only active pairs/markets are to be shown active_only = not args.get('list_pairs_all', False) diff --git a/freqtrade/commands/optimize_commands.py b/freqtrade/commands/optimize_commands.py index 1bfd384fc..cdddf0fe5 100644 --- a/freqtrade/commands/optimize_commands.py +++ b/freqtrade/commands/optimize_commands.py @@ -132,3 +132,15 @@ def start_edge(args: Dict[str, Any]) -> None: # Initialize Edge object edge_cli = EdgeCli(config) edge_cli.start() + + +def start_lookahead_analysis(args: Dict[str, Any]) -> None: + """ + Start the backtest bias tester script + :param args: Cli args from Arguments() + :return: None + """ + from freqtrade.optimize.lookahead_analysis_helpers import LookaheadAnalysisSubFunctions + + config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE) + LookaheadAnalysisSubFunctions.start(config) diff --git a/freqtrade/commands/pairlist_commands.py b/freqtrade/commands/pairlist_commands.py index 9f7a5958e..a815cd5f3 100644 --- a/freqtrade/commands/pairlist_commands.py +++ b/freqtrade/commands/pairlist_commands.py @@ -18,7 +18,7 @@ def start_test_pairlist(args: Dict[str, Any]) -> None: from freqtrade.plugins.pairlistmanager import PairListManager config = setup_utils_configuration(args, RunMode.UTIL_EXCHANGE) - exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config, validate=False) + exchange = ExchangeResolver.load_exchange(config, validate=False) quote_currencies = args.get('quote_currencies') if not quote_currencies: diff --git a/freqtrade/configuration/config_validation.py b/freqtrade/configuration/config_validation.py index 0ee48cf91..f1745df61 100644 --- a/freqtrade/configuration/config_validation.py +++ b/freqtrade/configuration/config_validation.py @@ -174,7 +174,7 @@ def _validate_whitelist(conf: Dict[str, Any]) -> None: return for pl in conf.get('pairlists', [{'method': 'StaticPairList'}]): - if (pl.get('method') == 'StaticPairList' + if (isinstance(pl, dict) and pl.get('method') == 'StaticPairList' and not conf.get('exchange', {}).get('pair_whitelist')): raise OperationalException("StaticPairList requires pair_whitelist to be set.") diff --git a/freqtrade/configuration/configuration.py b/freqtrade/configuration/configuration.py index 862976eb1..43ede568c 100644 --- a/freqtrade/configuration/configuration.py +++ b/freqtrade/configuration/configuration.py @@ -203,7 +203,7 @@ class Configuration: # This will override the strategy configuration self._args_to_config(config, argname='timeframe', logstring='Parameter -i/--timeframe detected ... ' - 'Using timeframe: {} ...') + 'Using timeframe: {} ...') self._args_to_config(config, argname='position_stacking', logstring='Parameter --enable-position-stacking detected ...') @@ -300,6 +300,9 @@ class Configuration: self._args_to_config(config, argname='hyperoptexportfilename', logstring='Using hyperopt file: {}') + self._args_to_config(config, argname='lookahead_analysis_exportfilename', + logstring='Saving lookahead analysis results into {} ...') + self._args_to_config(config, argname='epochs', logstring='Parameter --epochs detected ... ' 'Will run Hyperopt with for {} epochs ...' @@ -465,6 +468,28 @@ class Configuration: self._args_to_config(config, argname='timerange', logstring='Filter trades by timerange: {}') + self._args_to_config(config, argname='analysis_rejected', + logstring='Analyse rejected signals: {}') + + self._args_to_config(config, argname='analysis_to_csv', + logstring='Store analysis tables to CSV: {}') + + self._args_to_config(config, argname='analysis_csv_path', + logstring='Path to store analysis CSVs: {}') + + self._args_to_config(config, argname='analysis_csv_path', + logstring='Path to store analysis CSVs: {}') + + # Lookahead analysis results + self._args_to_config(config, argname='targeted_trade_amount', + logstring='Targeted Trade amount: {}') + + self._args_to_config(config, argname='minimum_trade_amount', + logstring='Minimum Trade amount: {}') + + self._args_to_config(config, argname='lookahead_analysis_exportfilename', + logstring='Path to store lookahead-analysis-results: {}') + def _process_runmode(self, config: Config) -> None: self._args_to_config(config, argname='dry_run', @@ -543,6 +568,7 @@ class Configuration: # Fall back to /dl_path/pairs.json pairs_file = config['datadir'] / 'pairs.json' if pairs_file.exists(): + logger.info(f'Reading pairs file "{pairs_file}".') config['pairs'] = load_file(pairs_file) if 'pairs' in config and isinstance(config['pairs'], list): config['pairs'].sort() diff --git a/freqtrade/configuration/timerange.py b/freqtrade/configuration/timerange.py index 0c2f0d1b8..23e3a6b60 100644 --- a/freqtrade/configuration/timerange.py +++ b/freqtrade/configuration/timerange.py @@ -6,7 +6,7 @@ import re from datetime import datetime, timezone from typing import Optional -import arrow +from typing_extensions import Self from freqtrade.constants import DATETIME_PRINT_FORMAT from freqtrade.exceptions import OperationalException @@ -109,15 +109,15 @@ class TimeRange: self.startts = int(min_date.timestamp() + timeframe_secs * startup_candles) self.starttype = 'date' - @staticmethod - def parse_timerange(text: Optional[str]) -> 'TimeRange': + @classmethod + def parse_timerange(cls, text: Optional[str]) -> Self: """ Parse the value of the argument --timerange to determine what is the range desired :param text: value from --timerange :return: Start and End range period """ if not text: - return TimeRange(None, None, 0, 0) + return cls(None, None, 0, 0) syntax = [(r'^-(\d{8})$', (None, 'date')), (r'^(\d{8})-$', ('date', None)), (r'^(\d{8})-(\d{8})$', ('date', 'date')), @@ -139,7 +139,8 @@ class TimeRange: if stype[0]: starts = rvals[index] if stype[0] == 'date' and len(starts) == 8: - start = arrow.get(starts, 'YYYYMMDD').int_timestamp + start = int(datetime.strptime(starts, '%Y%m%d').replace( + tzinfo=timezone.utc).timestamp()) elif len(starts) == 13: start = int(starts) // 1000 else: @@ -148,7 +149,8 @@ class TimeRange: if stype[1]: stops = rvals[index] if stype[1] == 'date' and len(stops) == 8: - stop = arrow.get(stops, 'YYYYMMDD').int_timestamp + stop = int(datetime.strptime(stops, '%Y%m%d').replace( + tzinfo=timezone.utc).timestamp()) elif len(stops) == 13: stop = int(stops) // 1000 else: @@ -156,5 +158,5 @@ class TimeRange: if start > stop > 0: raise OperationalException( f'Start date is after stop date for timerange "{text}"') - return TimeRange(stype[0], stype[1], start, stop) + return cls(stype[0], stype[1], start, stop) raise OperationalException(f'Incorrect syntax for timerange "{text}"') diff --git a/freqtrade/constants.py b/freqtrade/constants.py index b8e240419..3b09b4eae 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -8,8 +8,8 @@ from typing import Any, Dict, List, Literal, Tuple from freqtrade.enums import CandleType, PriceType, RPCMessageType +DOCS_LINK = "https://www.freqtrade.io/en/stable" DEFAULT_CONFIG = 'config.json' -DEFAULT_EXCHANGE = 'bittrex' PROCESS_THROTTLE_SECS = 5 # sec HYPEROPT_EPOCH = 100 # epochs RETRY_TIMEOUT = 30 # sec @@ -65,6 +65,7 @@ TELEGRAM_SETTING_OPTIONS = ['on', 'off', 'silent'] WEBHOOK_FORMAT_OPTIONS = ['form', 'json', 'raw'] FULL_DATAFRAME_THRESHOLD = 100 CUSTOM_TAG_MAX_LENGTH = 255 +DL_DATA_TIMEFRAMES = ['1m', '5m'] ENV_VAR_PREFIX = 'FREQTRADE__' @@ -111,6 +112,8 @@ MINIMAL_CONFIG = { } } +__MESSAGE_TYPE_DICT: Dict[str, Dict[str, str]] = {x: {'type': 'object'} for x in RPCMessageType} + # Required json-schema for user specified config CONF_SCHEMA = { 'type': 'object', @@ -148,7 +151,6 @@ CONF_SCHEMA = { 'patternProperties': { '^[0-9.]+$': {'type': 'number'} }, - 'minProperties': 1 }, 'amount_reserve_percent': {'type': 'number', 'minimum': 0.0, 'maximum': 0.5}, 'stoploss': {'type': 'number', 'maximum': 0, 'exclusiveMaximum': True, 'minimum': -1}, @@ -164,6 +166,9 @@ CONF_SCHEMA = { 'trading_mode': {'type': 'string', 'enum': TRADING_MODES}, 'margin_mode': {'type': 'string', 'enum': MARGIN_MODES}, 'reduce_df_footprint': {'type': 'boolean', 'default': False}, + 'minimum_trade_amount': {'type': 'number', 'default': 10}, + 'targeted_trade_amount': {'type': 'number', 'default': 20}, + 'lookahead_analysis_exportfilename': {'type': 'string'}, 'liquidation_buffer': {'type': 'number', 'minimum': 0.0, 'maximum': 0.99}, 'backtest_breakdown': { 'type': 'array', @@ -351,7 +356,8 @@ CONF_SCHEMA = { 'format': {'type': 'string', 'enum': WEBHOOK_FORMAT_OPTIONS, 'default': 'form'}, 'retries': {'type': 'integer', 'minimum': 0}, 'retry_delay': {'type': 'number', 'minimum': 0}, - **dict([(x, {'type': 'object'}) for x in RPCMessageType]), + **__MESSAGE_TYPE_DICT, + # **{x: {'type': 'object'} for x in RPCMessageType}, # Below -> Deprecated 'webhookentry': {'type': 'object'}, 'webhookentrycancel': {'type': 'object'}, @@ -690,4 +696,6 @@ BidAsk = Literal['bid', 'ask'] OBLiteral = Literal['asks', 'bids'] Config = Dict[str, Any] +# Exchange part of the configuration. +ExchangeConfig = Dict[str, Any] IntOrInf = float diff --git a/freqtrade/data/converter.py b/freqtrade/data/converter.py index 2d3855d87..4b4c89d08 100644 --- a/freqtrade/data/converter.py +++ b/freqtrade/data/converter.py @@ -11,7 +11,7 @@ import pandas as pd from pandas import DataFrame, to_datetime from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS, DEFAULT_TRADES_COLUMNS, Config, TradeList -from freqtrade.enums import CandleType +from freqtrade.enums import CandleType, TradingMode logger = logging.getLogger(__name__) @@ -264,7 +264,6 @@ def convert_ohlcv_format( convert_from: str, convert_to: str, erase: bool, - candle_type: CandleType ): """ Convert OHLCV from one format to another @@ -272,7 +271,6 @@ def convert_ohlcv_format( :param convert_from: Source format :param convert_to: Target format :param erase: Erase source data (does not apply if source and target format are identical) - :param candle_type: Any of the enum CandleType (must match trading mode!) """ from freqtrade.data.history.idatahandler import get_datahandler src = get_datahandler(config['datadir'], convert_from) @@ -280,37 +278,45 @@ def convert_ohlcv_format( timeframes = config.get('timeframes', [config.get('timeframe')]) logger.info(f"Converting candle (OHLCV) for timeframe {timeframes}") - if 'pairs' not in config: - config['pairs'] = [] - # Check timeframes or fall back to timeframe. - for timeframe in timeframes: - config['pairs'].extend(src.ohlcv_get_pairs( - config['datadir'], - timeframe, - candle_type=candle_type - )) - config['pairs'] = sorted(set(config['pairs'])) - logger.info(f"Converting candle (OHLCV) data for {config['pairs']}") + candle_types = [CandleType.from_string(ct) for ct in config.get('candle_types', [ + c.value for c in CandleType])] + logger.info(candle_types) + paircombs = src.ohlcv_get_available_data(config['datadir'], TradingMode.SPOT) + paircombs.extend(src.ohlcv_get_available_data(config['datadir'], TradingMode.FUTURES)) - for timeframe in timeframes: - for pair in config['pairs']: - data = src.ohlcv_load(pair=pair, timeframe=timeframe, - timerange=None, - fill_missing=False, - drop_incomplete=False, - startup_candles=0, - candle_type=candle_type) - logger.info(f"Converting {len(data)} {timeframe} {candle_type} candles for {pair}") - if len(data) > 0: - trg.ohlcv_store( - pair=pair, - timeframe=timeframe, - data=data, - candle_type=candle_type - ) - if erase and convert_from != convert_to: - logger.info(f"Deleting source data for {pair} / {timeframe}") - src.ohlcv_purge(pair=pair, timeframe=timeframe, candle_type=candle_type) + if 'pairs' in config: + # Filter pairs + paircombs = [comb for comb in paircombs if comb[0] in config['pairs']] + + if 'timeframes' in config: + paircombs = [comb for comb in paircombs if comb[1] in config['timeframes']] + paircombs = [comb for comb in paircombs if comb[2] in candle_types] + + paircombs = sorted(paircombs, key=lambda x: (x[0], x[1], x[2].value)) + + formatted_paircombs = '\n'.join([f"{pair}, {timeframe}, {candle_type}" + for pair, timeframe, candle_type in paircombs]) + + logger.info(f"Converting candle (OHLCV) data for the following pair combinations:\n" + f"{formatted_paircombs}") + for pair, timeframe, candle_type in paircombs: + data = src.ohlcv_load(pair=pair, timeframe=timeframe, + timerange=None, + fill_missing=False, + drop_incomplete=False, + startup_candles=0, + candle_type=candle_type) + logger.info(f"Converting {len(data)} {timeframe} {candle_type} candles for {pair}") + if len(data) > 0: + trg.ohlcv_store( + pair=pair, + timeframe=timeframe, + data=data, + candle_type=candle_type + ) + if erase and convert_from != convert_to: + logger.info(f"Deleting source data for {pair} / {timeframe}") + src.ohlcv_purge(pair=pair, timeframe=timeframe, candle_type=candle_type) def reduce_dataframe_footprint(df: DataFrame) -> DataFrame: diff --git a/freqtrade/data/entryexitanalysis.py b/freqtrade/data/entryexitanalysis.py index 5d67655cd..db3a7d3a4 100644 --- a/freqtrade/data/entryexitanalysis.py +++ b/freqtrade/data/entryexitanalysis.py @@ -1,5 +1,6 @@ import logging from pathlib import Path +from typing import List import joblib import pandas as pd @@ -15,22 +16,31 @@ from freqtrade.exceptions import OperationalException logger = logging.getLogger(__name__) -def _load_signal_candles(backtest_dir: Path): +def _load_backtest_analysis_data(backtest_dir: Path, name: str): if backtest_dir.is_dir(): scpf = Path(backtest_dir, - Path(get_latest_backtest_filename(backtest_dir)).stem + "_signals.pkl" + Path(get_latest_backtest_filename(backtest_dir)).stem + "_" + name + ".pkl" ) else: - scpf = Path(backtest_dir.parent / f"{backtest_dir.stem}_signals.pkl") + scpf = Path(backtest_dir.parent / f"{backtest_dir.stem}_{name}.pkl") try: with scpf.open("rb") as scp: - signal_candles = joblib.load(scp) - logger.info(f"Loaded signal candles: {str(scpf)}") + loaded_data = joblib.load(scp) + logger.info(f"Loaded {name} candles: {str(scpf)}") except Exception as e: - logger.error("Cannot load signal candles from pickled results: ", e) + logger.error(f"Cannot load {name} data from pickled results: ", e) + return None - return signal_candles + return loaded_data + + +def _load_rejected_signals(backtest_dir: Path): + return _load_backtest_analysis_data(backtest_dir, "rejected") + + +def _load_signal_candles(backtest_dir: Path): + return _load_backtest_analysis_data(backtest_dir, "signals") def _process_candles_and_indicators(pairlist, strategy_name, trades, signal_candles): @@ -43,9 +53,7 @@ def _process_candles_and_indicators(pairlist, strategy_name, trades, signal_cand for pair in pairlist: if pair in signal_candles[strategy_name]: analysed_trades_dict[strategy_name][pair] = _analyze_candles_and_indicators( - pair, - trades, - signal_candles[strategy_name][pair]) + pair, trades, signal_candles[strategy_name][pair]) except Exception as e: print(f"Cannot process entry/exit reasons for {strategy_name}: ", e) @@ -85,7 +93,7 @@ def _analyze_candles_and_indicators(pair, trades: pd.DataFrame, signal_candles: return pd.DataFrame() -def _do_group_table_output(bigdf, glist): +def _do_group_table_output(bigdf, glist, csv_path: Path, to_csv=False, ): for g in glist: # 0: summary wins/losses grouped by enter tag if g == "0": @@ -116,7 +124,8 @@ def _do_group_table_output(bigdf, glist): sortcols = ['total_num_buys'] - _print_table(new, sortcols, show_index=True) + _print_table(new, sortcols, show_index=True, name="Group 0:", + to_csv=to_csv, csv_path=csv_path) else: agg_mask = {'profit_abs': ['count', 'sum', 'median', 'mean'], @@ -154,11 +163,24 @@ def _do_group_table_output(bigdf, glist): new['mean_profit_pct'] = new['mean_profit_pct'] * 100 new['total_profit_pct'] = new['total_profit_pct'] * 100 - _print_table(new, sortcols) + _print_table(new, sortcols, name=f"Group {g}:", + to_csv=to_csv, csv_path=csv_path) else: logger.warning("Invalid group mask specified.") +def _do_rejected_signals_output(rejected_signals_df: pd.DataFrame, + to_csv: bool = False, csv_path=None) -> None: + cols = ['pair', 'date', 'enter_tag'] + sortcols = ['date', 'pair', 'enter_tag'] + _print_table(rejected_signals_df[cols], + sortcols, + show_index=False, + name="Rejected Signals:", + to_csv=to_csv, + csv_path=csv_path) + + def _select_rows_within_dates(df, timerange=None, df_date_col: str = 'date'): if timerange: if timerange.starttype == 'date': @@ -192,38 +214,64 @@ def prepare_results(analysed_trades, stratname, return res_df -def print_results(res_df, analysis_groups, indicator_list): +def print_results(res_df: pd.DataFrame, analysis_groups: List[str], indicator_list: List[str], + csv_path: Path, rejected_signals=None, to_csv=False): if res_df.shape[0] > 0: if analysis_groups: - _do_group_table_output(res_df, analysis_groups) + _do_group_table_output(res_df, analysis_groups, to_csv=to_csv, csv_path=csv_path) + if rejected_signals is not None: + if rejected_signals.empty: + print("There were no rejected signals.") + else: + _do_rejected_signals_output(rejected_signals, to_csv=to_csv, csv_path=csv_path) + + # NB this can be large for big dataframes! if "all" in indicator_list: - print(res_df) - elif indicator_list is not None: + _print_table(res_df, + show_index=False, + name="Indicators:", + to_csv=to_csv, + csv_path=csv_path) + elif indicator_list is not None and indicator_list: available_inds = [] for ind in indicator_list: if ind in res_df: available_inds.append(ind) ilist = ["pair", "enter_reason", "exit_reason"] + available_inds - _print_table(res_df[ilist], sortcols=['exit_reason'], show_index=False) + _print_table(res_df[ilist], + sortcols=['exit_reason'], + show_index=False, + name="Indicators:", + to_csv=to_csv, + csv_path=csv_path) else: print("\\No trades to show") -def _print_table(df, sortcols=None, show_index=False): +def _print_table(df: pd.DataFrame, sortcols=None, *, show_index=False, name=None, + to_csv=False, csv_path: Path): if (sortcols is not None): data = df.sort_values(sortcols) else: data = df - print( - tabulate( - data, - headers='keys', - tablefmt='psql', - showindex=show_index + if to_csv: + safe_name = Path(csv_path, name.lower().replace(" ", "_").replace(":", "") + ".csv") + data.to_csv(safe_name) + print(f"Saved {name} to {safe_name}") + else: + if name is not None: + print(name) + + print( + tabulate( + data, + headers='keys', + tablefmt='psql', + showindex=show_index + ) ) - ) def process_entry_exit_reasons(config: Config): @@ -232,6 +280,11 @@ def process_entry_exit_reasons(config: Config): enter_reason_list = config.get('enter_reason_list', ["all"]) exit_reason_list = config.get('exit_reason_list', ["all"]) indicator_list = config.get('indicator_list', []) + do_rejected = config.get('analysis_rejected', False) + to_csv = config.get('analysis_to_csv', False) + csv_path = Path(config.get('analysis_csv_path', config['exportfilename'])) + if to_csv and not csv_path.is_dir(): + raise OperationalException(f"Specified directory {csv_path} does not exist.") timerange = TimeRange.parse_timerange(None if config.get( 'timerange') is None else str(config.get('timerange'))) @@ -241,8 +294,16 @@ def process_entry_exit_reasons(config: Config): for strategy_name, results in backtest_stats['strategy'].items(): trades = load_backtest_data(config['exportfilename'], strategy_name) - if not trades.empty: + if trades is not None and not trades.empty: signal_candles = _load_signal_candles(config['exportfilename']) + + rej_df = None + if do_rejected: + rejected_signals_dict = _load_rejected_signals(config['exportfilename']) + rej_df = prepare_results(rejected_signals_dict, strategy_name, + enter_reason_list, exit_reason_list, + timerange=timerange) + analysed_trades_dict = _process_candles_and_indicators( config['exchange']['pair_whitelist'], strategy_name, trades, signal_candles) @@ -253,7 +314,10 @@ def process_entry_exit_reasons(config: Config): print_results(res_df, analysis_groups, - indicator_list) + indicator_list, + rejected_signals=rej_df, + to_csv=to_csv, + csv_path=csv_path) except ValueError as e: raise OperationalException(e) from e diff --git a/freqtrade/data/history/__init__.py b/freqtrade/data/history/__init__.py index 107f9c401..414848c22 100644 --- a/freqtrade/data/history/__init__.py +++ b/freqtrade/data/history/__init__.py @@ -6,7 +6,7 @@ Includes: * download data from exchange and store to disk """ # flake8: noqa: F401 -from .history_utils import (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 .history_utils import (convert_trades_to_ohlcv, download_data_main, get_timerange, load_data, + load_pair_history, refresh_backtest_ohlcv_data, + refresh_backtest_trades_data, refresh_data, validate_backtest_data) from .idatahandler import get_datahandler diff --git a/freqtrade/data/history/history_utils.py b/freqtrade/data/history/history_utils.py index b567b58bf..39556889e 100644 --- a/freqtrade/data/history/history_utils.py +++ b/freqtrade/data/history/history_utils.py @@ -1,21 +1,23 @@ import logging import operator -from datetime import datetime +from datetime import datetime, timedelta from pathlib import Path from typing import Dict, List, Optional, Tuple -import arrow from pandas import DataFrame, concat from freqtrade.configuration import TimeRange -from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS +from freqtrade.constants import (DATETIME_PRINT_FORMAT, DEFAULT_DATAFRAME_COLUMNS, + DL_DATA_TIMEFRAMES, Config) from freqtrade.data.converter import (clean_ohlcv_dataframe, ohlcv_to_dataframe, trades_remove_duplicates, trades_to_ohlcv) from freqtrade.data.history.idatahandler import IDataHandler, get_datahandler from freqtrade.enums import CandleType from freqtrade.exceptions import OperationalException from freqtrade.exchange import Exchange -from freqtrade.misc import format_ms_time +from freqtrade.plugins.pairlist.pairlist_helpers import dynamic_expand_pairlist +from freqtrade.util import format_ms_time +from freqtrade.util.binance_mig import migrate_binance_futures_data logger = logging.getLogger(__name__) @@ -228,16 +230,18 @@ def _download_pair_history(pair: str, *, ) logger.debug("Current Start: %s", - f"{data.iloc[0]['date']:DATETIME_PRINT_FORMAT}" if not data.empty else 'None') + f"{data.iloc[0]['date']:{DATETIME_PRINT_FORMAT}}" + if not data.empty else 'None') logger.debug("Current End: %s", - f"{data.iloc[-1]['date']:DATETIME_PRINT_FORMAT}" if not data.empty else 'None') + f"{data.iloc[-1]['date']:{DATETIME_PRINT_FORMAT}}" + if not data.empty else 'None') # Default since_ms to 30 days if nothing is given new_data = exchange.get_historic_ohlcv(pair=pair, timeframe=timeframe, since_ms=since_ms if since_ms else - arrow.utcnow().shift( - days=-new_pairs_days).int_timestamp * 1000, + int((datetime.now() - timedelta(days=new_pairs_days) + ).timestamp()) * 1000, is_new_pair=data.empty, candle_type=candle_type, until_ms=until_ms if until_ms else None @@ -253,10 +257,12 @@ def _download_pair_history(pair: str, *, data = clean_ohlcv_dataframe(concat([data, new_dataframe], axis=0), timeframe, pair, fill_missing=False, drop_incomplete=False) - logger.debug("New Start: %s", - f"{data.iloc[0]['date']:DATETIME_PRINT_FORMAT}" if not data.empty else 'None') + logger.debug("New Start: %s", + f"{data.iloc[0]['date']:{DATETIME_PRINT_FORMAT}}" + if not data.empty else 'None') logger.debug("New End: %s", - f"{data.iloc[-1]['date']:DATETIME_PRINT_FORMAT}" if not data.empty else 'None') + f"{data.iloc[-1]['date']:{DATETIME_PRINT_FORMAT}}" + if not data.empty else 'None') data_handler.ohlcv_store(pair, timeframe, data=data, candle_type=candle_type) return True @@ -291,7 +297,7 @@ def refresh_backtest_ohlcv_data(exchange: Exchange, pairs: List[str], timeframes continue for timeframe in timeframes: - logger.info(f'Downloading pair {pair}, interval {timeframe}.') + logger.debug(f'Downloading pair {pair}, {candle_type}, interval {timeframe}.') process = f'{idx}/{len(pairs)}' _download_pair_history(pair=pair, process=process, datadir=datadir, exchange=exchange, @@ -349,7 +355,7 @@ def _download_trades_history(exchange: Exchange, trades = [] if not since: - since = arrow.utcnow().shift(days=-new_pairs_days).int_timestamp * 1000 + since = int((datetime.now() - timedelta(days=new_pairs_days)).timestamp()) * 1000 from_id = trades[-1][1] if trades else None if trades and since < trades[-1][0]: @@ -480,3 +486,79 @@ def validate_backtest_data(data: DataFrame, pair: str, min_date: datetime, logger.warning("%s has missing frames: expected %s, got %s, that's %s missing values", pair, expected_frames, dflen, expected_frames - dflen) return found_missing + + +def download_data_main(config: Config) -> None: + + timerange = TimeRange() + if 'days' in config: + time_since = (datetime.now() - timedelta(days=config['days'])).strftime("%Y%m%d") + timerange = TimeRange.parse_timerange(f'{time_since}-') + + if 'timerange' in config: + timerange = timerange.parse_timerange(config['timerange']) + + # Remove stake-currency to skip checks which are not relevant for datadownload + config['stake_currency'] = '' + + pairs_not_available: List[str] = [] + + # Init exchange + from freqtrade.resolvers.exchange_resolver import ExchangeResolver + exchange = ExchangeResolver.load_exchange(config, validate=False) + available_pairs = [ + p for p in exchange.get_markets( + tradable_only=True, active_only=not config.get('include_inactive') + ).keys() + ] + + expanded_pairs = dynamic_expand_pairlist(config, available_pairs) + if 'timeframes' not in config: + config['timeframes'] = DL_DATA_TIMEFRAMES + + # Manual validations of relevant settings + if not config['exchange'].get('skip_pair_validation', False): + exchange.validate_pairs(expanded_pairs) + logger.info(f"About to download pairs: {expanded_pairs}, " + f"intervals: {config['timeframes']} to {config['datadir']}") + + for timeframe in config['timeframes']: + exchange.validate_timeframes(timeframe) + + # Start downloading + try: + if config.get('download_trades'): + if config.get('trading_mode') == 'futures': + raise OperationalException("Trade download not supported for futures.") + pairs_not_available = refresh_backtest_trades_data( + exchange, pairs=expanded_pairs, datadir=config['datadir'], + timerange=timerange, new_pairs_days=config['new_pairs_days'], + erase=bool(config.get('erase')), data_format=config['dataformat_trades']) + + # Convert downloaded trade data to different timeframes + convert_trades_to_ohlcv( + pairs=expanded_pairs, timeframes=config['timeframes'], + datadir=config['datadir'], timerange=timerange, erase=bool(config.get('erase')), + data_format_ohlcv=config['dataformat_ohlcv'], + data_format_trades=config['dataformat_trades'], + ) + else: + if not exchange.get_option('ohlcv_has_history', True): + raise OperationalException( + f"Historic klines not available for {exchange.name}. " + "Please use `--dl-trades` instead for this exchange " + "(will unfortunately take a long time)." + ) + migrate_binance_futures_data(config) + pairs_not_available = refresh_backtest_ohlcv_data( + exchange, pairs=expanded_pairs, timeframes=config['timeframes'], + datadir=config['datadir'], timerange=timerange, + new_pairs_days=config['new_pairs_days'], + erase=bool(config.get('erase')), data_format=config['dataformat_ohlcv'], + trading_mode=config.get('trading_mode', 'spot'), + prepend=config.get('prepend_data', False) + ) + finally: + if pairs_not_available: + logger.info(f"Pairs [{','.join(pairs_not_available)}] not available " + f"on exchange {exchange.name}.") diff --git a/freqtrade/edge/edge_positioning.py b/freqtrade/edge/edge_positioning.py index 73820ecbe..f2df0d3f2 100644 --- a/freqtrade/edge/edge_positioning.py +++ b/freqtrade/edge/edge_positioning.py @@ -3,9 +3,9 @@ import logging from collections import defaultdict from copy import deepcopy +from datetime import timedelta from typing import Any, Dict, List, NamedTuple -import arrow import numpy as np import utils_find_1st as utf1st from pandas import DataFrame @@ -18,6 +18,7 @@ from freqtrade.exceptions import OperationalException from freqtrade.exchange import timeframe_to_seconds from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist from freqtrade.strategy.interface import IStrategy +from freqtrade.util import dt_now logger = logging.getLogger(__name__) @@ -79,8 +80,8 @@ class Edge: self._stoploss_range_step ) - self._timerange: TimeRange = TimeRange.parse_timerange("%s-" % arrow.now().shift( - days=-1 * self._since_number_of_days).format('YYYYMMDD')) + self._timerange: TimeRange = TimeRange.parse_timerange( + f"{(dt_now() - timedelta(days=self._since_number_of_days)).strftime('%Y%m%d')}-") if config.get('fee'): self.fee = config['fee'] else: @@ -97,7 +98,7 @@ class Edge: heartbeat = self.edge_config.get('process_throttle_secs') if (self._last_updated > 0) and ( - self._last_updated + heartbeat > arrow.utcnow().int_timestamp): + self._last_updated + heartbeat > int(dt_now().timestamp())): return False data: Dict[str, Any] = {} @@ -189,7 +190,7 @@ class Edge: # Fill missing, calculable columns, profit, duration , abs etc. trades_df = self._fill_calculable_fields(DataFrame(trades)) self._cached_pairs = self._process_expectancy(trades_df) - self._last_updated = arrow.utcnow().int_timestamp + self._last_updated = int(dt_now().timestamp()) return True diff --git a/freqtrade/enums/exittype.py b/freqtrade/enums/exittype.py index b025230ba..c21b62667 100644 --- a/freqtrade/enums/exittype.py +++ b/freqtrade/enums/exittype.py @@ -15,6 +15,7 @@ class ExitType(Enum): EMERGENCY_EXIT = "emergency_exit" CUSTOM_EXIT = "custom_exit" PARTIAL_EXIT = "partial_exit" + SOLD_ON_EXCHANGE = "sold_on_exchange" NONE = "" def __str__(self): diff --git a/freqtrade/enums/marginmode.py b/freqtrade/enums/marginmode.py index 1e42809ea..7fd749b29 100644 --- a/freqtrade/enums/marginmode.py +++ b/freqtrade/enums/marginmode.py @@ -1,7 +1,7 @@ from enum import Enum -class MarginMode(Enum): +class MarginMode(str, Enum): """ Enum to distinguish between cross margin/futures margin_mode and diff --git a/freqtrade/exchange/__init__.py b/freqtrade/exchange/__init__.py index 8092d5af8..9ac31a0d8 100644 --- a/freqtrade/exchange/__init__.py +++ b/freqtrade/exchange/__init__.py @@ -1,6 +1,6 @@ # flake8: noqa: F401 # isort: off -from freqtrade.exchange.common import remove_credentials, MAP_EXCHANGE_CHILDCLASS +from freqtrade.exchange.common import remove_exchange_credentials, MAP_EXCHANGE_CHILDCLASS from freqtrade.exchange.exchange import Exchange # isort: on from freqtrade.exchange.binance import Binance @@ -13,11 +13,11 @@ from freqtrade.exchange.exchange_utils import (ROUND_DOWN, ROUND_UP, amount_to_c amount_to_contracts, amount_to_precision, available_exchanges, ccxt_exchanges, contracts_to_amount, date_minus_candles, - is_exchange_known_ccxt, market_is_active, - price_to_precision, timeframe_to_minutes, - timeframe_to_msecs, timeframe_to_next_date, - timeframe_to_prev_date, timeframe_to_seconds, - validate_exchange, validate_exchanges) + is_exchange_known_ccxt, list_available_exchanges, + market_is_active, price_to_precision, + timeframe_to_minutes, timeframe_to_msecs, + timeframe_to_next_date, timeframe_to_prev_date, + timeframe_to_seconds, validate_exchange) from freqtrade.exchange.gate import Gate from freqtrade.exchange.hitbtc import Hitbtc from freqtrade.exchange.huobi import Huobi diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index 7ac496f62..8075d775a 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -1,10 +1,9 @@ """ Binance exchange subclass """ import logging -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from typing import Dict, List, Optional, Tuple -import arrow import ccxt from freqtrade.enums import CandleType, MarginMode, PriceType, TradingMode @@ -66,7 +65,7 @@ class Binance(Exchange): """ try: if self.trading_mode == TradingMode.FUTURES and not self._config['dry_run']: - position_side = self._api.fapiPrivateGetPositionsideDual() + 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) @@ -105,8 +104,9 @@ class Binance(Exchange): if x and x[3] and x[3][0] and x[3][0][0] > since_ms: # Set starting date to first available candle. since_ms = x[3][0][0] - logger.info(f"Candle-data for {pair} available starting with " - f"{arrow.get(since_ms // 1000).isoformat()}.") + logger.info( + f"Candle-data for {pair} available starting with " + f"{datetime.fromtimestamp(since_ms // 1000, tz=timezone.utc).isoformat()}.") return await super()._async_get_historic_ohlcv( pair=pair, diff --git a/freqtrade/exchange/binance_leverage_tiers.json b/freqtrade/exchange/binance_leverage_tiers.json index 0b9be0f55..b211be701 100644 --- a/freqtrade/exchange/binance_leverage_tiers.json +++ b/freqtrade/exchange/binance_leverage_tiers.json @@ -1,8 +1,8 @@ { - "1000LUNC/BUSD:BUSD": [ + "1000FLOKI/USDT:USDT": [ { "tier": 1.0, - "currency": "BUSD", + "currency": "USDT", "minNotional": 0.0, "maxNotional": 5000.0, "maintenanceMarginRate": 0.02, @@ -16,16 +16,130 @@ "cum": "0.0" } }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 5000.0, + "maxNotional": 25000.0, + "maintenanceMarginRate": 0.025, + "maxLeverage": 15.0, + "info": { + "bracket": "2", + "initialLeverage": "15", + "notionalCap": "25000", + "notionalFloor": "5000", + "maintMarginRatio": "0.025", + "cum": "25.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 25000.0, + "maxNotional": 300000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "3", + "initialLeverage": "10", + "notionalCap": "300000", + "notionalFloor": "25000", + "maintMarginRatio": "0.05", + "cum": "650.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 300000.0, + "maxNotional": 800000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "4", + "initialLeverage": "5", + "notionalCap": "800000", + "notionalFloor": "300000", + "maintMarginRatio": "0.1", + "cum": "15650.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 800000.0, + "maxNotional": 1000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "5", + "initialLeverage": "4", + "notionalCap": "1000000", + "notionalFloor": "800000", + "maintMarginRatio": "0.125", + "cum": "35650.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 1000000.0, + "maxNotional": 3000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "3000000", + "notionalFloor": "1000000", + "maintMarginRatio": "0.25", + "cum": "160650.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 3000000.0, + "maxNotional": 5000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "7", + "initialLeverage": "1", + "notionalCap": "5000000", + "notionalFloor": "3000000", + "maintMarginRatio": "0.5", + "cum": "910650.0" + } + } + ], + "1000LUNC/BUSD:BUSD": [ + { + "tier": 1.0, + "currency": "BUSD", + "minNotional": 0.0, + "maxNotional": 5000.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 10.0, + "info": { + "bracket": "1", + "initialLeverage": "10", + "notionalCap": "5000", + "notionalFloor": "0", + "maintMarginRatio": "0.02", + "cum": "0.0" + } + }, { "tier": 2.0, "currency": "BUSD", "minNotional": 5000.0, "maxNotional": 25000.0, "maintenanceMarginRate": 0.025, - "maxLeverage": 10.0, + "maxLeverage": 8.0, "info": { "bracket": "2", - "initialLeverage": "10", + "initialLeverage": "8", "notionalCap": "25000", "notionalFloor": "5000", "maintMarginRatio": "0.025", @@ -38,10 +152,10 @@ "minNotional": 25000.0, "maxNotional": 100000.0, "maintenanceMarginRate": 0.05, - "maxLeverage": 8.0, + "maxLeverage": 6.0, "info": { "bracket": "3", - "initialLeverage": "8", + "initialLeverage": "6", "notionalCap": "100000", "notionalFloor": "25000", "maintMarginRatio": "0.05", @@ -84,13 +198,13 @@ "tier": 6.0, "currency": "BUSD", "minNotional": 1000000.0, - "maxNotional": 5000000.0, + "maxNotional": 1500000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "6", "initialLeverage": "1", - "notionalCap": "5000000", + "notionalCap": "1500000", "notionalFloor": "1000000", "maintMarginRatio": "0.5", "cum": "386900.0" @@ -211,10 +325,10 @@ } } ], - "1000SHIB/BUSD:BUSD": [ + "1000PEPE/USDT:USDT": [ { "tier": 1.0, - "currency": "BUSD", + "currency": "USDT", "minNotional": 0.0, "maxNotional": 5000.0, "maintenanceMarginRate": 0.02, @@ -228,16 +342,130 @@ "cum": "0.0" } }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 5000.0, + "maxNotional": 50000.0, + "maintenanceMarginRate": 0.025, + "maxLeverage": 20.0, + "info": { + "bracket": "2", + "initialLeverage": "20", + "notionalCap": "50000", + "notionalFloor": "5000", + "maintMarginRatio": "0.025", + "cum": "25.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 50000.0, + "maxNotional": 900000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "3", + "initialLeverage": "10", + "notionalCap": "900000", + "notionalFloor": "50000", + "maintMarginRatio": "0.05", + "cum": "1275.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 900000.0, + "maxNotional": 2400000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "4", + "initialLeverage": "5", + "notionalCap": "2400000", + "notionalFloor": "900000", + "maintMarginRatio": "0.1", + "cum": "46275.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 2400000.0, + "maxNotional": 3000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "5", + "initialLeverage": "4", + "notionalCap": "3000000", + "notionalFloor": "2400000", + "maintMarginRatio": "0.125", + "cum": "106275.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 3000000.0, + "maxNotional": 9000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "9000000", + "notionalFloor": "3000000", + "maintMarginRatio": "0.25", + "cum": "481275.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 9000000.0, + "maxNotional": 15000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "7", + "initialLeverage": "1", + "notionalCap": "15000000", + "notionalFloor": "9000000", + "maintMarginRatio": "0.5", + "cum": "2731275.0" + } + } + ], + "1000SHIB/BUSD:BUSD": [ + { + "tier": 1.0, + "currency": "BUSD", + "minNotional": 0.0, + "maxNotional": 5000.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 10.0, + "info": { + "bracket": "1", + "initialLeverage": "10", + "notionalCap": "5000", + "notionalFloor": "0", + "maintMarginRatio": "0.02", + "cum": "0.0" + } + }, { "tier": 2.0, "currency": "BUSD", "minNotional": 5000.0, "maxNotional": 25000.0, "maintenanceMarginRate": 0.025, - "maxLeverage": 15.0, + "maxLeverage": 8.0, "info": { "bracket": "2", - "initialLeverage": "15", + "initialLeverage": "8", "notionalCap": "25000", "notionalFloor": "5000", "maintMarginRatio": "0.025", @@ -250,10 +478,10 @@ "minNotional": 25000.0, "maxNotional": 100000.0, "maintenanceMarginRate": 0.05, - "maxLeverage": 10.0, + "maxLeverage": 6.0, "info": { "bracket": "3", - "initialLeverage": "10", + "initialLeverage": "6", "notionalCap": "100000", "notionalFloor": "25000", "maintMarginRatio": "0.05", @@ -312,13 +540,13 @@ "tier": 7.0, "currency": "BUSD", "minNotional": 3000000.0, - "maxNotional": 8000000.0, + "maxNotional": 3500000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "7", "initialLeverage": "1", - "notionalCap": "8000000", + "notionalCap": "3500000", "notionalFloor": "3000000", "maintMarginRatio": "0.5", "cum": "949400.0" @@ -362,13 +590,13 @@ "tier": 3.0, "currency": "USDT", "minNotional": 25000.0, - "maxNotional": 50000.0, + "maxNotional": 100000.0, "maintenanceMarginRate": 0.01, "maxLeverage": 25.0, "info": { "bracket": "3", "initialLeverage": "25", - "notionalCap": "50000", + "notionalCap": "100000", "notionalFloor": "25000", "maintMarginRatio": "0.01", "cum": "67.5" @@ -377,87 +605,87 @@ { "tier": 4.0, "currency": "USDT", - "minNotional": 50000.0, - "maxNotional": 150000.0, + "minNotional": 100000.0, + "maxNotional": 300000.0, "maintenanceMarginRate": 0.025, "maxLeverage": 20.0, "info": { "bracket": "4", "initialLeverage": "20", - "notionalCap": "150000", - "notionalFloor": "50000", + "notionalCap": "300000", + "notionalFloor": "100000", "maintMarginRatio": "0.025", - "cum": "817.5" + "cum": "1567.5" } }, { "tier": 5.0, "currency": "USDT", - "minNotional": 150000.0, - "maxNotional": 250000.0, + "minNotional": 300000.0, + "maxNotional": 750000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 10.0, "info": { "bracket": "5", "initialLeverage": "10", - "notionalCap": "250000", - "notionalFloor": "150000", + "notionalCap": "750000", + "notionalFloor": "300000", "maintMarginRatio": "0.05", - "cum": "4567.5" + "cum": "9067.5" } }, { "tier": 6.0, "currency": "USDT", - "minNotional": 250000.0, - "maxNotional": 500000.0, + "minNotional": 750000.0, + "maxNotional": 1500000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "6", "initialLeverage": "5", - "notionalCap": "500000", - "notionalFloor": "250000", + "notionalCap": "1500000", + "notionalFloor": "750000", "maintMarginRatio": "0.1", - "cum": "17067.5" + "cum": "46567.5" } }, { "tier": 7.0, "currency": "USDT", - "minNotional": 500000.0, - "maxNotional": 1000000.0, + "minNotional": 1500000.0, + "maxNotional": 3000000.0, "maintenanceMarginRate": 0.125, "maxLeverage": 4.0, "info": { "bracket": "7", "initialLeverage": "4", - "notionalCap": "1000000", - "notionalFloor": "500000", + "notionalCap": "3000000", + "notionalFloor": "1500000", "maintMarginRatio": "0.125", - "cum": "29567.5" + "cum": "84067.5" } }, { "tier": 8.0, "currency": "USDT", - "minNotional": 1000000.0, - "maxNotional": 2000000.0, + "minNotional": 3000000.0, + "maxNotional": 6000000.0, "maintenanceMarginRate": 0.25, "maxLeverage": 2.0, "info": { "bracket": "8", "initialLeverage": "2", - "notionalCap": "2000000", - "notionalFloor": "1000000", + "notionalCap": "6000000", + "notionalFloor": "3000000", "maintMarginRatio": "0.25", - "cum": "154567.5" + "cum": "459067.5" } }, { "tier": 9.0, "currency": "USDT", - "minNotional": 2000000.0, + "minNotional": 6000000.0, "maxNotional": 30000000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, @@ -465,9 +693,9 @@ "bracket": "9", "initialLeverage": "1", "notionalCap": "30000000", - "notionalFloor": "2000000", + "notionalFloor": "6000000", "maintMarginRatio": "0.5", - "cum": "654567.5" + "cum": "1959067.5" } } ], @@ -1750,10 +1978,10 @@ "minNotional": 0.0, "maxNotional": 5000.0, "maintenanceMarginRate": 0.02, - "maxLeverage": 20.0, + "maxLeverage": 10.0, "info": { "bracket": "1", - "initialLeverage": "20", + "initialLeverage": "10", "notionalCap": "5000", "notionalFloor": "0", "maintMarginRatio": "0.02", @@ -1766,10 +1994,10 @@ "minNotional": 5000.0, "maxNotional": 25000.0, "maintenanceMarginRate": 0.025, - "maxLeverage": 15.0, + "maxLeverage": 8.0, "info": { "bracket": "2", - "initialLeverage": "15", + "initialLeverage": "8", "notionalCap": "25000", "notionalFloor": "5000", "maintMarginRatio": "0.025", @@ -1782,10 +2010,10 @@ "minNotional": 25000.0, "maxNotional": 200000.0, "maintenanceMarginRate": 0.05, - "maxLeverage": 10.0, + "maxLeverage": 6.0, "info": { "bracket": "3", - "initialLeverage": "10", + "initialLeverage": "6", "notionalCap": "200000", "notionalFloor": "25000", "maintMarginRatio": "0.05", @@ -1844,13 +2072,13 @@ "tier": 7.0, "currency": "USDT", "minNotional": 3000000.0, - "maxNotional": 5000000.0, + "maxNotional": 3500000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "7", "initialLeverage": "1", - "notionalCap": "5000000", + "notionalCap": "3500000", "notionalFloor": "3000000", "maintMarginRatio": "0.5", "cum": "898150.0" @@ -2174,10 +2402,10 @@ "minNotional": 0.0, "maxNotional": 5000.0, "maintenanceMarginRate": 0.02, - "maxLeverage": 20.0, + "maxLeverage": 10.0, "info": { "bracket": "1", - "initialLeverage": "20", + "initialLeverage": "10", "notionalCap": "5000", "notionalFloor": "0", "maintMarginRatio": "0.02", @@ -2190,10 +2418,10 @@ "minNotional": 5000.0, "maxNotional": 25000.0, "maintenanceMarginRate": 0.025, - "maxLeverage": 10.0, + "maxLeverage": 8.0, "info": { "bracket": "2", - "initialLeverage": "10", + "initialLeverage": "8", "notionalCap": "25000", "notionalFloor": "5000", "maintMarginRatio": "0.025", @@ -2206,10 +2434,10 @@ "minNotional": 25000.0, "maxNotional": 100000.0, "maintenanceMarginRate": 0.05, - "maxLeverage": 8.0, + "maxLeverage": 6.0, "info": { "bracket": "3", - "initialLeverage": "8", + "initialLeverage": "6", "notionalCap": "100000", "notionalFloor": "25000", "maintMarginRatio": "0.05", @@ -2252,13 +2480,13 @@ "tier": 6.0, "currency": "BUSD", "minNotional": 1000000.0, - "maxNotional": 5000000.0, + "maxNotional": 1200000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "6", "initialLeverage": "1", - "notionalCap": "5000000", + "notionalCap": "1200000", "notionalFloor": "1000000", "maintMarginRatio": "0.5", "cum": "386900.0" @@ -2904,13 +3132,13 @@ "tier": 3.0, "currency": "USDT", "minNotional": 50000.0, - "maxNotional": 400000.0, + "maxNotional": 600000.0, "maintenanceMarginRate": 0.025, "maxLeverage": 20.0, "info": { "bracket": "3", "initialLeverage": "20", - "notionalCap": "400000", + "notionalCap": "600000", "notionalFloor": "50000", "maintMarginRatio": "0.025", "cum": "770.0" @@ -2919,39 +3147,39 @@ { "tier": 4.0, "currency": "USDT", - "minNotional": 400000.0, - "maxNotional": 800000.0, + "minNotional": 600000.0, + "maxNotional": 1200000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 10.0, "info": { "bracket": "4", "initialLeverage": "10", - "notionalCap": "800000", - "notionalFloor": "400000", + "notionalCap": "1200000", + "notionalFloor": "600000", "maintMarginRatio": "0.05", - "cum": "10770.0" + "cum": "15770.0" } }, { "tier": 5.0, "currency": "USDT", - "minNotional": 800000.0, - "maxNotional": 2000000.0, + "minNotional": 1200000.0, + "maxNotional": 3000000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "5", "initialLeverage": "5", - "notionalCap": "2000000", - "notionalFloor": "800000", + "notionalCap": "3000000", + "notionalFloor": "1200000", "maintMarginRatio": "0.1", - "cum": "50770.0" + "cum": "75770.0" } }, { "tier": 6.0, "currency": "USDT", - "minNotional": 2000000.0, + "minNotional": 3000000.0, "maxNotional": 5000000.0, "maintenanceMarginRate": 0.125, "maxLeverage": 4.0, @@ -2959,9 +3187,9 @@ "bracket": "6", "initialLeverage": "4", "notionalCap": "5000000", - "notionalFloor": "2000000", + "notionalFloor": "3000000", "maintMarginRatio": "0.125", - "cum": "100770.0" + "cum": "150770.0" } }, { @@ -2977,7 +3205,7 @@ "notionalCap": "12000000", "notionalFloor": "5000000", "maintMarginRatio": "0.25", - "cum": "725770.0" + "cum": "775770.0" } }, { @@ -2993,7 +3221,7 @@ "notionalCap": "20000000", "notionalFloor": "12000000", "maintMarginRatio": "0.5", - "cum": "3725770.0" + "cum": "3775770.0" } } ], @@ -4646,13 +4874,13 @@ "tier": 2.0, "currency": "USDT", "minNotional": 5000.0, - "maxNotional": 25000.0, + "maxNotional": 50000.0, "maintenanceMarginRate": 0.025, "maxLeverage": 10.0, "info": { "bracket": "2", "initialLeverage": "10", - "notionalCap": "25000", + "notionalCap": "50000", "notionalFloor": "5000", "maintMarginRatio": "0.025", "cum": "25.0" @@ -4661,65 +4889,81 @@ { "tier": 3.0, "currency": "USDT", - "minNotional": 25000.0, - "maxNotional": 100000.0, + "minNotional": 50000.0, + "maxNotional": 200000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 8.0, "info": { "bracket": "3", "initialLeverage": "8", - "notionalCap": "100000", - "notionalFloor": "25000", + "notionalCap": "200000", + "notionalFloor": "50000", "maintMarginRatio": "0.05", - "cum": "650.0" + "cum": "1275.0" } }, { "tier": 4.0, "currency": "USDT", - "minNotional": 100000.0, - "maxNotional": 250000.0, + "minNotional": 200000.0, + "maxNotional": 500000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "4", "initialLeverage": "5", - "notionalCap": "250000", - "notionalFloor": "100000", + "notionalCap": "500000", + "notionalFloor": "200000", "maintMarginRatio": "0.1", - "cum": "5650.0" + "cum": "11275.0" } }, { "tier": 5.0, "currency": "USDT", - "minNotional": 250000.0, + "minNotional": 500000.0, "maxNotional": 1000000.0, "maintenanceMarginRate": 0.125, - "maxLeverage": 2.0, + "maxLeverage": 4.0, "info": { "bracket": "5", - "initialLeverage": "2", + "initialLeverage": "4", "notionalCap": "1000000", - "notionalFloor": "250000", + "notionalFloor": "500000", "maintMarginRatio": "0.125", - "cum": "11900.0" + "cum": "23775.0" } }, { "tier": 6.0, "currency": "USDT", "minNotional": 1000000.0, - "maxNotional": 2000000.0, + "maxNotional": 1500000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "1500000", + "notionalFloor": "1000000", + "maintMarginRatio": "0.25", + "cum": "148775.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 1500000.0, + "maxNotional": 3000000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { - "bracket": "6", + "bracket": "7", "initialLeverage": "1", - "notionalCap": "2000000", - "notionalFloor": "1000000", + "notionalCap": "3000000", + "notionalFloor": "1500000", "maintMarginRatio": "0.5", - "cum": "386900.0" + "cum": "523775.0" } } ], @@ -4821,6 +5065,120 @@ } } ], + "BLUR/USDT:USDT": [ + { + "tier": 1.0, + "currency": "USDT", + "minNotional": 0.0, + "maxNotional": 5000.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 25.0, + "info": { + "bracket": "1", + "initialLeverage": "25", + "notionalCap": "5000", + "notionalFloor": "0", + "maintMarginRatio": "0.02", + "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": "25.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 25000.0, + "maxNotional": 600000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "3", + "initialLeverage": "10", + "notionalCap": "600000", + "notionalFloor": "25000", + "maintMarginRatio": "0.05", + "cum": "650.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 600000.0, + "maxNotional": 1600000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "4", + "initialLeverage": "5", + "notionalCap": "1600000", + "notionalFloor": "600000", + "maintMarginRatio": "0.1", + "cum": "30650.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 1600000.0, + "maxNotional": 2000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "5", + "initialLeverage": "4", + "notionalCap": "2000000", + "notionalFloor": "1600000", + "maintMarginRatio": "0.125", + "cum": "70650.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 2000000.0, + "maxNotional": 6000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "6000000", + "notionalFloor": "2000000", + "maintMarginRatio": "0.25", + "cum": "320650.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 6000000.0, + "maxNotional": 10000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "7", + "initialLeverage": "1", + "notionalCap": "10000000", + "notionalFloor": "6000000", + "maintMarginRatio": "0.5", + "cum": "1820650.0" + } + } + ], "BLZ/USDT:USDT": [ { "tier": 1.0, @@ -6372,13 +6730,13 @@ "tier": 2.0, "currency": "USDT", "minNotional": 5000.0, - "maxNotional": 25000.0, + "maxNotional": 50000.0, "maintenanceMarginRate": 0.015, "maxLeverage": 25.0, "info": { "bracket": "2", "initialLeverage": "25", - "notionalCap": "25000", + "notionalCap": "50000", "notionalFloor": "5000", "maintMarginRatio": "0.015", "cum": "25.0" @@ -6387,23 +6745,23 @@ { "tier": 3.0, "currency": "USDT", - "minNotional": 25000.0, - "maxNotional": 300000.0, + "minNotional": 50000.0, + "maxNotional": 400000.0, "maintenanceMarginRate": 0.02, "maxLeverage": 20.0, "info": { "bracket": "3", "initialLeverage": "20", - "notionalCap": "300000", - "notionalFloor": "25000", + "notionalCap": "400000", + "notionalFloor": "50000", "maintMarginRatio": "0.02", - "cum": "150.0" + "cum": "275.0" } }, { "tier": 4.0, "currency": "USDT", - "minNotional": 300000.0, + "minNotional": 400000.0, "maxNotional": 1200000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 10.0, @@ -6411,9 +6769,9 @@ "bracket": "4", "initialLeverage": "10", "notionalCap": "1200000", - "notionalFloor": "300000", + "notionalFloor": "400000", "maintMarginRatio": "0.05", - "cum": "9150.0" + "cum": "12275.0" } }, { @@ -6429,7 +6787,7 @@ "notionalCap": "3000000", "notionalFloor": "1200000", "maintMarginRatio": "0.1", - "cum": "69150.0" + "cum": "72275.0" } }, { @@ -6445,7 +6803,7 @@ "notionalCap": "6000000", "notionalFloor": "3000000", "maintMarginRatio": "0.125", - "cum": "144150.0" + "cum": "147275.0" } }, { @@ -6461,7 +6819,7 @@ "notionalCap": "18000000", "notionalFloor": "6000000", "maintMarginRatio": "0.25", - "cum": "894150.0" + "cum": "897275.0" } }, { @@ -6477,7 +6835,7 @@ "notionalCap": "30000000", "notionalFloor": "18000000", "maintMarginRatio": "0.5", - "cum": "5394150.0" + "cum": "5397275.0" } } ], @@ -6798,10 +7156,10 @@ "minNotional": 0.0, "maxNotional": 5000.0, "maintenanceMarginRate": 0.02, - "maxLeverage": 20.0, + "maxLeverage": 8.0, "info": { "bracket": "1", - "initialLeverage": "20", + "initialLeverage": "8", "notionalCap": "5000", "notionalFloor": "0", "maintMarginRatio": "0.02", @@ -6814,10 +7172,10 @@ "minNotional": 5000.0, "maxNotional": 25000.0, "maintenanceMarginRate": 0.025, - "maxLeverage": 15.0, + "maxLeverage": 7.0, "info": { "bracket": "2", - "initialLeverage": "15", + "initialLeverage": "7", "notionalCap": "25000", "notionalFloor": "5000", "maintMarginRatio": "0.025", @@ -6830,10 +7188,10 @@ "minNotional": 25000.0, "maxNotional": 100000.0, "maintenanceMarginRate": 0.05, - "maxLeverage": 10.0, + "maxLeverage": 6.0, "info": { "bracket": "3", - "initialLeverage": "10", + "initialLeverage": "6", "notionalCap": "100000", "notionalFloor": "25000", "maintMarginRatio": "0.05", @@ -6876,19 +7234,133 @@ "tier": 6.0, "currency": "USDT", "minNotional": 1000000.0, - "maxNotional": 5000000.0, + "maxNotional": 1500000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "6", "initialLeverage": "1", - "notionalCap": "5000000", + "notionalCap": "1500000", "notionalFloor": "1000000", "maintMarginRatio": "0.5", "cum": "386900.0" } } ], + "COMBO/USDT:USDT": [ + { + "tier": 1.0, + "currency": "USDT", + "minNotional": 0.0, + "maxNotional": 5000.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 20.0, + "info": { + "bracket": "1", + "initialLeverage": "20", + "notionalCap": "5000", + "notionalFloor": "0", + "maintMarginRatio": "0.02", + "cum": "0.0" + } + }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 5000.0, + "maxNotional": 25000.0, + "maintenanceMarginRate": 0.025, + "maxLeverage": 15.0, + "info": { + "bracket": "2", + "initialLeverage": "15", + "notionalCap": "25000", + "notionalFloor": "5000", + "maintMarginRatio": "0.025", + "cum": "25.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 25000.0, + "maxNotional": 200000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "3", + "initialLeverage": "10", + "notionalCap": "200000", + "notionalFloor": "25000", + "maintMarginRatio": "0.05", + "cum": "650.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 200000.0, + "maxNotional": 500000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "4", + "initialLeverage": "5", + "notionalCap": "500000", + "notionalFloor": "200000", + "maintMarginRatio": "0.1", + "cum": "10650.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 500000.0, + "maxNotional": 1000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "5", + "initialLeverage": "4", + "notionalCap": "1000000", + "notionalFloor": "500000", + "maintMarginRatio": "0.125", + "cum": "23150.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 1000000.0, + "maxNotional": 3000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "3000000", + "notionalFloor": "1000000", + "maintMarginRatio": "0.25", + "cum": "148150.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 3000000.0, + "maxNotional": 5000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "7", + "initialLeverage": "1", + "notionalCap": "5000000", + "notionalFloor": "3000000", + "maintMarginRatio": "0.5", + "cum": "898150.0" + } + } + ], "COMP/USDT:USDT": [ { "tier": 1.0, @@ -8544,10 +9016,10 @@ "minNotional": 0.0, "maxNotional": 5000.0, "maintenanceMarginRate": 0.02, - "maxLeverage": 25.0, + "maxLeverage": 10.0, "info": { "bracket": "1", - "initialLeverage": "25", + "initialLeverage": "10", "notionalCap": "5000", "notionalFloor": "0", "maintMarginRatio": "0.02", @@ -8560,10 +9032,10 @@ "minNotional": 5000.0, "maxNotional": 25000.0, "maintenanceMarginRate": 0.025, - "maxLeverage": 15.0, + "maxLeverage": 8.0, "info": { "bracket": "2", - "initialLeverage": "15", + "initialLeverage": "8", "notionalCap": "25000", "notionalFloor": "5000", "maintMarginRatio": "0.025", @@ -8576,10 +9048,10 @@ "minNotional": 25000.0, "maxNotional": 100000.0, "maintenanceMarginRate": 0.05, - "maxLeverage": 10.0, + "maxLeverage": 6.0, "info": { "bracket": "3", - "initialLeverage": "10", + "initialLeverage": "6", "notionalCap": "100000", "notionalFloor": "25000", "maintMarginRatio": "0.05", @@ -8638,13 +9110,13 @@ "tier": 7.0, "currency": "BUSD", "minNotional": 3000000.0, - "maxNotional": 8000000.0, + "maxNotional": 4000000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "7", "initialLeverage": "1", - "notionalCap": "8000000", + "notionalCap": "4000000", "notionalFloor": "3000000", "maintMarginRatio": "0.5", "cum": "949400.0" @@ -9041,6 +9513,120 @@ } } ], + "EDU/USDT:USDT": [ + { + "tier": 1.0, + "currency": "USDT", + "minNotional": 0.0, + "maxNotional": 5000.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 20.0, + "info": { + "bracket": "1", + "initialLeverage": "20", + "notionalCap": "5000", + "notionalFloor": "0", + "maintMarginRatio": "0.02", + "cum": "0.0" + } + }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 5000.0, + "maxNotional": 25000.0, + "maintenanceMarginRate": 0.025, + "maxLeverage": 15.0, + "info": { + "bracket": "2", + "initialLeverage": "15", + "notionalCap": "25000", + "notionalFloor": "5000", + "maintMarginRatio": "0.025", + "cum": "25.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 25000.0, + "maxNotional": 200000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "3", + "initialLeverage": "10", + "notionalCap": "200000", + "notionalFloor": "25000", + "maintMarginRatio": "0.05", + "cum": "650.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 200000.0, + "maxNotional": 500000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "4", + "initialLeverage": "5", + "notionalCap": "500000", + "notionalFloor": "200000", + "maintMarginRatio": "0.1", + "cum": "10650.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 500000.0, + "maxNotional": 1000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "5", + "initialLeverage": "4", + "notionalCap": "1000000", + "notionalFloor": "500000", + "maintMarginRatio": "0.125", + "cum": "23150.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 1000000.0, + "maxNotional": 3000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "3000000", + "notionalFloor": "1000000", + "maintMarginRatio": "0.25", + "cum": "148150.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 3000000.0, + "maxNotional": 5000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "7", + "initialLeverage": "1", + "notionalCap": "5000000", + "notionalFloor": "3000000", + "maintMarginRatio": "0.5", + "cum": "898150.0" + } + } + ], "EGLD/USDT:USDT": [ { "tier": 1.0, @@ -9552,10 +10138,10 @@ "minNotional": 0.0, "maxNotional": 5000.0, "maintenanceMarginRate": 0.02, - "maxLeverage": 20.0, + "maxLeverage": 8.0, "info": { "bracket": "1", - "initialLeverage": "20", + "initialLeverage": "8", "notionalCap": "5000", "notionalFloor": "0", "maintMarginRatio": "0.02", @@ -9568,10 +10154,10 @@ "minNotional": 5000.0, "maxNotional": 25000.0, "maintenanceMarginRate": 0.025, - "maxLeverage": 10.0, + "maxLeverage": 7.0, "info": { "bracket": "2", - "initialLeverage": "10", + "initialLeverage": "7", "notionalCap": "25000", "notionalFloor": "5000", "maintMarginRatio": "0.025", @@ -9584,10 +10170,10 @@ "minNotional": 25000.0, "maxNotional": 100000.0, "maintenanceMarginRate": 0.05, - "maxLeverage": 8.0, + "maxLeverage": 6.0, "info": { "bracket": "3", - "initialLeverage": "8", + "initialLeverage": "6", "notionalCap": "100000", "notionalFloor": "25000", "maintMarginRatio": "0.05", @@ -9630,13 +10216,13 @@ "tier": 6.0, "currency": "BUSD", "minNotional": 1000000.0, - "maxNotional": 5000000.0, + "maxNotional": 1500000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "6", "initialLeverage": "1", - "notionalCap": "5000000", + "notionalCap": "1500000", "notionalFloor": "1000000", "maintMarginRatio": "0.5", "cum": "386900.0" @@ -9696,13 +10282,13 @@ "tier": 4.0, "currency": "USDT", "minNotional": 50000.0, - "maxNotional": 250000.0, + "maxNotional": 400000.0, "maintenanceMarginRate": 0.02, "maxLeverage": 25.0, "info": { "bracket": "4", "initialLeverage": "25", - "notionalCap": "250000", + "notionalCap": "400000", "notionalFloor": "50000", "maintMarginRatio": "0.02", "cum": "545.0" @@ -9711,39 +10297,39 @@ { "tier": 5.0, "currency": "USDT", - "minNotional": 250000.0, - "maxNotional": 1000000.0, + "minNotional": 400000.0, + "maxNotional": 2000000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 10.0, "info": { "bracket": "5", "initialLeverage": "10", - "notionalCap": "1000000", - "notionalFloor": "250000", + "notionalCap": "2000000", + "notionalFloor": "400000", "maintMarginRatio": "0.05", - "cum": "8045.0" + "cum": "12545.0" } }, { "tier": 6.0, "currency": "USDT", - "minNotional": 1000000.0, - "maxNotional": 5000000.0, + "minNotional": 2000000.0, + "maxNotional": 8000000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "6", "initialLeverage": "5", - "notionalCap": "5000000", - "notionalFloor": "1000000", + "notionalCap": "8000000", + "notionalFloor": "2000000", "maintMarginRatio": "0.1", - "cum": "58045.0" + "cum": "112545.0" } }, { "tier": 7.0, "currency": "USDT", - "minNotional": 5000000.0, + "minNotional": 8000000.0, "maxNotional": 10000000.0, "maintenanceMarginRate": 0.125, "maxLeverage": 4.0, @@ -9751,9 +10337,9 @@ "bracket": "7", "initialLeverage": "4", "notionalCap": "10000000", - "notionalFloor": "5000000", + "notionalFloor": "8000000", "maintMarginRatio": "0.125", - "cum": "183045.0" + "cum": "312545.0" } }, { @@ -9769,7 +10355,7 @@ "notionalCap": "20000000", "notionalFloor": "10000000", "maintMarginRatio": "0.15", - "cum": "433045.0" + "cum": "562545.0" } }, { @@ -9785,7 +10371,7 @@ "notionalCap": "30000000", "notionalFloor": "20000000", "maintMarginRatio": "0.25", - "cum": "2433045.0" + "cum": "2562545.0" } }, { @@ -9801,7 +10387,169 @@ "notionalCap": "50000000", "notionalFloor": "30000000", "maintMarginRatio": "0.5", - "cum": "9933045.0" + "cum": "1.0062545E7" + } + } + ], + "ETH/BTC:BTC": [ + { + "tier": 1.0, + "currency": "BTC", + "minNotional": 0.0, + "maxNotional": 5.0, + "maintenanceMarginRate": 0.005, + "maxLeverage": 100.0, + "info": { + "bracket": "1", + "initialLeverage": "100", + "notionalCap": "5", + "notionalFloor": "0", + "maintMarginRatio": "0.005", + "cum": "0.0" + } + }, + { + "tier": 2.0, + "currency": "BTC", + "minNotional": 5.0, + "maxNotional": 10.0, + "maintenanceMarginRate": 0.006, + "maxLeverage": 75.0, + "info": { + "bracket": "2", + "initialLeverage": "75", + "notionalCap": "10", + "notionalFloor": "5", + "maintMarginRatio": "0.006", + "cum": "0.005" + } + }, + { + "tier": 3.0, + "currency": "BTC", + "minNotional": 10.0, + "maxNotional": 100.0, + "maintenanceMarginRate": 0.01, + "maxLeverage": 50.0, + "info": { + "bracket": "3", + "initialLeverage": "50", + "notionalCap": "100", + "notionalFloor": "10", + "maintMarginRatio": "0.01", + "cum": "0.045" + } + }, + { + "tier": 4.0, + "currency": "BTC", + "minNotional": 100.0, + "maxNotional": 400.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 20.0, + "info": { + "bracket": "4", + "initialLeverage": "20", + "notionalCap": "400", + "notionalFloor": "100", + "maintMarginRatio": "0.02", + "cum": "1.045" + } + }, + { + "tier": 5.0, + "currency": "BTC", + "minNotional": 400.0, + "maxNotional": 800.0, + "maintenanceMarginRate": 0.025, + "maxLeverage": 10.0, + "info": { + "bracket": "5", + "initialLeverage": "10", + "notionalCap": "800", + "notionalFloor": "400", + "maintMarginRatio": "0.025", + "cum": "3.045" + } + }, + { + "tier": 6.0, + "currency": "BTC", + "minNotional": 800.0, + "maxNotional": 1500.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 8.0, + "info": { + "bracket": "6", + "initialLeverage": "8", + "notionalCap": "1500", + "notionalFloor": "800", + "maintMarginRatio": "0.05", + "cum": "23.045" + } + }, + { + "tier": 7.0, + "currency": "BTC", + "minNotional": 1500.0, + "maxNotional": 2000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "7", + "initialLeverage": "5", + "notionalCap": "2000", + "notionalFloor": "1500", + "maintMarginRatio": "0.1", + "cum": "98.045" + } + }, + { + "tier": 8.0, + "currency": "BTC", + "minNotional": 2000.0, + "maxNotional": 3000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "8", + "initialLeverage": "4", + "notionalCap": "3000", + "notionalFloor": "2000", + "maintMarginRatio": "0.125", + "cum": "148.045" + } + }, + { + "tier": 9.0, + "currency": "BTC", + "minNotional": 3000.0, + "maxNotional": 5000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "9", + "initialLeverage": "2", + "notionalCap": "5000", + "notionalFloor": "3000", + "maintMarginRatio": "0.25", + "cum": "523.045" + } + }, + { + "tier": 10.0, + "currency": "BTC", + "minNotional": 5000.0, + "maxNotional": 10000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "10", + "initialLeverage": "1", + "notionalCap": "10000", + "notionalFloor": "5000", + "maintMarginRatio": "0.5", + "cum": "1773.045" } } ], @@ -10364,10 +11112,10 @@ "minNotional": 0.0, "maxNotional": 5000.0, "maintenanceMarginRate": 0.02, - "maxLeverage": 20.0, + "maxLeverage": 8.0, "info": { "bracket": "1", - "initialLeverage": "20", + "initialLeverage": "8", "notionalCap": "5000", "notionalFloor": "0", "maintMarginRatio": "0.02", @@ -10380,10 +11128,10 @@ "minNotional": 5000.0, "maxNotional": 25000.0, "maintenanceMarginRate": 0.025, - "maxLeverage": 10.0, + "maxLeverage": 7.0, "info": { "bracket": "2", - "initialLeverage": "10", + "initialLeverage": "7", "notionalCap": "25000", "notionalFloor": "5000", "maintMarginRatio": "0.025", @@ -10396,10 +11144,10 @@ "minNotional": 25000.0, "maxNotional": 100000.0, "maintenanceMarginRate": 0.05, - "maxLeverage": 8.0, + "maxLeverage": 6.0, "info": { "bracket": "3", - "initialLeverage": "8", + "initialLeverage": "6", "notionalCap": "100000", "notionalFloor": "25000", "maintMarginRatio": "0.05", @@ -10442,13 +11190,13 @@ "tier": 6.0, "currency": "BUSD", "minNotional": 1000000.0, - "maxNotional": 5000000.0, + "maxNotional": 2000000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "6", "initialLeverage": "1", - "notionalCap": "5000000", + "notionalCap": "2000000", "notionalFloor": "1000000", "maintMarginRatio": "0.5", "cum": "386900.0" @@ -11976,13 +12724,13 @@ "tier": 3.0, "currency": "USDT", "minNotional": 150000.0, - "maxNotional": 250000.0, + "maxNotional": 600000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 10.0, "info": { "bracket": "3", "initialLeverage": "10", - "notionalCap": "250000", + "notionalCap": "600000", "notionalFloor": "150000", "maintMarginRatio": "0.05", "cum": "4500.0" @@ -11991,55 +12739,55 @@ { "tier": 4.0, "currency": "USDT", - "minNotional": 250000.0, - "maxNotional": 500000.0, + "minNotional": 600000.0, + "maxNotional": 1600000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "4", "initialLeverage": "5", - "notionalCap": "500000", - "notionalFloor": "250000", + "notionalCap": "1600000", + "notionalFloor": "600000", "maintMarginRatio": "0.1", - "cum": "17000.0" + "cum": "34500.0" } }, { "tier": 5.0, "currency": "USDT", - "minNotional": 500000.0, - "maxNotional": 1000000.0, + "minNotional": 1600000.0, + "maxNotional": 2000000.0, "maintenanceMarginRate": 0.125, "maxLeverage": 4.0, "info": { "bracket": "5", "initialLeverage": "4", - "notionalCap": "1000000", - "notionalFloor": "500000", + "notionalCap": "2000000", + "notionalFloor": "1600000", "maintMarginRatio": "0.125", - "cum": "29500.0" + "cum": "74500.0" } }, { "tier": 6.0, "currency": "USDT", - "minNotional": 1000000.0, - "maxNotional": 2000000.0, + "minNotional": 2000000.0, + "maxNotional": 4000000.0, "maintenanceMarginRate": 0.25, "maxLeverage": 2.0, "info": { "bracket": "6", "initialLeverage": "2", - "notionalCap": "2000000", - "notionalFloor": "1000000", + "notionalCap": "4000000", + "notionalFloor": "2000000", "maintMarginRatio": "0.25", - "cum": "154500.0" + "cum": "324500.0" } }, { "tier": 7.0, "currency": "USDT", - "minNotional": 2000000.0, + "minNotional": 4000000.0, "maxNotional": 30000000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, @@ -12047,9 +12795,9 @@ "bracket": "7", "initialLeverage": "1", "notionalCap": "30000000", - "notionalFloor": "2000000", + "notionalFloor": "4000000", "maintMarginRatio": "0.5", - "cum": "654500.0" + "cum": "1324500.0" } } ], @@ -13341,6 +14089,120 @@ } } ], + "IDEX/USDT:USDT": [ + { + "tier": 1.0, + "currency": "USDT", + "minNotional": 0.0, + "maxNotional": 5000.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 20.0, + "info": { + "bracket": "1", + "initialLeverage": "20", + "notionalCap": "5000", + "notionalFloor": "0", + "maintMarginRatio": "0.02", + "cum": "0.0" + } + }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 5000.0, + "maxNotional": 25000.0, + "maintenanceMarginRate": 0.025, + "maxLeverage": 15.0, + "info": { + "bracket": "2", + "initialLeverage": "15", + "notionalCap": "25000", + "notionalFloor": "5000", + "maintMarginRatio": "0.025", + "cum": "25.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 25000.0, + "maxNotional": 200000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "3", + "initialLeverage": "10", + "notionalCap": "200000", + "notionalFloor": "25000", + "maintMarginRatio": "0.05", + "cum": "650.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 200000.0, + "maxNotional": 500000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "4", + "initialLeverage": "5", + "notionalCap": "500000", + "notionalFloor": "200000", + "maintMarginRatio": "0.1", + "cum": "10650.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 500000.0, + "maxNotional": 1000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "5", + "initialLeverage": "4", + "notionalCap": "1000000", + "notionalFloor": "500000", + "maintMarginRatio": "0.125", + "cum": "23150.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 1000000.0, + "maxNotional": 3000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "3000000", + "notionalFloor": "1000000", + "maintMarginRatio": "0.25", + "cum": "148150.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 3000000.0, + "maxNotional": 5000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "7", + "initialLeverage": "1", + "notionalCap": "5000000", + "notionalFloor": "3000000", + "maintMarginRatio": "0.5", + "cum": "898150.0" + } + } + ], "IMX/USDT:USDT": [ { "tier": 1.0, @@ -13492,13 +14354,13 @@ "tier": 3.0, "currency": "USDT", "minNotional": 25000.0, - "maxNotional": 600000.0, + "maxNotional": 1200000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 10.0, "info": { "bracket": "3", "initialLeverage": "10", - "notionalCap": "600000", + "notionalCap": "1200000", "notionalFloor": "25000", "maintMarginRatio": "0.05", "cum": "650.0" @@ -13507,65 +14369,65 @@ { "tier": 4.0, "currency": "USDT", - "minNotional": 600000.0, - "maxNotional": 1600000.0, + "minNotional": 1200000.0, + "maxNotional": 3200000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "4", "initialLeverage": "5", - "notionalCap": "1600000", - "notionalFloor": "600000", + "notionalCap": "3200000", + "notionalFloor": "1200000", "maintMarginRatio": "0.1", - "cum": "30650.0" + "cum": "60650.0" } }, { "tier": 5.0, "currency": "USDT", - "minNotional": 1600000.0, - "maxNotional": 2000000.0, + "minNotional": 3200000.0, + "maxNotional": 4000000.0, "maintenanceMarginRate": 0.125, "maxLeverage": 4.0, "info": { "bracket": "5", "initialLeverage": "4", - "notionalCap": "2000000", - "notionalFloor": "1600000", + "notionalCap": "4000000", + "notionalFloor": "3200000", "maintMarginRatio": "0.125", - "cum": "70650.0" + "cum": "140650.0" } }, { "tier": 6.0, "currency": "USDT", - "minNotional": 2000000.0, - "maxNotional": 6000000.0, + "minNotional": 4000000.0, + "maxNotional": 12000000.0, "maintenanceMarginRate": 0.25, "maxLeverage": 2.0, "info": { "bracket": "6", "initialLeverage": "2", - "notionalCap": "6000000", - "notionalFloor": "2000000", + "notionalCap": "12000000", + "notionalFloor": "4000000", "maintMarginRatio": "0.25", - "cum": "320650.0" + "cum": "640650.0" } }, { "tier": 7.0, "currency": "USDT", - "minNotional": 6000000.0, - "maxNotional": 10000000.0, + "minNotional": 12000000.0, + "maxNotional": 20000000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "7", "initialLeverage": "1", - "notionalCap": "10000000", - "notionalFloor": "6000000", + "notionalCap": "20000000", + "notionalFloor": "12000000", "maintMarginRatio": "0.5", - "cum": "1820650.0" + "cum": "3640650.0" } } ], @@ -14205,6 +15067,120 @@ } } ], + "KEY/USDT:USDT": [ + { + "tier": 1.0, + "currency": "USDT", + "minNotional": 0.0, + "maxNotional": 5000.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 20.0, + "info": { + "bracket": "1", + "initialLeverage": "20", + "notionalCap": "5000", + "notionalFloor": "0", + "maintMarginRatio": "0.02", + "cum": "0.0" + } + }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 5000.0, + "maxNotional": 25000.0, + "maintenanceMarginRate": 0.025, + "maxLeverage": 15.0, + "info": { + "bracket": "2", + "initialLeverage": "15", + "notionalCap": "25000", + "notionalFloor": "5000", + "maintMarginRatio": "0.025", + "cum": "25.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 25000.0, + "maxNotional": 200000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "3", + "initialLeverage": "10", + "notionalCap": "200000", + "notionalFloor": "25000", + "maintMarginRatio": "0.05", + "cum": "650.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 200000.0, + "maxNotional": 500000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "4", + "initialLeverage": "5", + "notionalCap": "500000", + "notionalFloor": "200000", + "maintMarginRatio": "0.1", + "cum": "10650.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 500000.0, + "maxNotional": 1000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "5", + "initialLeverage": "4", + "notionalCap": "1000000", + "notionalFloor": "500000", + "maintMarginRatio": "0.125", + "cum": "23150.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 1000000.0, + "maxNotional": 3000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "3000000", + "notionalFloor": "1000000", + "maintMarginRatio": "0.25", + "cum": "148150.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 3000000.0, + "maxNotional": 5000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "7", + "initialLeverage": "1", + "notionalCap": "5000000", + "notionalFloor": "3000000", + "maintMarginRatio": "0.5", + "cum": "898150.0" + } + } + ], "KLAY/USDT:USDT": [ { "tier": 1.0, @@ -14962,10 +15938,10 @@ "minNotional": 0.0, "maxNotional": 5000.0, "maintenanceMarginRate": 0.02, - "maxLeverage": 25.0, + "maxLeverage": 20.0, "info": { "bracket": "1", - "initialLeverage": "25", + "initialLeverage": "20", "notionalCap": "5000", "notionalFloor": "0", "maintMarginRatio": "0.02", @@ -14978,10 +15954,10 @@ "minNotional": 5000.0, "maxNotional": 25000.0, "maintenanceMarginRate": 0.025, - "maxLeverage": 20.0, + "maxLeverage": 15.0, "info": { "bracket": "2", - "initialLeverage": "20", + "initialLeverage": "15", "notionalCap": "25000", "notionalFloor": "5000", "maintMarginRatio": "0.025", @@ -15040,13 +16016,13 @@ "tier": 6.0, "currency": "USDT", "minNotional": 3000000.0, - "maxNotional": 9000000.0, + "maxNotional": 5000000.0, "maintenanceMarginRate": 0.25, "maxLeverage": 2.0, "info": { "bracket": "6", "initialLeverage": "2", - "notionalCap": "9000000", + "notionalCap": "5000000", "notionalFloor": "3000000", "maintMarginRatio": "0.25", "cum": "480650.0" @@ -15055,17 +16031,17 @@ { "tier": 7.0, "currency": "USDT", - "minNotional": 9000000.0, - "maxNotional": 15000000.0, + "minNotional": 5000000.0, + "maxNotional": 7000000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "7", "initialLeverage": "1", - "notionalCap": "15000000", - "notionalFloor": "9000000", + "notionalCap": "7000000", + "notionalFloor": "5000000", "maintMarginRatio": "0.5", - "cum": "2730650.0" + "cum": "1730650.0" } } ], @@ -15076,10 +16052,10 @@ "minNotional": 0.0, "maxNotional": 5000.0, "maintenanceMarginRate": 0.02, - "maxLeverage": 25.0, + "maxLeverage": 10.0, "info": { "bracket": "1", - "initialLeverage": "25", + "initialLeverage": "10", "notionalCap": "5000", "notionalFloor": "0", "maintMarginRatio": "0.02", @@ -15092,10 +16068,10 @@ "minNotional": 5000.0, "maxNotional": 25000.0, "maintenanceMarginRate": 0.025, - "maxLeverage": 15.0, + "maxLeverage": 8.0, "info": { "bracket": "2", - "initialLeverage": "15", + "initialLeverage": "8", "notionalCap": "25000", "notionalFloor": "5000", "maintMarginRatio": "0.025", @@ -15108,10 +16084,10 @@ "minNotional": 25000.0, "maxNotional": 100000.0, "maintenanceMarginRate": 0.05, - "maxLeverage": 10.0, + "maxLeverage": 6.0, "info": { "bracket": "3", - "initialLeverage": "10", + "initialLeverage": "6", "notionalCap": "100000", "notionalFloor": "25000", "maintMarginRatio": "0.05", @@ -15170,13 +16146,13 @@ "tier": 7.0, "currency": "BUSD", "minNotional": 3000000.0, - "maxNotional": 8000000.0, + "maxNotional": 4000000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "7", "initialLeverage": "1", - "notionalCap": "8000000", + "notionalCap": "4000000", "notionalFloor": "3000000", "maintMarginRatio": "0.5", "cum": "949400.0" @@ -15562,13 +16538,13 @@ "tier": 3.0, "currency": "USDT", "minNotional": 25000.0, - "maxNotional": 200000.0, + "maxNotional": 600000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 10.0, "info": { "bracket": "3", "initialLeverage": "10", - "notionalCap": "200000", + "notionalCap": "600000", "notionalFloor": "25000", "maintMarginRatio": "0.05", "cum": "650.0" @@ -15577,65 +16553,65 @@ { "tier": 4.0, "currency": "USDT", - "minNotional": 200000.0, - "maxNotional": 500000.0, + "minNotional": 600000.0, + "maxNotional": 1600000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "4", "initialLeverage": "5", - "notionalCap": "500000", - "notionalFloor": "200000", + "notionalCap": "1600000", + "notionalFloor": "600000", "maintMarginRatio": "0.1", - "cum": "10650.0" + "cum": "30650.0" } }, { "tier": 5.0, "currency": "USDT", - "minNotional": 500000.0, - "maxNotional": 1000000.0, + "minNotional": 1600000.0, + "maxNotional": 2000000.0, "maintenanceMarginRate": 0.125, "maxLeverage": 4.0, "info": { "bracket": "5", "initialLeverage": "4", - "notionalCap": "1000000", - "notionalFloor": "500000", + "notionalCap": "2000000", + "notionalFloor": "1600000", "maintMarginRatio": "0.125", - "cum": "23150.0" + "cum": "70650.0" } }, { "tier": 6.0, "currency": "USDT", - "minNotional": 1000000.0, - "maxNotional": 3000000.0, + "minNotional": 2000000.0, + "maxNotional": 6000000.0, "maintenanceMarginRate": 0.25, "maxLeverage": 2.0, "info": { "bracket": "6", "initialLeverage": "2", - "notionalCap": "3000000", - "notionalFloor": "1000000", + "notionalCap": "6000000", + "notionalFloor": "2000000", "maintMarginRatio": "0.25", - "cum": "148150.0" + "cum": "320650.0" } }, { "tier": 7.0, "currency": "USDT", - "minNotional": 3000000.0, - "maxNotional": 5000000.0, + "minNotional": 6000000.0, + "maxNotional": 10000000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "7", "initialLeverage": "1", - "notionalCap": "5000000", - "notionalFloor": "3000000", + "notionalCap": "10000000", + "notionalFloor": "6000000", "maintMarginRatio": "0.5", - "cum": "898150.0" + "cum": "1820650.0" } } ], @@ -15904,13 +16880,13 @@ "tier": 4.0, "currency": "USDT", "minNotional": 50000.0, - "maxNotional": 250000.0, + "maxNotional": 500000.0, "maintenanceMarginRate": 0.02, "maxLeverage": 25.0, "info": { "bracket": "4", "initialLeverage": "25", - "notionalCap": "250000", + "notionalCap": "500000", "notionalFloor": "50000", "maintMarginRatio": "0.02", "cum": "545.0" @@ -15919,39 +16895,39 @@ { "tier": 5.0, "currency": "USDT", - "minNotional": 250000.0, - "maxNotional": 1000000.0, + "minNotional": 500000.0, + "maxNotional": 2000000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 10.0, "info": { "bracket": "5", "initialLeverage": "10", - "notionalCap": "1000000", - "notionalFloor": "250000", + "notionalCap": "2000000", + "notionalFloor": "500000", "maintMarginRatio": "0.05", - "cum": "8045.0" + "cum": "15545.0" } }, { "tier": 6.0, "currency": "USDT", - "minNotional": 1000000.0, - "maxNotional": 5000000.0, + "minNotional": 2000000.0, + "maxNotional": 8000000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "6", "initialLeverage": "5", - "notionalCap": "5000000", - "notionalFloor": "1000000", + "notionalCap": "8000000", + "notionalFloor": "2000000", "maintMarginRatio": "0.1", - "cum": "58045.0" + "cum": "115545.0" } }, { "tier": 7.0, "currency": "USDT", - "minNotional": 5000000.0, + "minNotional": 8000000.0, "maxNotional": 10000000.0, "maintenanceMarginRate": 0.125, "maxLeverage": 4.0, @@ -15959,9 +16935,9 @@ "bracket": "7", "initialLeverage": "4", "notionalCap": "10000000", - "notionalFloor": "5000000", + "notionalFloor": "8000000", "maintMarginRatio": "0.125", - "cum": "183045.0" + "cum": "315545.0" } }, { @@ -15977,7 +16953,7 @@ "notionalCap": "20000000", "notionalFloor": "10000000", "maintMarginRatio": "0.15", - "cum": "433045.0" + "cum": "565545.0" } }, { @@ -15993,7 +16969,7 @@ "notionalCap": "30000000", "notionalFloor": "20000000", "maintMarginRatio": "0.25", - "cum": "2433045.0" + "cum": "2565545.0" } }, { @@ -16009,7 +16985,7 @@ "notionalCap": "50000000", "notionalFloor": "30000000", "maintMarginRatio": "0.5", - "cum": "9933045.0" + "cum": "1.0065545E7" } } ], @@ -17466,13 +18442,13 @@ "tier": 2.0, "currency": "USDT", "minNotional": 5000.0, - "maxNotional": 25000.0, + "maxNotional": 50000.0, "maintenanceMarginRate": 0.025, "maxLeverage": 10.0, "info": { "bracket": "2", "initialLeverage": "10", - "notionalCap": "25000", + "notionalCap": "50000", "notionalFloor": "5000", "maintMarginRatio": "0.025", "cum": "25.0" @@ -17481,39 +18457,39 @@ { "tier": 3.0, "currency": "USDT", - "minNotional": 25000.0, - "maxNotional": 100000.0, + "minNotional": 50000.0, + "maxNotional": 200000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 8.0, "info": { "bracket": "3", "initialLeverage": "8", - "notionalCap": "100000", - "notionalFloor": "25000", + "notionalCap": "200000", + "notionalFloor": "50000", "maintMarginRatio": "0.05", - "cum": "650.0" + "cum": "1275.0" } }, { "tier": 4.0, "currency": "USDT", - "minNotional": 100000.0, - "maxNotional": 250000.0, + "minNotional": 200000.0, + "maxNotional": 500000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "4", "initialLeverage": "5", - "notionalCap": "250000", - "notionalFloor": "100000", + "notionalCap": "500000", + "notionalFloor": "200000", "maintMarginRatio": "0.1", - "cum": "5650.0" + "cum": "11275.0" } }, { "tier": 5.0, "currency": "USDT", - "minNotional": 250000.0, + "minNotional": 500000.0, "maxNotional": 1000000.0, "maintenanceMarginRate": 0.125, "maxLeverage": 2.0, @@ -17521,9 +18497,9 @@ "bracket": "5", "initialLeverage": "2", "notionalCap": "1000000", - "notionalFloor": "250000", + "notionalFloor": "500000", "maintMarginRatio": "0.125", - "cum": "11900.0" + "cum": "23775.0" } }, { @@ -17539,7 +18515,7 @@ "notionalCap": "3000000", "notionalFloor": "1000000", "maintMarginRatio": "0.5", - "cum": "386900.0" + "cum": "398775.0" } } ], @@ -17746,10 +18722,10 @@ "minNotional": 0.0, "maxNotional": 5000.0, "maintenanceMarginRate": 0.02, - "maxLeverage": 20.0, + "maxLeverage": 25.0, "info": { "bracket": "1", - "initialLeverage": "20", + "initialLeverage": "25", "notionalCap": "5000", "notionalFloor": "0", "maintMarginRatio": "0.02", @@ -17762,10 +18738,10 @@ "minNotional": 5000.0, "maxNotional": 25000.0, "maintenanceMarginRate": 0.025, - "maxLeverage": 10.0, + "maxLeverage": 20.0, "info": { "bracket": "2", - "initialLeverage": "10", + "initialLeverage": "20", "notionalCap": "25000", "notionalFloor": "5000", "maintMarginRatio": "0.025", @@ -17778,10 +18754,10 @@ "minNotional": 25000.0, "maxNotional": 900000.0, "maintenanceMarginRate": 0.05, - "maxLeverage": 8.0, + "maxLeverage": 10.0, "info": { "bracket": "3", - "initialLeverage": "8", + "initialLeverage": "10", "notionalCap": "900000", "notionalFloor": "25000", "maintMarginRatio": "0.05", @@ -18102,13 +19078,13 @@ "tier": 3.0, "currency": "USDT", "minNotional": 50000.0, - "maxNotional": 400000.0, + "maxNotional": 600000.0, "maintenanceMarginRate": 0.025, "maxLeverage": 20.0, "info": { "bracket": "3", "initialLeverage": "20", - "notionalCap": "400000", + "notionalCap": "600000", "notionalFloor": "50000", "maintMarginRatio": "0.025", "cum": "770.0" @@ -18117,39 +19093,39 @@ { "tier": 4.0, "currency": "USDT", - "minNotional": 400000.0, - "maxNotional": 800000.0, + "minNotional": 600000.0, + "maxNotional": 1200000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 10.0, "info": { "bracket": "4", "initialLeverage": "10", - "notionalCap": "800000", - "notionalFloor": "400000", + "notionalCap": "1200000", + "notionalFloor": "600000", "maintMarginRatio": "0.05", - "cum": "10770.0" + "cum": "15770.0" } }, { "tier": 5.0, "currency": "USDT", - "minNotional": 800000.0, - "maxNotional": 2000000.0, + "minNotional": 1200000.0, + "maxNotional": 3000000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "5", "initialLeverage": "5", - "notionalCap": "2000000", - "notionalFloor": "800000", + "notionalCap": "3000000", + "notionalFloor": "1200000", "maintMarginRatio": "0.1", - "cum": "50770.0" + "cum": "75770.0" } }, { "tier": 6.0, "currency": "USDT", - "minNotional": 2000000.0, + "minNotional": 3000000.0, "maxNotional": 5000000.0, "maintenanceMarginRate": 0.125, "maxLeverage": 4.0, @@ -18157,9 +19133,9 @@ "bracket": "6", "initialLeverage": "4", "notionalCap": "5000000", - "notionalFloor": "2000000", + "notionalFloor": "3000000", "maintMarginRatio": "0.125", - "cum": "100770.0" + "cum": "150770.0" } }, { @@ -18175,7 +19151,7 @@ "notionalCap": "12000000", "notionalFloor": "5000000", "maintMarginRatio": "0.25", - "cum": "725770.0" + "cum": "775770.0" } }, { @@ -18191,7 +19167,7 @@ "notionalCap": "20000000", "notionalFloor": "12000000", "maintMarginRatio": "0.5", - "cum": "3725770.0" + "cum": "3775770.0" } } ], @@ -18202,10 +19178,10 @@ "minNotional": 0.0, "maxNotional": 5000.0, "maintenanceMarginRate": 0.02, - "maxLeverage": 20.0, + "maxLeverage": 25.0, "info": { "bracket": "1", - "initialLeverage": "20", + "initialLeverage": "25", "notionalCap": "5000", "notionalFloor": "0", "maintMarginRatio": "0.02", @@ -18218,10 +19194,10 @@ "minNotional": 5000.0, "maxNotional": 25000.0, "maintenanceMarginRate": 0.025, - "maxLeverage": 10.0, + "maxLeverage": 20.0, "info": { "bracket": "2", - "initialLeverage": "10", + "initialLeverage": "20", "notionalCap": "25000", "notionalFloor": "5000", "maintMarginRatio": "0.025", @@ -18232,13 +19208,13 @@ "tier": 3.0, "currency": "USDT", "minNotional": 25000.0, - "maxNotional": 100000.0, + "maxNotional": 300000.0, "maintenanceMarginRate": 0.05, - "maxLeverage": 8.0, + "maxLeverage": 10.0, "info": { "bracket": "3", - "initialLeverage": "8", - "notionalCap": "100000", + "initialLeverage": "10", + "notionalCap": "300000", "notionalFloor": "25000", "maintMarginRatio": "0.05", "cum": "650.0" @@ -18247,33 +19223,33 @@ { "tier": 4.0, "currency": "USDT", - "minNotional": 100000.0, - "maxNotional": 250000.0, + "minNotional": 300000.0, + "maxNotional": 800000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "4", "initialLeverage": "5", - "notionalCap": "250000", - "notionalFloor": "100000", + "notionalCap": "800000", + "notionalFloor": "300000", "maintMarginRatio": "0.1", - "cum": "5650.0" + "cum": "15650.0" } }, { "tier": 5.0, "currency": "USDT", - "minNotional": 250000.0, + "minNotional": 800000.0, "maxNotional": 1000000.0, "maintenanceMarginRate": 0.125, - "maxLeverage": 2.0, + "maxLeverage": 4.0, "info": { "bracket": "5", - "initialLeverage": "2", + "initialLeverage": "4", "notionalCap": "1000000", - "notionalFloor": "250000", + "notionalFloor": "800000", "maintMarginRatio": "0.125", - "cum": "11900.0" + "cum": "35650.0" } }, { @@ -18281,15 +19257,31 @@ "currency": "USDT", "minNotional": 1000000.0, "maxNotional": 3000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "3000000", + "notionalFloor": "1000000", + "maintMarginRatio": "0.25", + "cum": "160650.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 3000000.0, + "maxNotional": 5000000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { - "bracket": "6", + "bracket": "7", "initialLeverage": "1", - "notionalCap": "3000000", - "notionalFloor": "1000000", + "notionalCap": "5000000", + "notionalFloor": "3000000", "maintMarginRatio": "0.5", - "cum": "386900.0" + "cum": "910650.0" } } ], @@ -18815,6 +19807,120 @@ } } ], + "RAD/USDT:USDT": [ + { + "tier": 1.0, + "currency": "USDT", + "minNotional": 0.0, + "maxNotional": 5000.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 20.0, + "info": { + "bracket": "1", + "initialLeverage": "20", + "notionalCap": "5000", + "notionalFloor": "0", + "maintMarginRatio": "0.02", + "cum": "0.0" + } + }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 5000.0, + "maxNotional": 25000.0, + "maintenanceMarginRate": 0.025, + "maxLeverage": 15.0, + "info": { + "bracket": "2", + "initialLeverage": "15", + "notionalCap": "25000", + "notionalFloor": "5000", + "maintMarginRatio": "0.025", + "cum": "25.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 25000.0, + "maxNotional": 200000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "3", + "initialLeverage": "10", + "notionalCap": "200000", + "notionalFloor": "25000", + "maintMarginRatio": "0.05", + "cum": "650.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 200000.0, + "maxNotional": 500000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "4", + "initialLeverage": "5", + "notionalCap": "500000", + "notionalFloor": "200000", + "maintMarginRatio": "0.1", + "cum": "10650.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 500000.0, + "maxNotional": 1000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "5", + "initialLeverage": "4", + "notionalCap": "1000000", + "notionalFloor": "500000", + "maintMarginRatio": "0.125", + "cum": "23150.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 1000000.0, + "maxNotional": 3000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "3000000", + "notionalFloor": "1000000", + "maintMarginRatio": "0.25", + "cum": "148150.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 3000000.0, + "maxNotional": 5000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "7", + "initialLeverage": "1", + "notionalCap": "5000000", + "notionalFloor": "3000000", + "maintMarginRatio": "0.5", + "cum": "898150.0" + } + } + ], "RAY/USDT:USDT": [ { "tier": 1.0, @@ -18950,13 +20056,13 @@ "tier": 3.0, "currency": "USDT", "minNotional": 25000.0, - "maxNotional": 200000.0, + "maxNotional": 600000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 10.0, "info": { "bracket": "3", "initialLeverage": "10", - "notionalCap": "200000", + "notionalCap": "600000", "notionalFloor": "25000", "maintMarginRatio": "0.05", "cum": "650.0" @@ -18965,65 +20071,65 @@ { "tier": 4.0, "currency": "USDT", - "minNotional": 200000.0, - "maxNotional": 500000.0, + "minNotional": 600000.0, + "maxNotional": 1600000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "4", "initialLeverage": "5", - "notionalCap": "500000", - "notionalFloor": "200000", + "notionalCap": "1600000", + "notionalFloor": "600000", "maintMarginRatio": "0.1", - "cum": "10650.0" + "cum": "30650.0" } }, { "tier": 5.0, "currency": "USDT", - "minNotional": 500000.0, - "maxNotional": 1000000.0, + "minNotional": 1600000.0, + "maxNotional": 2000000.0, "maintenanceMarginRate": 0.125, "maxLeverage": 4.0, "info": { "bracket": "5", "initialLeverage": "4", - "notionalCap": "1000000", - "notionalFloor": "500000", + "notionalCap": "2000000", + "notionalFloor": "1600000", "maintMarginRatio": "0.125", - "cum": "23150.0" + "cum": "70650.0" } }, { "tier": 6.0, "currency": "USDT", - "minNotional": 1000000.0, - "maxNotional": 3000000.0, + "minNotional": 2000000.0, + "maxNotional": 6000000.0, "maintenanceMarginRate": 0.25, "maxLeverage": 2.0, "info": { "bracket": "6", "initialLeverage": "2", - "notionalCap": "3000000", - "notionalFloor": "1000000", + "notionalCap": "6000000", + "notionalFloor": "2000000", "maintMarginRatio": "0.25", - "cum": "148150.0" + "cum": "320650.0" } }, { "tier": 7.0, "currency": "USDT", - "minNotional": 3000000.0, - "maxNotional": 5000000.0, + "minNotional": 6000000.0, + "maxNotional": 10000000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "7", "initialLeverage": "1", - "notionalCap": "5000000", - "notionalFloor": "3000000", + "notionalCap": "10000000", + "notionalFloor": "6000000", "maintMarginRatio": "0.5", - "cum": "898150.0" + "cum": "1820650.0" } } ], @@ -21412,13 +22518,13 @@ "tier": 2.0, "currency": "USDT", "minNotional": 5000.0, - "maxNotional": 25000.0, + "maxNotional": 50000.0, "maintenanceMarginRate": 0.025, "maxLeverage": 20.0, "info": { "bracket": "2", "initialLeverage": "20", - "notionalCap": "25000", + "notionalCap": "50000", "notionalFloor": "5000", "maintMarginRatio": "0.025", "cum": "75.0" @@ -21427,39 +22533,39 @@ { "tier": 3.0, "currency": "USDT", - "minNotional": 25000.0, - "maxNotional": 400000.0, + "minNotional": 50000.0, + "maxNotional": 600000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 10.0, "info": { "bracket": "3", "initialLeverage": "10", - "notionalCap": "400000", - "notionalFloor": "25000", + "notionalCap": "600000", + "notionalFloor": "50000", "maintMarginRatio": "0.05", - "cum": "700.0" + "cum": "1325.0" } }, { "tier": 4.0, "currency": "USDT", - "minNotional": 400000.0, - "maxNotional": 1000000.0, + "minNotional": 600000.0, + "maxNotional": 1600000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "4", "initialLeverage": "5", - "notionalCap": "1000000", - "notionalFloor": "400000", + "notionalCap": "1600000", + "notionalFloor": "600000", "maintMarginRatio": "0.1", - "cum": "20700.0" + "cum": "31325.0" } }, { "tier": 5.0, "currency": "USDT", - "minNotional": 1000000.0, + "minNotional": 1600000.0, "maxNotional": 2000000.0, "maintenanceMarginRate": 0.125, "maxLeverage": 4.0, @@ -21467,9 +22573,9 @@ "bracket": "5", "initialLeverage": "4", "notionalCap": "2000000", - "notionalFloor": "1000000", + "notionalFloor": "1600000", "maintMarginRatio": "0.125", - "cum": "45700.0" + "cum": "71325.0" } }, { @@ -21485,7 +22591,7 @@ "notionalCap": "6000000", "notionalFloor": "2000000", "maintMarginRatio": "0.25", - "cum": "295700.0" + "cum": "321325.0" } }, { @@ -21501,7 +22607,137 @@ "notionalCap": "10000000", "notionalFloor": "6000000", "maintMarginRatio": "0.5", - "cum": "1795700.0" + "cum": "1821325.0" + } + } + ], + "SUI/USDT:USDT": [ + { + "tier": 1.0, + "currency": "USDT", + "minNotional": 0.0, + "maxNotional": 5000.0, + "maintenanceMarginRate": 0.01, + "maxLeverage": 50.0, + "info": { + "bracket": "1", + "initialLeverage": "50", + "notionalCap": "5000", + "notionalFloor": "0", + "maintMarginRatio": "0.01", + "cum": "0.0" + } + }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 5000.0, + "maxNotional": 50000.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 25.0, + "info": { + "bracket": "2", + "initialLeverage": "25", + "notionalCap": "50000", + "notionalFloor": "5000", + "maintMarginRatio": "0.02", + "cum": "50.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 50000.0, + "maxNotional": 450000.0, + "maintenanceMarginRate": 0.025, + "maxLeverage": 20.0, + "info": { + "bracket": "3", + "initialLeverage": "20", + "notionalCap": "450000", + "notionalFloor": "50000", + "maintMarginRatio": "0.025", + "cum": "300.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 450000.0, + "maxNotional": 900000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "4", + "initialLeverage": "10", + "notionalCap": "900000", + "notionalFloor": "450000", + "maintMarginRatio": "0.05", + "cum": "11550.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 900000.0, + "maxNotional": 2400000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "5", + "initialLeverage": "5", + "notionalCap": "2400000", + "notionalFloor": "900000", + "maintMarginRatio": "0.1", + "cum": "56550.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 2400000.0, + "maxNotional": 3000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "6", + "initialLeverage": "4", + "notionalCap": "3000000", + "notionalFloor": "2400000", + "maintMarginRatio": "0.125", + "cum": "116550.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 3000000.0, + "maxNotional": 6000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "7", + "initialLeverage": "2", + "notionalCap": "6000000", + "notionalFloor": "3000000", + "maintMarginRatio": "0.25", + "cum": "491550.0" + } + }, + { + "tier": 8.0, + "currency": "USDT", + "minNotional": 6000000.0, + "maxNotional": 10000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "8", + "initialLeverage": "1", + "notionalCap": "10000000", + "notionalFloor": "6000000", + "maintMarginRatio": "0.5", + "cum": "1991550.0" } } ], @@ -21626,10 +22862,10 @@ "minNotional": 0.0, "maxNotional": 5000.0, "maintenanceMarginRate": 0.02, - "maxLeverage": 15.0, + "maxLeverage": 25.0, "info": { "bracket": "1", - "initialLeverage": "15", + "initialLeverage": "25", "notionalCap": "5000", "notionalFloor": "0", "maintMarginRatio": "0.02", @@ -21642,10 +22878,10 @@ "minNotional": 5000.0, "maxNotional": 25000.0, "maintenanceMarginRate": 0.025, - "maxLeverage": 10.0, + "maxLeverage": 20.0, "info": { "bracket": "2", - "initialLeverage": "10", + "initialLeverage": "20", "notionalCap": "25000", "notionalFloor": "5000", "maintMarginRatio": "0.025", @@ -21658,10 +22894,10 @@ "minNotional": 25000.0, "maxNotional": 600000.0, "maintenanceMarginRate": 0.05, - "maxLeverage": 8.0, + "maxLeverage": 10.0, "info": { "bracket": "3", - "initialLeverage": "8", + "initialLeverage": "10", "notionalCap": "600000", "notionalFloor": "25000", "maintMarginRatio": "0.05", @@ -22194,13 +23430,13 @@ "tier": 2.0, "currency": "USDT", "minNotional": 5000.0, - "maxNotional": 25000.0, + "maxNotional": 50000.0, "maintenanceMarginRate": 0.025, "maxLeverage": 20.0, "info": { "bracket": "2", "initialLeverage": "20", - "notionalCap": "25000", + "notionalCap": "50000", "notionalFloor": "5000", "maintMarginRatio": "0.025", "cum": "25.0" @@ -22209,23 +23445,23 @@ { "tier": 3.0, "currency": "USDT", - "minNotional": 25000.0, - "maxNotional": 480000.0, + "minNotional": 50000.0, + "maxNotional": 600000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 10.0, "info": { "bracket": "3", "initialLeverage": "10", - "notionalCap": "480000", - "notionalFloor": "25000", + "notionalCap": "600000", + "notionalFloor": "50000", "maintMarginRatio": "0.05", - "cum": "650.0" + "cum": "1275.0" } }, { "tier": 4.0, "currency": "USDT", - "minNotional": 480000.0, + "minNotional": 600000.0, "maxNotional": 1280000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, @@ -22233,9 +23469,9 @@ "bracket": "4", "initialLeverage": "5", "notionalCap": "1280000", - "notionalFloor": "480000", + "notionalFloor": "600000", "maintMarginRatio": "0.1", - "cum": "24650.0" + "cum": "31275.0" } }, { @@ -22251,7 +23487,7 @@ "notionalCap": "1600000", "notionalFloor": "1280000", "maintMarginRatio": "0.125", - "cum": "56650.0" + "cum": "63275.0" } }, { @@ -22267,7 +23503,7 @@ "notionalCap": "4800000", "notionalFloor": "1600000", "maintMarginRatio": "0.25", - "cum": "256650.0" + "cum": "263275.0" } }, { @@ -22283,7 +23519,7 @@ "notionalCap": "8000000", "notionalFloor": "4800000", "maintMarginRatio": "0.5", - "cum": "1456650.0" + "cum": "1463275.0" } } ], @@ -22759,6 +23995,120 @@ } } ], + "UMA/USDT:USDT": [ + { + "tier": 1.0, + "currency": "USDT", + "minNotional": 0.0, + "maxNotional": 5000.0, + "maintenanceMarginRate": 0.02, + "maxLeverage": 20.0, + "info": { + "bracket": "1", + "initialLeverage": "20", + "notionalCap": "5000", + "notionalFloor": "0", + "maintMarginRatio": "0.02", + "cum": "0.0" + } + }, + { + "tier": 2.0, + "currency": "USDT", + "minNotional": 5000.0, + "maxNotional": 25000.0, + "maintenanceMarginRate": 0.025, + "maxLeverage": 15.0, + "info": { + "bracket": "2", + "initialLeverage": "15", + "notionalCap": "25000", + "notionalFloor": "5000", + "maintMarginRatio": "0.025", + "cum": "25.0" + } + }, + { + "tier": 3.0, + "currency": "USDT", + "minNotional": 25000.0, + "maxNotional": 200000.0, + "maintenanceMarginRate": 0.05, + "maxLeverage": 10.0, + "info": { + "bracket": "3", + "initialLeverage": "10", + "notionalCap": "200000", + "notionalFloor": "25000", + "maintMarginRatio": "0.05", + "cum": "650.0" + } + }, + { + "tier": 4.0, + "currency": "USDT", + "minNotional": 200000.0, + "maxNotional": 500000.0, + "maintenanceMarginRate": 0.1, + "maxLeverage": 5.0, + "info": { + "bracket": "4", + "initialLeverage": "5", + "notionalCap": "500000", + "notionalFloor": "200000", + "maintMarginRatio": "0.1", + "cum": "10650.0" + } + }, + { + "tier": 5.0, + "currency": "USDT", + "minNotional": 500000.0, + "maxNotional": 1000000.0, + "maintenanceMarginRate": 0.125, + "maxLeverage": 4.0, + "info": { + "bracket": "5", + "initialLeverage": "4", + "notionalCap": "1000000", + "notionalFloor": "500000", + "maintMarginRatio": "0.125", + "cum": "23150.0" + } + }, + { + "tier": 6.0, + "currency": "USDT", + "minNotional": 1000000.0, + "maxNotional": 3000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "3000000", + "notionalFloor": "1000000", + "maintMarginRatio": "0.25", + "cum": "148150.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 3000000.0, + "maxNotional": 5000000.0, + "maintenanceMarginRate": 0.5, + "maxLeverage": 1.0, + "info": { + "bracket": "7", + "initialLeverage": "1", + "notionalCap": "5000000", + "notionalFloor": "3000000", + "maintMarginRatio": "0.5", + "cum": "898150.0" + } + } + ], "UNFI/USDT:USDT": [ { "tier": 1.0, @@ -23092,10 +24442,10 @@ "minNotional": 0.0, "maxNotional": 5000.0, "maintenanceMarginRate": 0.006, - "maxLeverage": 30.0, + "maxLeverage": 50.0, "info": { "bracket": "1", - "initialLeverage": "30", + "initialLeverage": "50", "notionalCap": "5000", "notionalFloor": "0", "maintMarginRatio": "0.006", @@ -23122,13 +24472,13 @@ "tier": 3.0, "currency": "USDT", "minNotional": 50000.0, - "maxNotional": 600000.0, + "maxNotional": 900000.0, "maintenanceMarginRate": 0.025, "maxLeverage": 20.0, "info": { "bracket": "3", "initialLeverage": "20", - "notionalCap": "600000", + "notionalCap": "900000", "notionalFloor": "50000", "maintMarginRatio": "0.025", "cum": "770.0" @@ -23137,81 +24487,81 @@ { "tier": 4.0, "currency": "USDT", - "minNotional": 600000.0, - "maxNotional": 1200000.0, + "minNotional": 900000.0, + "maxNotional": 1800000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 10.0, "info": { "bracket": "4", "initialLeverage": "10", - "notionalCap": "1200000", - "notionalFloor": "600000", + "notionalCap": "1800000", + "notionalFloor": "900000", "maintMarginRatio": "0.05", - "cum": "15770.0" + "cum": "23270.0" } }, { "tier": 5.0, "currency": "USDT", - "minNotional": 1200000.0, - "maxNotional": 3200000.0, + "minNotional": 1800000.0, + "maxNotional": 4800000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "5", "initialLeverage": "5", - "notionalCap": "3200000", - "notionalFloor": "1200000", + "notionalCap": "4800000", + "notionalFloor": "1800000", "maintMarginRatio": "0.1", - "cum": "75770.0" + "cum": "113270.0" } }, { "tier": 6.0, "currency": "USDT", - "minNotional": 3200000.0, - "maxNotional": 5000000.0, + "minNotional": 4800000.0, + "maxNotional": 6000000.0, "maintenanceMarginRate": 0.125, "maxLeverage": 4.0, "info": { "bracket": "6", "initialLeverage": "4", - "notionalCap": "5000000", - "notionalFloor": "3200000", + "notionalCap": "6000000", + "notionalFloor": "4800000", "maintMarginRatio": "0.125", - "cum": "155770.0" + "cum": "233270.0" } }, { "tier": 7.0, "currency": "USDT", - "minNotional": 5000000.0, - "maxNotional": 12000000.0, + "minNotional": 6000000.0, + "maxNotional": 18000000.0, "maintenanceMarginRate": 0.25, "maxLeverage": 2.0, "info": { "bracket": "7", "initialLeverage": "2", - "notionalCap": "12000000", - "notionalFloor": "5000000", + "notionalCap": "18000000", + "notionalFloor": "6000000", "maintMarginRatio": "0.25", - "cum": "780770.0" + "cum": "983270.0" } }, { "tier": 8.0, "currency": "USDT", - "minNotional": 12000000.0, - "maxNotional": 20000000.0, + "minNotional": 18000000.0, + "maxNotional": 30000000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { "bracket": "8", "initialLeverage": "1", - "notionalCap": "20000000", - "notionalFloor": "12000000", + "notionalCap": "30000000", + "notionalFloor": "18000000", "maintMarginRatio": "0.5", - "cum": "3780770.0" + "cum": "5483270.0" } } ], @@ -23464,13 +24814,13 @@ "tier": 2.0, "currency": "USDT", "minNotional": 5000.0, - "maxNotional": 25000.0, + "maxNotional": 50000.0, "maintenanceMarginRate": 0.025, "maxLeverage": 20.0, "info": { "bracket": "2", "initialLeverage": "20", - "notionalCap": "25000", + "notionalCap": "50000", "notionalFloor": "5000", "maintMarginRatio": "0.025", "cum": "25.0" @@ -23479,39 +24829,39 @@ { "tier": 3.0, "currency": "USDT", - "minNotional": 25000.0, - "maxNotional": 200000.0, + "minNotional": 50000.0, + "maxNotional": 300000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 10.0, "info": { "bracket": "3", "initialLeverage": "10", - "notionalCap": "200000", - "notionalFloor": "25000", + "notionalCap": "300000", + "notionalFloor": "50000", "maintMarginRatio": "0.05", - "cum": "650.0" + "cum": "1275.0" } }, { "tier": 4.0, "currency": "USDT", - "minNotional": 200000.0, - "maxNotional": 500000.0, + "minNotional": 300000.0, + "maxNotional": 800000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "4", "initialLeverage": "5", - "notionalCap": "500000", - "notionalFloor": "200000", + "notionalCap": "800000", + "notionalFloor": "300000", "maintMarginRatio": "0.1", - "cum": "10650.0" + "cum": "16275.0" } }, { "tier": 5.0, "currency": "USDT", - "minNotional": 500000.0, + "minNotional": 800000.0, "maxNotional": 1000000.0, "maintenanceMarginRate": 0.125, "maxLeverage": 4.0, @@ -23519,9 +24869,9 @@ "bracket": "5", "initialLeverage": "4", "notionalCap": "1000000", - "notionalFloor": "500000", + "notionalFloor": "800000", "maintMarginRatio": "0.125", - "cum": "23150.0" + "cum": "36275.0" } }, { @@ -23537,7 +24887,7 @@ "notionalCap": "3000000", "notionalFloor": "1000000", "maintMarginRatio": "0.25", - "cum": "148150.0" + "cum": "161275.0" } }, { @@ -23553,7 +24903,7 @@ "notionalCap": "5000000", "notionalFloor": "3000000", "maintMarginRatio": "0.5", - "cum": "898150.0" + "cum": "911275.0" } } ], @@ -24148,13 +25498,13 @@ "tier": 4.0, "currency": "USDT", "minNotional": 50000.0, - "maxNotional": 250000.0, + "maxNotional": 500000.0, "maintenanceMarginRate": 0.02, "maxLeverage": 25.0, "info": { "bracket": "4", "initialLeverage": "25", - "notionalCap": "250000", + "notionalCap": "500000", "notionalFloor": "50000", "maintMarginRatio": "0.02", "cum": "545.0" @@ -24163,39 +25513,39 @@ { "tier": 5.0, "currency": "USDT", - "minNotional": 250000.0, - "maxNotional": 1000000.0, + "minNotional": 500000.0, + "maxNotional": 2000000.0, "maintenanceMarginRate": 0.05, "maxLeverage": 10.0, "info": { "bracket": "5", "initialLeverage": "10", - "notionalCap": "1000000", - "notionalFloor": "250000", + "notionalCap": "2000000", + "notionalFloor": "500000", "maintMarginRatio": "0.05", - "cum": "8045.0" + "cum": "15545.0" } }, { "tier": 6.0, "currency": "USDT", - "minNotional": 1000000.0, - "maxNotional": 5000000.0, + "minNotional": 2000000.0, + "maxNotional": 8000000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "6", "initialLeverage": "5", - "notionalCap": "5000000", - "notionalFloor": "1000000", + "notionalCap": "8000000", + "notionalFloor": "2000000", "maintMarginRatio": "0.1", - "cum": "58045.0" + "cum": "115545.0" } }, { "tier": 7.0, "currency": "USDT", - "minNotional": 5000000.0, + "minNotional": 8000000.0, "maxNotional": 10000000.0, "maintenanceMarginRate": 0.125, "maxLeverage": 4.0, @@ -24203,9 +25553,9 @@ "bracket": "7", "initialLeverage": "4", "notionalCap": "10000000", - "notionalFloor": "5000000", + "notionalFloor": "8000000", "maintMarginRatio": "0.125", - "cum": "183045.0" + "cum": "315545.0" } }, { @@ -24221,7 +25571,7 @@ "notionalCap": "20000000", "notionalFloor": "10000000", "maintMarginRatio": "0.15", - "cum": "433045.0" + "cum": "565545.0" } }, { @@ -24237,7 +25587,7 @@ "notionalCap": "30000000", "notionalFloor": "20000000", "maintMarginRatio": "0.25", - "cum": "2433045.0" + "cum": "2565545.0" } }, { @@ -24253,7 +25603,7 @@ "notionalCap": "50000000", "notionalFloor": "30000000", "maintMarginRatio": "0.5", - "cum": "9933045.0" + "cum": "1.0065545E7" } } ], @@ -24818,10 +26168,10 @@ "minNotional": 0.0, "maxNotional": 5000.0, "maintenanceMarginRate": 0.01, - "maxLeverage": 20.0, + "maxLeverage": 25.0, "info": { "bracket": "1", - "initialLeverage": "20", + "initialLeverage": "25", "notionalCap": "5000", "notionalFloor": "0", "maintMarginRatio": "0.01", @@ -24834,10 +26184,10 @@ "minNotional": 5000.0, "maxNotional": 25000.0, "maintenanceMarginRate": 0.025, - "maxLeverage": 10.0, + "maxLeverage": 20.0, "info": { "bracket": "2", - "initialLeverage": "10", + "initialLeverage": "20", "notionalCap": "25000", "notionalFloor": "5000", "maintMarginRatio": "0.025", @@ -24848,13 +26198,13 @@ "tier": 3.0, "currency": "USDT", "minNotional": 25000.0, - "maxNotional": 100000.0, + "maxNotional": 600000.0, "maintenanceMarginRate": 0.05, - "maxLeverage": 8.0, + "maxLeverage": 10.0, "info": { "bracket": "3", - "initialLeverage": "8", - "notionalCap": "100000", + "initialLeverage": "10", + "notionalCap": "600000", "notionalFloor": "25000", "maintMarginRatio": "0.05", "cum": "700.0" @@ -24863,49 +26213,65 @@ { "tier": 4.0, "currency": "USDT", - "minNotional": 100000.0, - "maxNotional": 250000.0, + "minNotional": 600000.0, + "maxNotional": 1600000.0, "maintenanceMarginRate": 0.1, "maxLeverage": 5.0, "info": { "bracket": "4", "initialLeverage": "5", - "notionalCap": "250000", - "notionalFloor": "100000", + "notionalCap": "1600000", + "notionalFloor": "600000", "maintMarginRatio": "0.1", - "cum": "5700.0" + "cum": "30700.0" } }, { "tier": 5.0, "currency": "USDT", - "minNotional": 250000.0, - "maxNotional": 1000000.0, + "minNotional": 1600000.0, + "maxNotional": 2000000.0, "maintenanceMarginRate": 0.125, - "maxLeverage": 2.0, + "maxLeverage": 4.0, "info": { "bracket": "5", - "initialLeverage": "2", - "notionalCap": "1000000", - "notionalFloor": "250000", + "initialLeverage": "4", + "notionalCap": "2000000", + "notionalFloor": "1600000", "maintMarginRatio": "0.125", - "cum": "11950.0" + "cum": "70700.0" } }, { "tier": 6.0, "currency": "USDT", - "minNotional": 1000000.0, - "maxNotional": 5000000.0, + "minNotional": 2000000.0, + "maxNotional": 6000000.0, + "maintenanceMarginRate": 0.25, + "maxLeverage": 2.0, + "info": { + "bracket": "6", + "initialLeverage": "2", + "notionalCap": "6000000", + "notionalFloor": "2000000", + "maintMarginRatio": "0.25", + "cum": "320700.0" + } + }, + { + "tier": 7.0, + "currency": "USDT", + "minNotional": 6000000.0, + "maxNotional": 10000000.0, "maintenanceMarginRate": 0.5, "maxLeverage": 1.0, "info": { - "bracket": "6", + "bracket": "7", "initialLeverage": "1", - "notionalCap": "5000000", - "notionalFloor": "1000000", + "notionalCap": "10000000", + "notionalFloor": "6000000", "maintMarginRatio": "0.5", - "cum": "386950.0" + "cum": "1820700.0" } } ], diff --git a/freqtrade/exchange/bybit.py b/freqtrade/exchange/bybit.py index a4b070741..145a501c9 100644 --- a/freqtrade/exchange/bybit.py +++ b/freqtrade/exchange/bybit.py @@ -28,7 +28,7 @@ class Bybit(Exchange): _ft_has: Dict = { "ohlcv_candle_limit": 200, - "ohlcv_has_history": False, + "ohlcv_has_history": True, } _ft_has_futures: Dict = { "ohlcv_has_history": True, diff --git a/freqtrade/exchange/common.py b/freqtrade/exchange/common.py index 42a7094ba..10dfdf178 100644 --- a/freqtrade/exchange/common.py +++ b/freqtrade/exchange/common.py @@ -4,6 +4,7 @@ import time from functools import wraps from typing import Any, Callable, Optional, TypeVar, cast, overload +from freqtrade.constants import ExchangeConfig from freqtrade.exceptions import DDosProtection, RetryableOrderError, TemporaryError from freqtrade.mixins import LoggingMixin @@ -84,20 +85,22 @@ EXCHANGE_HAS_OPTIONAL = [ # 'fetchPositions', # Futures trading # 'fetchLeverageTiers', # Futures initialization # 'fetchMarketLeverageTiers', # Futures initialization + # 'fetchOpenOrders', 'fetchClosedOrders', # 'fetchOrders', # Refinding balance... ] -def remove_credentials(config) -> None: +def remove_exchange_credentials(exchange_config: ExchangeConfig, dry_run: bool) -> None: """ Removes exchange keys from the configuration and specifies dry-run Used for backtesting / hyperopt / edge and utils. Modifies the input dict! """ - if config.get('dry_run', False): - config['exchange']['key'] = '' - config['exchange']['secret'] = '' - config['exchange']['password'] = '' - config['exchange']['uid'] = '' + if dry_run: + exchange_config['key'] = '' + exchange_config['apiKey'] = '' + exchange_config['secret'] = '' + exchange_config['password'] = '' + exchange_config['uid'] = '' def calculate_backoff(retrycount, max_retries): diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index ba7d79f97..2cf98c266 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -11,7 +11,6 @@ from math import floor from threading import Lock from typing import Any, Coroutine, Dict, List, Literal, Optional, Tuple, Union -import arrow import ccxt import ccxt.async_support as ccxt_async from cachetools import TTLCache @@ -20,16 +19,16 @@ from dateutil import parser from pandas import DataFrame, concat from freqtrade.constants import (DEFAULT_AMOUNT_RESERVE_PERCENT, NON_OPEN_EXCHANGE_STATES, BidAsk, - BuySell, Config, EntryExit, ListPairsWithTimeframes, MakerTaker, - OBLiteral, PairWithTimeframe) + BuySell, Config, EntryExit, ExchangeConfig, + ListPairsWithTimeframes, MakerTaker, OBLiteral, PairWithTimeframe) from freqtrade.data.converter import clean_ohlcv_dataframe, ohlcv_to_dataframe, trades_dict_to_list from freqtrade.enums import OPTIMIZE_MODES, CandleType, MarginMode, TradingMode from freqtrade.enums.pricetype import PriceType from freqtrade.exceptions import (DDosProtection, ExchangeError, InsufficientFundsError, InvalidOrderException, OperationalException, PricingError, RetryableOrderError, TemporaryError) -from freqtrade.exchange.common import (API_FETCH_ORDER_RETRY_COUNT, remove_credentials, retrier, - retrier_async) +from freqtrade.exchange.common import (API_FETCH_ORDER_RETRY_COUNT, remove_exchange_credentials, + retrier, retrier_async) from freqtrade.exchange.exchange_utils import (ROUND, ROUND_DOWN, ROUND_UP, CcxtModuleType, amount_to_contract_precision, amount_to_contracts, amount_to_precision, contracts_to_amount, @@ -42,6 +41,8 @@ from freqtrade.exchange.types import OHLCVResponse, OrderBook, Ticker, Tickers from freqtrade.misc import (chunks, deep_merge_dicts, file_dump_json, file_load_json, safe_value_fallback2) from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist +from freqtrade.util import dt_from_ts, dt_now +from freqtrade.util.datetime_helpers import dt_humanize, dt_ts logger = logging.getLogger(__name__) @@ -92,8 +93,8 @@ class Exchange: # TradingMode.SPOT always supported and not required in this list ] - def __init__(self, config: Config, validate: bool = True, - load_leverage_tiers: bool = False) -> None: + def __init__(self, config: Config, *, exchange_config: Optional[ExchangeConfig] = None, + validate: bool = True, load_leverage_tiers: bool = False) -> None: """ Initializes this module with the given config, it does basic validation whether the specified exchange and pairs are valid. @@ -107,8 +108,7 @@ class Exchange: # Lock event loop. This is necessary to avoid race-conditions when using force* commands # Due to funding fee fetching. self._loop_lock = Lock() - self.loop = asyncio.new_event_loop() - asyncio.set_event_loop(self.loop) + self.loop = self._init_async_loop() self._config: Config = {} self._config.update(config) @@ -132,13 +132,13 @@ class Exchange: # Holds all open sell orders for dry_run self._dry_run_open_orders: Dict[str, Any] = {} - remove_credentials(config) if config['dry_run']: logger.info('Instance is running with dry_run enabled') logger.info(f"Using CCXT {ccxt.__version__}") - exchange_config = config['exchange'] - self.log_responses = exchange_config.get('log_responses', False) + exchange_conf: Dict[str, Any] = exchange_config if exchange_config else config['exchange'] + remove_exchange_credentials(exchange_conf, config.get('dry_run', False)) + self.log_responses = exchange_conf.get('log_responses', False) # Leverage properties self.trading_mode: TradingMode = config.get('trading_mode', TradingMode.SPOT) @@ -153,8 +153,8 @@ class Exchange: self._ft_has = deep_merge_dicts(self._ft_has, deepcopy(self._ft_has_default)) if self.trading_mode == TradingMode.FUTURES: self._ft_has = deep_merge_dicts(self._ft_has_futures, self._ft_has) - if exchange_config.get('_ft_has_params'): - self._ft_has = deep_merge_dicts(exchange_config.get('_ft_has_params'), + if exchange_conf.get('_ft_has_params'): + self._ft_has = deep_merge_dicts(exchange_conf.get('_ft_has_params'), self._ft_has) logger.info("Overriding exchange._ft_has with config params, result: %s", self._ft_has) @@ -166,18 +166,18 @@ class Exchange: # Initialize ccxt objects ccxt_config = self._ccxt_config - ccxt_config = deep_merge_dicts(exchange_config.get('ccxt_config', {}), ccxt_config) - ccxt_config = deep_merge_dicts(exchange_config.get('ccxt_sync_config', {}), ccxt_config) + ccxt_config = deep_merge_dicts(exchange_conf.get('ccxt_config', {}), ccxt_config) + ccxt_config = deep_merge_dicts(exchange_conf.get('ccxt_sync_config', {}), ccxt_config) - self._api = self._init_ccxt(exchange_config, ccxt_kwargs=ccxt_config) + self._api = self._init_ccxt(exchange_conf, ccxt_kwargs=ccxt_config) ccxt_async_config = self._ccxt_config - ccxt_async_config = deep_merge_dicts(exchange_config.get('ccxt_config', {}), + ccxt_async_config = deep_merge_dicts(exchange_conf.get('ccxt_config', {}), ccxt_async_config) - ccxt_async_config = deep_merge_dicts(exchange_config.get('ccxt_async_config', {}), + ccxt_async_config = deep_merge_dicts(exchange_conf.get('ccxt_async_config', {}), ccxt_async_config) self._api_async = self._init_ccxt( - exchange_config, ccxt_async, ccxt_kwargs=ccxt_async_config) + exchange_conf, ccxt_async, ccxt_kwargs=ccxt_async_config) logger.info(f'Using Exchange "{self.name}"') self.required_candle_call_count = 1 @@ -190,8 +190,8 @@ class Exchange: self._startup_candle_count, config.get('timeframe', '')) # Converts the interval provided in minutes in config to seconds - self.markets_refresh_interval: int = exchange_config.get( - "markets_refresh_interval", 60) * 60 + self.markets_refresh_interval: int = exchange_conf.get( + "markets_refresh_interval", 60) * 60 * 1000 if self.trading_mode != TradingMode.SPOT and load_leverage_tiers: self.fill_leverage_tiers() @@ -212,6 +212,11 @@ class Exchange: if self.loop and not self.loop.is_closed(): self.loop.close() + def _init_async_loop(self) -> asyncio.AbstractEventLoop: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop + def validate_config(self, config): # Check if timeframe is available self.validate_timeframes(config.get('timeframe')) @@ -296,7 +301,7 @@ class Exchange: return list((self._api.timeframes or {}).keys()) @property - def markets(self) -> Dict: + def markets(self) -> Dict[str, Any]: """exchange ccxt markets""" if not self._markets: logger.info("Markets were not loaded. Loading them now..") @@ -486,7 +491,7 @@ class Exchange: try: self._markets = self._api.load_markets(params={}) self._load_async_markets() - self._last_markets_refresh = arrow.utcnow().int_timestamp + self._last_markets_refresh = dt_ts() if self._ft_has['needs_trading_fees']: self._trading_fees = self.fetch_trading_fees() @@ -497,15 +502,14 @@ class Exchange: """Reload markets both sync and async if refresh interval has passed """ # Check whether markets have to be reloaded if (self._last_markets_refresh > 0) and ( - self._last_markets_refresh + self.markets_refresh_interval - > arrow.utcnow().int_timestamp): + self._last_markets_refresh + self.markets_refresh_interval > dt_ts()): return None logger.debug("Performing scheduled market reload..") try: self._markets = self._api.load_markets(reload=True, params={}) # Also reload async markets to avoid issues with newly listed pairs self._load_async_markets(reload=True) - self._last_markets_refresh = arrow.utcnow().int_timestamp + self._last_markets_refresh = dt_ts() self.fill_leverage_tiers() except ccxt.BaseError: logger.exception("Could not reload markets.") @@ -839,7 +843,8 @@ class Exchange: def create_dry_run_order(self, pair: str, ordertype: str, side: str, amount: float, rate: float, leverage: float, params: Dict = {}, stop_loss: bool = False) -> Dict[str, Any]: - order_id = f'dry_run_{side}_{datetime.now().timestamp()}' + now = dt_now() + order_id = f'dry_run_{side}_{now.timestamp()}' # Rounding here must respect to contract sizes _amount = self._contracts_to_amount( pair, self.amount_to_precision(pair, self._amount_to_contracts(pair, amount))) @@ -854,8 +859,8 @@ class Exchange: 'side': side, 'filled': 0, 'remaining': _amount, - 'datetime': arrow.utcnow().strftime('%Y-%m-%dT%H:%M:%S.%fZ'), - 'timestamp': arrow.utcnow().int_timestamp * 1000, + 'datetime': now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + 'timestamp': dt_ts(now), 'status': "open", 'fee': None, 'info': {}, @@ -863,7 +868,7 @@ class Exchange: } if stop_loss: dry_order["info"] = {"stopPrice": dry_order["price"]} - dry_order["stopPrice"] = dry_order["price"] + dry_order[self._ft_has['stop_price_param']] = dry_order["price"] # Workaround to avoid filling stoploss orders immediately dry_order["ft_order_type"] = "stoploss" orderbook: Optional[OrderBook] = None @@ -1015,7 +1020,7 @@ class Exchange: from freqtrade.persistence import Order order = Order.order_by_id(order_id) if order: - ccxt_order = order.to_ccxt_object() + ccxt_order = order.to_ccxt_object(self._ft_has['stop_price_param']) self._dry_run_open_orders[order_id] = ccxt_order return ccxt_order # Gracefully handle errors with dry-run orders. @@ -1143,8 +1148,8 @@ class Exchange: else: limit_rate = stop_price * (2 - limit_price_pct) - bad_stop_price = ((stop_price <= limit_rate) if side == - "sell" else (stop_price >= limit_rate)) + bad_stop_price = ((stop_price < limit_rate) if side == + "sell" else (stop_price > limit_rate)) # Ensure rate is less than stop price if bad_stop_price: # This can for example happen if the stop / liquidation price is set to 0 @@ -1428,6 +1433,47 @@ class Exchange: except ccxt.BaseError as e: raise OperationalException(e) from e + @retrier(retries=0) + def fetch_orders(self, pair: str, since: datetime) -> List[Dict]: + """ + Fetch all orders for a pair "since" + :param pair: Pair for the query + :param since: Starting time for the query + """ + if self._config['dry_run']: + return [] + + def fetch_orders_emulate() -> List[Dict]: + orders = [] + if self.exchange_has('fetchClosedOrders'): + orders = self._api.fetch_closed_orders(pair, since=since_ms) + if self.exchange_has('fetchOpenOrders'): + orders_open = self._api.fetch_open_orders(pair, since=since_ms) + orders.extend(orders_open) + return orders + + try: + since_ms = int((since.timestamp() - 10) * 1000) + if self.exchange_has('fetchOrders'): + try: + orders: List[Dict] = self._api.fetch_orders(pair, since=since_ms) + except ccxt.NotSupported: + # Some exchanges don't support fetchOrders + # attempt to fetch open and closed orders separately + orders = fetch_orders_emulate() + else: + orders = fetch_orders_emulate() + self._log_exchange_response('fetch_orders', orders) + orders = [self._order_contracts_to_amount(o) for o in orders] + return orders + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e + except (ccxt.NetworkError, ccxt.ExchangeError) as e: + raise TemporaryError( + f'Could not fetch positions due to {e.__class__.__name__}. Message: {e}') from e + except ccxt.BaseError as e: + raise OperationalException(e) from e + @retrier def fetch_trading_fees(self) -> Dict[str, Any]: """ @@ -1616,39 +1662,18 @@ class Exchange: price_side = self._get_price_side(side, is_short, conf_strategy) - price_side_word = price_side.capitalize() - if conf_strategy.get('use_order_book', False): order_book_top = conf_strategy.get('order_book_top', 1) if order_book is None: order_book = self.fetch_l2_order_book(pair, order_book_top) - logger.debug('order_book %s', order_book) - # top 1 = index 0 - try: - obside: OBLiteral = 'bids' if price_side == 'bid' else 'asks' - rate = order_book[obside][order_book_top - 1][0] - except (IndexError, KeyError) as e: - logger.warning( - f"{pair} - {name} Price at location {order_book_top} from orderbook " - f"could not be determined. Orderbook: {order_book}" - ) - raise PricingError from e - logger.debug(f"{pair} - {name} price from orderbook {price_side_word}" - f"side - top {order_book_top} order book {side} rate {rate:.8f}") + rate = self._get_rate_from_ob(pair, side, order_book, name, price_side, + order_book_top) else: - logger.debug(f"Using Last {price_side_word} / Last Price") + logger.debug(f"Using Last {price_side.capitalize()} / Last Price") if ticker is None: ticker = self.fetch_ticker(pair) - ticker_rate = ticker[price_side] - if ticker['last'] and ticker_rate: - if side == 'entry' and ticker_rate > ticker['last']: - balance = conf_strategy.get('price_last_balance', 0.0) - ticker_rate = ticker_rate + balance * (ticker['last'] - ticker_rate) - elif side == 'exit' and ticker_rate < ticker['last']: - balance = conf_strategy.get('price_last_balance', 0.0) - ticker_rate = ticker_rate - balance * (ticker_rate - ticker['last']) - rate = ticker_rate + rate = self._get_rate_from_ticker(side, ticker, conf_strategy, price_side) if rate is None: raise PricingError(f"{name}-Rate for {pair} was empty.") @@ -1657,6 +1682,43 @@ class Exchange: return rate + def _get_rate_from_ticker(self, side: EntryExit, ticker: Ticker, conf_strategy: Dict[str, Any], + price_side: BidAsk) -> Optional[float]: + """ + Get rate from ticker. + """ + ticker_rate = ticker[price_side] + if ticker['last'] and ticker_rate: + if side == 'entry' and ticker_rate > ticker['last']: + balance = conf_strategy.get('price_last_balance', 0.0) + ticker_rate = ticker_rate + balance * (ticker['last'] - ticker_rate) + elif side == 'exit' and ticker_rate < ticker['last']: + balance = conf_strategy.get('price_last_balance', 0.0) + ticker_rate = ticker_rate - balance * (ticker_rate - ticker['last']) + rate = ticker_rate + return rate + + def _get_rate_from_ob(self, pair: str, side: EntryExit, order_book: OrderBook, name: str, + price_side: BidAsk, order_book_top: int) -> float: + """ + Get rate from orderbook + :raises: PricingError if rate could not be determined. + """ + logger.debug('order_book %s', order_book) + # top 1 = index 0 + try: + obside: OBLiteral = 'bids' if price_side == 'bid' else 'asks' + rate = order_book[obside][order_book_top - 1][0] + except (IndexError, KeyError) as e: + logger.warning( + f"{pair} - {name} Price at location {order_book_top} from orderbook " + f"could not be determined. Orderbook: {order_book}" + ) + raise PricingError from e + logger.debug(f"{pair} - {name} price from orderbook {price_side.capitalize()}" + f"side - top {order_book_top} order book {side} rate {rate:.8f}") + return rate + def get_rates(self, pair: str, refresh: bool, is_short: bool) -> Tuple[float, float]: entry_rate = None exit_rate = None @@ -1885,11 +1947,11 @@ class Exchange: logger.debug( "one_call: %s msecs (%s)", one_call, - arrow.utcnow().shift(seconds=one_call // 1000).humanize(only_distance=True) + dt_humanize(dt_now() - timedelta(milliseconds=one_call), only_distance=True) ) input_coroutines = [self._async_get_candle_history( pair, timeframe, candle_type, since) for since in - range(since_ms, until_ms or (arrow.utcnow().int_timestamp * 1000), one_call)] + range(since_ms, until_ms or dt_ts(), one_call)] data: List = [] # Chunk requests into batches of 100 to avoid overwelming ccxt Throttling @@ -2072,7 +2134,7 @@ class Exchange: """ try: # Fetch OHLCV asynchronously - s = '(' + arrow.get(since_ms // 1000).isoformat() + ') ' if since_ms is not None else '' + s = '(' + dt_from_ts(since_ms).isoformat() + ') ' if since_ms is not None else '' logger.debug( "Fetching pair %s, %s, interval %s, since %s %s...", pair, candle_type, timeframe, since_ms, s @@ -2162,7 +2224,7 @@ class Exchange: logger.debug( "Fetching trades for pair %s, since %s %s...", pair, since, - '(' + arrow.get(since // 1000).isoformat() + ') ' if since is not None else '' + '(' + dt_from_ts(since).isoformat() + ') ' if since is not None else '' ) trades = await self._api_async.fetch_trades(pair, since=since, limit=1000) trades = self._trades_contracts_to_amount(trades) @@ -2371,12 +2433,12 @@ class Exchange: # Must fetch the leverage tiers for each market separately # * This is slow(~45s) on Okx, makes ~90 api calls to load all linear swap markets markets = self.markets - symbols = [] - for symbol, market in markets.items(): + symbols = [ + symbol for symbol, market in markets.items() if (self.market_is_future(market) - and market['quote'] == self._config['stake_currency']): - symbols.append(symbol) + and market['quote'] == self._config['stake_currency']) + ] tiers: Dict[str, List[Dict]] = {} @@ -2396,25 +2458,26 @@ class Exchange: else: logger.info("Using cached leverage_tiers.") - async def gather_results(): + async def gather_results(input_coro): return await asyncio.gather(*input_coro, return_exceptions=True) for input_coro in chunks(coros, 100): with self._loop_lock: - results = self.loop.run_until_complete(gather_results()) + results = self.loop.run_until_complete(gather_results(input_coro)) - for symbol, res in results: - tiers[symbol] = res + for res in results: + if isinstance(res, Exception): + logger.warning(f"Leverage tier exception: {repr(res)}") + continue + symbol, tier = res + tiers[symbol] = tier if len(coros) > 0: self.cache_leverage_tiers(tiers, self._config['stake_currency']) logger.info(f"Done initializing {len(symbols)} markets.") return tiers - else: - return {} - else: - return {} + return {} def cache_leverage_tiers(self, tiers: Dict[str, List[Dict]], stake_currency: str) -> None: @@ -2430,14 +2493,17 @@ class Exchange: def load_cached_leverage_tiers(self, stake_currency: str) -> Optional[Dict[str, List[Dict]]]: filename = self._config['datadir'] / "futures" / f"leverage_tiers_{stake_currency}.json" if filename.is_file(): - tiers = file_load_json(filename) - updated = tiers.get('updated') - if updated: - updated_dt = parser.parse(updated) - if updated_dt < datetime.now(timezone.utc) - timedelta(weeks=4): - logger.info("Cached leverage tiers are outdated. Will update.") - return None - return tiers['data'] + try: + tiers = file_load_json(filename) + updated = tiers.get('updated') + if updated: + updated_dt = parser.parse(updated) + if updated_dt < datetime.now(timezone.utc) - timedelta(weeks=4): + logger.info("Cached leverage tiers are outdated. Will update.") + return None + return tiers['data'] + except Exception: + logger.exception("Error loading cached leverage tiers. Refreshing.") return None def fill_leverage_tiers(self) -> None: @@ -2892,8 +2958,8 @@ class Exchange: if nominal_value >= tier['minNotional']: return (tier['maintenanceMarginRate'], tier['maintAmt']) - raise OperationalException("nominal value can not be lower than 0") + raise ExchangeError("nominal value can not be lower than 0") # The lowest notional_floor for any pair in fetch_leverage_tiers is always 0 because it # describes the min amt for a tier, and the lowest tier will always go down to 0 else: - raise OperationalException(f"Cannot get maintenance ratio using {self.name}") + raise ExchangeError(f"Cannot get maintenance ratio using {self.name}") diff --git a/freqtrade/exchange/exchange_utils.py b/freqtrade/exchange/exchange_utils.py index 83d2a214d..fe7264dd9 100644 --- a/freqtrade/exchange/exchange_utils.py +++ b/freqtrade/exchange/exchange_utils.py @@ -9,8 +9,11 @@ import ccxt from ccxt import (DECIMAL_PLACES, ROUND, ROUND_DOWN, ROUND_UP, SIGNIFICANT_DIGITS, TICK_SIZE, TRUNCATE, decimal_to_precision) -from freqtrade.exchange.common import BAD_EXCHANGES, EXCHANGE_HAS_OPTIONAL, EXCHANGE_HAS_REQUIRED +from freqtrade.exchange.common import (BAD_EXCHANGES, EXCHANGE_HAS_OPTIONAL, EXCHANGE_HAS_REQUIRED, + SUPPORTED_EXCHANGES) +from freqtrade.types import ValidExchangesType from freqtrade.util import FtPrecise +from freqtrade.util.datetime_helpers import dt_from_ts, dt_ts CcxtModuleType = Any @@ -54,14 +57,41 @@ def validate_exchange(exchange: str) -> Tuple[bool, str]: return True, '' -def validate_exchanges(all_exchanges: bool) -> List[Tuple[str, bool, str]]: +def _build_exchange_list_entry( + exchange_name: str, exchangeClasses: Dict[str, Any]) -> ValidExchangesType: + valid, comment = validate_exchange(exchange_name) + result: ValidExchangesType = { + 'name': exchange_name, + 'valid': valid, + 'supported': exchange_name.lower() in SUPPORTED_EXCHANGES, + 'comment': comment, + 'trade_modes': [{'trading_mode': 'spot', 'margin_mode': ''}], + } + if resolved := exchangeClasses.get(exchange_name.lower()): + supported_modes = [{'trading_mode': 'spot', 'margin_mode': ''}] + [ + {'trading_mode': tm.value, 'margin_mode': mm.value} + for tm, mm in resolved['class']._supported_trading_mode_margin_pairs + ] + result.update({ + 'trade_modes': supported_modes, + }) + + return result + + +def list_available_exchanges(all_exchanges: bool) -> List[ValidExchangesType]: """ :return: List of tuples with exchangename, valid, reason. """ exchanges = ccxt_exchanges() if all_exchanges else available_exchanges() - exchanges_valid = [ - (e, *validate_exchange(e)) for e in exchanges + from freqtrade.resolvers.exchange_resolver import ExchangeResolver + + subclassed = {e['name'].lower(): e for e in ExchangeResolver.search_all_objects({}, False)} + + exchanges_valid: List[ValidExchangesType] = [ + _build_exchange_list_entry(e, subclassed) for e in exchanges ] + return exchanges_valid @@ -99,9 +129,8 @@ def timeframe_to_prev_date(timeframe: str, date: Optional[datetime] = None) -> d if not date: date = datetime.now(timezone.utc) - new_timestamp = ccxt.Exchange.round_timeframe(timeframe, date.timestamp() * 1000, - ROUND_DOWN) // 1000 - return datetime.fromtimestamp(new_timestamp, tz=timezone.utc) + new_timestamp = ccxt.Exchange.round_timeframe(timeframe, dt_ts(date), ROUND_DOWN) // 1000 + return dt_from_ts(new_timestamp) def timeframe_to_next_date(timeframe: str, date: Optional[datetime] = None) -> datetime: @@ -113,9 +142,8 @@ def timeframe_to_next_date(timeframe: str, date: Optional[datetime] = None) -> d """ if not date: date = datetime.now(timezone.utc) - new_timestamp = ccxt.Exchange.round_timeframe(timeframe, date.timestamp() * 1000, - ROUND_UP) // 1000 - return datetime.fromtimestamp(new_timestamp, tz=timezone.utc) + new_timestamp = ccxt.Exchange.round_timeframe(timeframe, dt_ts(date), ROUND_UP) // 1000 + return dt_from_ts(new_timestamp) def date_minus_candles( diff --git a/freqtrade/exchange/gate.py b/freqtrade/exchange/gate.py index 2ac135fc1..eceab4ec1 100644 --- a/freqtrade/exchange/gate.py +++ b/freqtrade/exchange/gate.py @@ -33,7 +33,6 @@ class Gate(Exchange): _ft_has_futures: Dict = { "needs_trading_fees": True, "marketOrderRequiresPrice": False, - "tickers_have_bid_ask": False, "fee_cost_in_contracts": False, # Set explicitly to false for clarity "order_props_in_contracts": ['amount', 'filled', 'remaining'], "stop_price_type_field": "price_type", diff --git a/freqtrade/exchange/okx.py b/freqtrade/exchange/okx.py index 84b7deb7a..c703e3a78 100644 --- a/freqtrade/exchange/okx.py +++ b/freqtrade/exchange/okx.py @@ -125,6 +125,20 @@ class Okx(Exchange): params['posSide'] = self._get_posSide(side, reduceOnly) return params + def __fetch_leverage_already_set(self, pair: str, leverage: float, side: BuySell) -> bool: + try: + res_lev = self._api.fetch_leverage(symbol=pair, params={ + "mgnMode": self.margin_mode.value, + "posSide": self._get_posSide(side, False), + }) + self._log_exchange_response('get_leverage', res_lev) + already_set = all(float(x['lever']) == leverage for x in res_lev['data']) + return already_set + + except ccxt.BaseError: + # Assume all errors as "not set yet" + return False + @retrier def _lev_prep(self, pair: str, leverage: float, side: BuySell, accept_fail: bool = False): if self.trading_mode != TradingMode.SPOT and self.margin_mode is not None: @@ -141,8 +155,11 @@ class Okx(Exchange): 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 + already_set = self.__fetch_leverage_already_set(pair, leverage, side) + if not already_set: + 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 @@ -169,6 +186,23 @@ class Okx(Exchange): params['posSide'] = self._get_posSide(side, True) return params + def _convert_stop_order(self, pair: str, order_id: str, order: Dict) -> Dict: + if ( + order['status'] == 'closed' + and (real_order_id := order.get('info', {}).get('ordId')) is not None + ): + # Once a order triggered, we fetch the regular followup order. + order_reg = self.fetch_order(real_order_id, pair) + self._log_exchange_response('fetch_stoploss_order1', order_reg) + order_reg['id_stop'] = order_reg['id'] + order_reg['id'] = order_id + order_reg['type'] = 'stoploss' + order_reg['status_stop'] = 'triggered' + return order_reg + order = self._order_contracts_to_amount(order) + order['type'] = 'stoploss' + return order + def fetch_stoploss_order(self, order_id: str, pair: str, params: Dict = {}) -> Dict: if self._config['dry_run']: return self.fetch_dry_run_order(order_id) @@ -177,7 +211,7 @@ class Okx(Exchange): params1 = {'stop': True} order_reg = self._api.fetch_order(order_id, pair, params=params1) self._log_exchange_response('fetch_stoploss_order', order_reg) - return order_reg + return self._convert_stop_order(pair, order_id, order_reg) except ccxt.OrderNotFound: pass params2 = {'stop': True, 'ordType': 'conditional'} @@ -188,18 +222,7 @@ class Okx(Exchange): orders_f = [order for order in orders if order['id'] == order_id] if orders_f: order = orders_f[0] - if (order['status'] == 'closed' - and (real_order_id := order.get('info', {}).get('ordId')) is not None): - # Once a order triggered, we fetch the regular followup order. - order_reg = self.fetch_order(real_order_id, pair) - self._log_exchange_response('fetch_stoploss_order1', order_reg) - order_reg['id_stop'] = order_reg['id'] - order_reg['id'] = order_id - order_reg['type'] = 'stoploss' - order_reg['status_stop'] = 'triggered' - return order_reg - order['type'] = 'stoploss' - return order + return self._convert_stop_order(pair, order_id, order) except ccxt.BaseError: pass raise RetryableOrderError( diff --git a/freqtrade/freqai/RL/Base3ActionRLEnv.py b/freqtrade/freqai/RL/Base3ActionRLEnv.py index c0a7eedaa..538ca3a6a 100644 --- a/freqtrade/freqai/RL/Base3ActionRLEnv.py +++ b/freqtrade/freqai/RL/Base3ActionRLEnv.py @@ -1,7 +1,7 @@ import logging from enum import Enum -from gym import spaces +from gymnasium import spaces from freqtrade.freqai.RL.BaseEnvironment import BaseEnvironment, Positions @@ -94,9 +94,12 @@ class Base3ActionRLEnv(BaseEnvironment): observation = self._get_observation() + # user can play with time if they want + truncated = False + self._update_history(info) - return observation, step_reward, self._done, info + return observation, step_reward, self._done, truncated, info def is_tradesignal(self, action: int) -> bool: """ diff --git a/freqtrade/freqai/RL/Base4ActionRLEnv.py b/freqtrade/freqai/RL/Base4ActionRLEnv.py index e883136b2..12f10d4fc 100644 --- a/freqtrade/freqai/RL/Base4ActionRLEnv.py +++ b/freqtrade/freqai/RL/Base4ActionRLEnv.py @@ -1,7 +1,7 @@ import logging from enum import Enum -from gym import spaces +from gymnasium import spaces from freqtrade.freqai.RL.BaseEnvironment import BaseEnvironment, Positions @@ -96,9 +96,12 @@ class Base4ActionRLEnv(BaseEnvironment): observation = self._get_observation() + # user can play with time if they want + truncated = False + self._update_history(info) - return observation, step_reward, self._done, info + return observation, step_reward, self._done, truncated, info def is_tradesignal(self, action: int) -> bool: """ diff --git a/freqtrade/freqai/RL/Base5ActionRLEnv.py b/freqtrade/freqai/RL/Base5ActionRLEnv.py index 816211cc2..35d04f942 100644 --- a/freqtrade/freqai/RL/Base5ActionRLEnv.py +++ b/freqtrade/freqai/RL/Base5ActionRLEnv.py @@ -1,7 +1,7 @@ import logging from enum import Enum -from gym import spaces +from gymnasium import spaces from freqtrade.freqai.RL.BaseEnvironment import BaseEnvironment, Positions @@ -101,10 +101,12 @@ class Base5ActionRLEnv(BaseEnvironment): ) observation = self._get_observation() + # user can play with time if they want + truncated = False self._update_history(info) - return observation, step_reward, self._done, info + return observation, step_reward, self._done, truncated, info def is_tradesignal(self, action: int) -> bool: """ diff --git a/freqtrade/freqai/RL/BaseEnvironment.py b/freqtrade/freqai/RL/BaseEnvironment.py index 7ac77361c..91c7501c6 100644 --- a/freqtrade/freqai/RL/BaseEnvironment.py +++ b/freqtrade/freqai/RL/BaseEnvironment.py @@ -2,13 +2,13 @@ import logging import random from abc import abstractmethod from enum import Enum -from typing import Optional, Type, Union +from typing import List, Optional, Type, Union -import gym +import gymnasium as gym import numpy as np import pandas as pd -from gym import spaces -from gym.utils import seeding +from gymnasium import spaces +from gymnasium.utils import seeding from pandas import DataFrame @@ -127,12 +127,23 @@ class BaseEnvironment(gym.Env): self.history: dict = {} self.trade_history: list = [] + def get_attr(self, attr: str): + """ + Returns the attribute of the environment + :param attr: attribute to return + :return: attribute + """ + return getattr(self, attr) + @abstractmethod def set_action_space(self): """ Unique to the environment action count. Must be inherited. """ + def action_masks(self) -> List[bool]: + return [self._is_valid(action.value) for action in self.actions] + def seed(self, seed: int = 1): self.np_random, seed = seeding.np_random(seed) return [seed] @@ -172,7 +183,7 @@ class BaseEnvironment(gym.Env): def reset_tensorboard_log(self): self.tensorboard_metrics = {} - def reset(self): + def reset(self, seed=None): """ Reset is called at the beginning of every episode """ @@ -203,7 +214,7 @@ class BaseEnvironment(gym.Env): self.close_trade_profit = [] self._total_unrealized_profit = 1 - return self._get_observation() + return self._get_observation(), self.history @abstractmethod def step(self, action: int): @@ -298,6 +309,12 @@ class BaseEnvironment(gym.Env): """ An example reward function. This is the one function that users will likely wish to inject their own creativity into. + + Warning! + This is function is a showcase of functionality designed to show as many possible + environment control features as possible. It is also designed to run quickly + on small computers. This is a benchmark, it is *not* for live production. + :param action: int = The action made by the agent for the current candle. :return: float = the reward to give to the agent for current step (used for optimization diff --git a/freqtrade/freqai/RL/BaseReinforcementLearningModel.py b/freqtrade/freqai/RL/BaseReinforcementLearningModel.py index e10880f46..06ce772a8 100644 --- a/freqtrade/freqai/RL/BaseReinforcementLearningModel.py +++ b/freqtrade/freqai/RL/BaseReinforcementLearningModel.py @@ -6,24 +6,25 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable, Dict, Optional, Tuple, Type, Union -import gym +import gymnasium as gym import numpy as np import numpy.typing as npt import pandas as pd import torch as th import torch.multiprocessing from pandas import DataFrame -from stable_baselines3.common.callbacks import EvalCallback +from sb3_contrib.common.maskable.callbacks import MaskableEvalCallback +from sb3_contrib.common.maskable.utils import is_masking_supported from stable_baselines3.common.monitor import Monitor from stable_baselines3.common.utils import set_random_seed -from stable_baselines3.common.vec_env import SubprocVecEnv +from stable_baselines3.common.vec_env import SubprocVecEnv, VecMonitor from freqtrade.exceptions import OperationalException from freqtrade.freqai.data_kitchen import FreqaiDataKitchen from freqtrade.freqai.freqai_interface import IFreqaiModel from freqtrade.freqai.RL.Base5ActionRLEnv import Actions, Base5ActionRLEnv -from freqtrade.freqai.RL.BaseEnvironment import BaseActions, Positions -from freqtrade.freqai.RL.TensorboardCallback import TensorboardCallback +from freqtrade.freqai.RL.BaseEnvironment import BaseActions, BaseEnvironment, Positions +from freqtrade.freqai.tensorboard.TensorboardCallback import TensorboardCallback from freqtrade.persistence import Trade @@ -46,9 +47,9 @@ class BaseReinforcementLearningModel(IFreqaiModel): 'cpu_count', 1), max(int(self.max_system_threads / 2), 1)) th.set_num_threads(self.max_threads) self.reward_params = self.freqai_info['rl_config']['model_reward_parameters'] - self.train_env: Union[SubprocVecEnv, Type[gym.Env]] = gym.Env() - self.eval_env: Union[SubprocVecEnv, Type[gym.Env]] = gym.Env() - self.eval_callback: Optional[EvalCallback] = None + self.train_env: Union[VecMonitor, SubprocVecEnv, gym.Env] = gym.Env() + self.eval_env: Union[VecMonitor, SubprocVecEnv, gym.Env] = gym.Env() + self.eval_callback: Optional[MaskableEvalCallback] = None self.model_type = self.freqai_info['rl_config']['model_type'] self.rl_config = self.freqai_info['rl_config'] self.df_raw: DataFrame = DataFrame() @@ -82,6 +83,9 @@ class BaseReinforcementLearningModel(IFreqaiModel): if self.ft_params.get('use_DBSCAN_to_remove_outliers', False): self.ft_params.update({'use_DBSCAN_to_remove_outliers': False}) logger.warning('User tried to use DBSCAN with RL. Deactivating DBSCAN.') + if self.ft_params.get('DI_threshold', False): + self.ft_params.update({'DI_threshold': False}) + logger.warning('User tried to use DI_threshold with RL. Deactivating DI_threshold.') if self.freqai_info['data_split_parameters'].get('shuffle', False): self.freqai_info['data_split_parameters'].update({'shuffle': False}) logger.warning('User tried to shuffle training data. Setting shuffle to False') @@ -107,27 +111,37 @@ class BaseReinforcementLearningModel(IFreqaiModel): training_filter=True, ) - data_dictionary: Dict[str, Any] = dk.make_train_test_datasets( + dd: Dict[str, Any] = dk.make_train_test_datasets( features_filtered, labels_filtered) - self.df_raw = copy.deepcopy(data_dictionary["train_features"]) + self.df_raw = copy.deepcopy(dd["train_features"]) dk.fit_labels() # FIXME useless for now, but just satiating append methods # normalize all data based on train_dataset only prices_train, prices_test = self.build_ohlc_price_dataframes(dk.data_dictionary, pair, dk) - data_dictionary = dk.normalize_data(data_dictionary) + dk.feature_pipeline = self.define_data_pipeline(threads=dk.thread_count) - # data cleaning/analysis - self.data_cleaning_train(dk) + (dd["train_features"], + dd["train_labels"], + dd["train_weights"]) = dk.feature_pipeline.fit_transform(dd["train_features"], + dd["train_labels"], + dd["train_weights"]) + + if self.freqai_info.get('data_split_parameters', {}).get('test_size', 0.1) != 0: + (dd["test_features"], + dd["test_labels"], + dd["test_weights"]) = dk.feature_pipeline.transform(dd["test_features"], + dd["test_labels"], + dd["test_weights"]) logger.info( f'Training model on {len(dk.data_dictionary["train_features"].columns)}' - f' features and {len(data_dictionary["train_features"])} data points' + f' features and {len(dd["train_features"])} data points' ) - self.set_train_and_eval_environments(data_dictionary, prices_train, prices_test, dk) + self.set_train_and_eval_environments(dd, prices_train, prices_test, dk) - model = self.fit(data_dictionary, dk) + model = self.fit(dd, dk) logger.info(f"--------------------done training {pair}--------------------") @@ -151,9 +165,11 @@ class BaseReinforcementLearningModel(IFreqaiModel): self.train_env = self.MyRLEnv(df=train_df, prices=prices_train, **env_info) self.eval_env = Monitor(self.MyRLEnv(df=test_df, prices=prices_test, **env_info)) - self.eval_callback = EvalCallback(self.eval_env, deterministic=True, - render=False, eval_freq=len(train_df), - best_model_save_path=str(dk.data_path)) + self.eval_callback = MaskableEvalCallback(self.eval_env, deterministic=True, + render=False, eval_freq=len(train_df), + best_model_save_path=str(dk.data_path), + use_masking=(self.model_type == 'MaskablePPO' and + is_masking_supported(self.eval_env))) actions = self.train_env.get_actions() self.tensorboard_callback = TensorboardCallback(verbose=1, actions=actions) @@ -236,13 +252,10 @@ class BaseReinforcementLearningModel(IFreqaiModel): unfiltered_df, dk.training_features_list, training_filter=False ) - filtered_dataframe = self.drop_ohlc_from_df(filtered_dataframe, dk) + dk.data_dictionary["prediction_features"] = self.drop_ohlc_from_df(filtered_dataframe, dk) - filtered_dataframe = dk.normalize_data_from_metadata(filtered_dataframe) - dk.data_dictionary["prediction_features"] = filtered_dataframe - - # optional additional data cleaning/analysis - self.data_cleaning_predict(dk) + dk.data_dictionary["prediction_features"], _, _ = dk.feature_pipeline.transform( + dk.data_dictionary["prediction_features"], outlier_check=True) pred_df = self.rl_model_predict( dk.data_dictionary["prediction_features"], dk, self.model) @@ -371,6 +384,12 @@ class BaseReinforcementLearningModel(IFreqaiModel): """ An example reward function. This is the one function that users will likely wish to inject their own creativity into. + + Warning! + This is function is a showcase of functionality designed to show as many possible + environment control features as possible. It is also designed to run quickly + on small computers. This is a benchmark, it is *not* for live production. + :param action: int = The action made by the agent for the current candle. :return: float = the reward to give to the agent for current step (used for optimization @@ -431,9 +450,8 @@ class BaseReinforcementLearningModel(IFreqaiModel): return 0. -def make_env(MyRLEnv: Type[gym.Env], env_id: str, rank: int, +def make_env(MyRLEnv: Type[BaseEnvironment], env_id: str, rank: int, seed: int, train_df: DataFrame, price: DataFrame, - monitor: bool = False, env_info: Dict[str, Any] = {}) -> Callable: """ Utility function for multiprocessed env. @@ -450,8 +468,7 @@ def make_env(MyRLEnv: Type[gym.Env], env_id: str, rank: int, env = MyRLEnv(df=train_df, prices=price, id=env_id, seed=seed + rank, **env_info) - if monitor: - env = Monitor(env) + return env set_random_seed(seed) return _init diff --git a/freqtrade/freqai/base_models/BaseClassifierModel.py b/freqtrade/freqai/base_models/BaseClassifierModel.py index ffd42dd1d..42b5c1a0e 100644 --- a/freqtrade/freqai/base_models/BaseClassifierModel.py +++ b/freqtrade/freqai/base_models/BaseClassifierModel.py @@ -17,8 +17,8 @@ logger = logging.getLogger(__name__) class BaseClassifierModel(IFreqaiModel): """ Base class for regression type models (e.g. Catboost, LightGBM, XGboost etc.). - User *must* inherit from this class and set fit() and predict(). See example scripts - such as prediction_models/CatboostPredictionModel.py for guidance. + User *must* inherit from this class and set fit(). See example scripts + such as prediction_models/CatboostClassifier.py for guidance. """ def train( @@ -50,21 +50,30 @@ class BaseClassifierModel(IFreqaiModel): logger.info(f"-------------------- Training on data from {start_date} to " f"{end_date} --------------------") # split data into train/test data. - data_dictionary = dk.make_train_test_datasets(features_filtered, labels_filtered) + dd = dk.make_train_test_datasets(features_filtered, labels_filtered) if not self.freqai_info.get("fit_live_predictions_candles", 0) or not self.live: dk.fit_labels() - # normalize all data based on train_dataset only - data_dictionary = dk.normalize_data(data_dictionary) + dk.feature_pipeline = self.define_data_pipeline(threads=dk.thread_count) - # optional additional data cleaning/analysis - self.data_cleaning_train(dk) + (dd["train_features"], + dd["train_labels"], + dd["train_weights"]) = dk.feature_pipeline.fit_transform(dd["train_features"], + dd["train_labels"], + dd["train_weights"]) + + if self.freqai_info.get('data_split_parameters', {}).get('test_size', 0.1) != 0: + (dd["test_features"], + dd["test_labels"], + dd["test_weights"]) = dk.feature_pipeline.transform(dd["test_features"], + dd["test_labels"], + dd["test_weights"]) logger.info( 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") + logger.info(f"Training model on {len(dd['train_features'])} data points") - model = self.fit(data_dictionary, dk) + model = self.fit(dd, dk) end_time = time() @@ -89,10 +98,11 @@ class BaseClassifierModel(IFreqaiModel): filtered_df, _ = dk.filter_features( unfiltered_df, dk.training_features_list, training_filter=False ) - filtered_df = dk.normalize_data_from_metadata(filtered_df) + dk.data_dictionary["prediction_features"] = filtered_df - self.data_cleaning_predict(dk) + dk.data_dictionary["prediction_features"], outliers, _ = dk.feature_pipeline.transform( + dk.data_dictionary["prediction_features"], outlier_check=True) predictions = self.model.predict(dk.data_dictionary["prediction_features"]) if self.CONV_WIDTH == 1: @@ -107,4 +117,10 @@ class BaseClassifierModel(IFreqaiModel): pred_df = pd.concat([pred_df, pred_df_prob], axis=1) + if dk.feature_pipeline["di"]: + dk.DI_values = dk.feature_pipeline["di"].di_values + else: + dk.DI_values = np.zeros(outliers.shape[0]) + dk.do_predict = outliers + return (pred_df, dk.do_predict) diff --git a/freqtrade/freqai/base_models/BasePyTorchClassifier.py b/freqtrade/freqai/base_models/BasePyTorchClassifier.py index 977152cc5..4780af818 100644 --- a/freqtrade/freqai/base_models/BasePyTorchClassifier.py +++ b/freqtrade/freqai/base_models/BasePyTorchClassifier.py @@ -1,5 +1,6 @@ import logging -from typing import Dict, List, Tuple +from time import time +from typing import Any, Dict, List, Tuple import numpy as np import numpy.typing as npt @@ -35,6 +36,7 @@ class BasePyTorchClassifier(BasePyTorchModel): return dataframe """ + def __init__(self, **kwargs): super().__init__(**kwargs) self.class_name_to_index = None @@ -45,6 +47,7 @@ class BasePyTorchClassifier(BasePyTorchModel): ) -> Tuple[DataFrame, npt.NDArray[np.int_]]: """ Filter the prediction features data and predict with it. + :param dk: dk: The datakitchen object :param unfiltered_df: Full dataframe for the current backtest period. :return: :pred_df: dataframe containing the predictions @@ -67,20 +70,33 @@ class BasePyTorchClassifier(BasePyTorchModel): filtered_df, _ = dk.filter_features( unfiltered_df, dk.training_features_list, training_filter=False ) - filtered_df = dk.normalize_data_from_metadata(filtered_df) + dk.data_dictionary["prediction_features"] = filtered_df - self.data_cleaning_predict(dk) + + dk.data_dictionary["prediction_features"], outliers, _ = dk.feature_pipeline.transform( + dk.data_dictionary["prediction_features"], outlier_check=True) + x = self.data_convertor.convert_x( dk.data_dictionary["prediction_features"], device=self.device ) + self.model.model.eval() logits = self.model.model(x) probs = F.softmax(logits, dim=-1) predicted_classes = torch.argmax(probs, dim=-1) predicted_classes_str = self.decode_class_names(predicted_classes) - pred_df_prob = DataFrame(probs.detach().numpy(), columns=class_names) + # used .tolist to convert probs into an iterable, in this way Tensors + # are automatically moved to the CPU first if necessary. + pred_df_prob = DataFrame(probs.detach().tolist(), columns=class_names) pred_df = DataFrame(predicted_classes_str, columns=[dk.label_list[0]]) pred_df = pd.concat([pred_df, pred_df_prob], axis=1) + + if dk.feature_pipeline["di"]: + dk.DI_values = dk.feature_pipeline["di"].di_values + else: + dk.DI_values = np.zeros(outliers.shape[0]) + dk.do_predict = outliers + return (pred_df, dk.do_predict) def encode_class_names( @@ -145,3 +161,58 @@ class BasePyTorchClassifier(BasePyTorchModel): ) return self.class_names + + def train( + self, unfiltered_df: DataFrame, pair: str, dk: FreqaiDataKitchen, **kwargs + ) -> Any: + """ + Filter the training data and train a model to it. Train makes heavy use of the datakitchen + for storing, saving, loading, and analyzing the data. + :param unfiltered_df: Full dataframe for the current training period + :return: + :model: Trained model which can be used to inference (self.predict) + """ + + logger.info(f"-------------------- Starting training {pair} --------------------") + + start_time = time() + + features_filtered, labels_filtered = dk.filter_features( + unfiltered_df, + dk.training_features_list, + dk.label_list, + training_filter=True, + ) + + # split data into train/test data. + dd = dk.make_train_test_datasets(features_filtered, labels_filtered) + if not self.freqai_info.get("fit_live_predictions_candles", 0) or not self.live: + dk.fit_labels() + + dk.feature_pipeline = self.define_data_pipeline(threads=dk.thread_count) + + (dd["train_features"], + dd["train_labels"], + dd["train_weights"]) = dk.feature_pipeline.fit_transform(dd["train_features"], + dd["train_labels"], + dd["train_weights"]) + + if self.freqai_info.get('data_split_parameters', {}).get('test_size', 0.1) != 0: + (dd["test_features"], + dd["test_labels"], + dd["test_weights"]) = dk.feature_pipeline.transform(dd["test_features"], + dd["test_labels"], + dd["test_weights"]) + + logger.info( + f"Training model on {len(dk.data_dictionary['train_features'].columns)} features" + ) + logger.info(f"Training model on {len(dd['train_features'])} data points") + + model = self.fit(dd, dk) + 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/BasePyTorchModel.py b/freqtrade/freqai/base_models/BasePyTorchModel.py index 8177b8eb8..71369a146 100644 --- a/freqtrade/freqai/base_models/BasePyTorchModel.py +++ b/freqtrade/freqai/base_models/BasePyTorchModel.py @@ -1,12 +1,8 @@ import logging from abc import ABC, abstractmethod -from time import time -from typing import Any import torch -from pandas import DataFrame -from freqtrade.freqai.data_kitchen import FreqaiDataKitchen from freqtrade.freqai.freqai_interface import IFreqaiModel from freqtrade.freqai.torch.PyTorchDataConvertor import PyTorchDataConvertor @@ -27,51 +23,7 @@ class BasePyTorchModel(IFreqaiModel, ABC): self.device = "cuda" if torch.cuda.is_available() else "cpu" test_size = self.freqai_info.get('data_split_parameters', {}).get('test_size') self.splits = ["train", "test"] if test_size != 0 else ["train"] - - def train( - self, unfiltered_df: DataFrame, pair: str, dk: FreqaiDataKitchen, **kwargs - ) -> Any: - """ - Filter the training data and train a model to it. Train makes heavy use of the datakitchen - for storing, saving, loading, and analyzing the data. - :param unfiltered_df: Full dataframe for the current training period - :return: - :model: Trained model which can be used to inference (self.predict) - """ - - logger.info(f"-------------------- Starting training {pair} --------------------") - - start_time = time() - - features_filtered, labels_filtered = dk.filter_features( - unfiltered_df, - dk.training_features_list, - dk.label_list, - training_filter=True, - ) - - # 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: - dk.fit_labels() - # normalize all data based on train_dataset only - data_dictionary = dk.normalize_data(data_dictionary) - - # optional additional data cleaning/analysis - self.data_cleaning_train(dk) - - logger.info( - 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) - end_time = time() - - logger.info(f"-------------------- Done training {pair} " - f"({end_time - start_time:.2f} secs) --------------------") - - return model + self.window_size = self.freqai_info.get("conv_width", 1) @property @abstractmethod diff --git a/freqtrade/freqai/base_models/BasePyTorchRegressor.py b/freqtrade/freqai/base_models/BasePyTorchRegressor.py index ea6fabe49..83fea4ef9 100644 --- a/freqtrade/freqai/base_models/BasePyTorchRegressor.py +++ b/freqtrade/freqai/base_models/BasePyTorchRegressor.py @@ -1,5 +1,6 @@ import logging -from typing import Tuple +from time import time +from typing import Any, Tuple import numpy as np import numpy.typing as npt @@ -17,6 +18,7 @@ class BasePyTorchRegressor(BasePyTorchModel): A PyTorch implementation of a regressor. User must implement fit method """ + def __init__(self, **kwargs): super().__init__(**kwargs) @@ -36,15 +38,83 @@ class BasePyTorchRegressor(BasePyTorchModel): filtered_df, _ = dk.filter_features( unfiltered_df, dk.training_features_list, training_filter=False ) - filtered_df = dk.normalize_data_from_metadata(filtered_df) dk.data_dictionary["prediction_features"] = filtered_df - self.data_cleaning_predict(dk) + dk.data_dictionary["prediction_features"], outliers, _ = dk.feature_pipeline.transform( + dk.data_dictionary["prediction_features"], outlier_check=True) + x = self.data_convertor.convert_x( dk.data_dictionary["prediction_features"], device=self.device ) + self.model.model.eval() y = self.model.model(x) - y = y.cpu() - pred_df = DataFrame(y.detach().numpy(), columns=[dk.label_list[0]]) + pred_df = DataFrame(y.detach().tolist(), columns=[dk.label_list[0]]) + pred_df, _, _ = dk.label_pipeline.inverse_transform(pred_df) + + if dk.feature_pipeline["di"]: + dk.DI_values = dk.feature_pipeline["di"].di_values + else: + dk.DI_values = np.zeros(outliers.shape[0]) + dk.do_predict = outliers return (pred_df, dk.do_predict) + + def train( + self, unfiltered_df: DataFrame, pair: str, dk: FreqaiDataKitchen, **kwargs + ) -> Any: + """ + Filter the training data and train a model to it. Train makes heavy use of the datakitchen + for storing, saving, loading, and analyzing the data. + :param unfiltered_df: Full dataframe for the current training period + :return: + :model: Trained model which can be used to inference (self.predict) + """ + + logger.info(f"-------------------- Starting training {pair} --------------------") + + start_time = time() + + features_filtered, labels_filtered = dk.filter_features( + unfiltered_df, + dk.training_features_list, + dk.label_list, + training_filter=True, + ) + + # split data into train/test data. + dd = dk.make_train_test_datasets(features_filtered, labels_filtered) + if not self.freqai_info.get("fit_live_predictions_candles", 0) or not self.live: + dk.fit_labels() + dk.feature_pipeline = self.define_data_pipeline(threads=dk.thread_count) + dk.label_pipeline = self.define_label_pipeline(threads=dk.thread_count) + + dd["train_labels"], _, _ = dk.label_pipeline.fit_transform(dd["train_labels"]) + dd["test_labels"], _, _ = dk.label_pipeline.transform(dd["test_labels"]) + + (dd["train_features"], + dd["train_labels"], + dd["train_weights"]) = dk.feature_pipeline.fit_transform(dd["train_features"], + dd["train_labels"], + dd["train_weights"]) + dd["train_labels"], _, _ = dk.label_pipeline.fit_transform(dd["train_labels"]) + + if self.freqai_info.get('data_split_parameters', {}).get('test_size', 0.1) != 0: + (dd["test_features"], + dd["test_labels"], + dd["test_weights"]) = dk.feature_pipeline.transform(dd["test_features"], + dd["test_labels"], + dd["test_weights"]) + dd["test_labels"], _, _ = dk.label_pipeline.transform(dd["test_labels"]) + + logger.info( + f"Training model on {len(dk.data_dictionary['train_features'].columns)} features" + ) + logger.info(f"Training model on {len(dd['train_features'])} data points") + + model = self.fit(dd, dk) + 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/BaseRegressionModel.py b/freqtrade/freqai/base_models/BaseRegressionModel.py index 1f9b4f5a6..179e4be87 100644 --- a/freqtrade/freqai/base_models/BaseRegressionModel.py +++ b/freqtrade/freqai/base_models/BaseRegressionModel.py @@ -16,8 +16,8 @@ logger = logging.getLogger(__name__) class BaseRegressionModel(IFreqaiModel): """ Base class for regression type models (e.g. Catboost, LightGBM, XGboost etc.). - User *must* inherit from this class and set fit() and predict(). See example scripts - such as prediction_models/CatboostPredictionModel.py for guidance. + User *must* inherit from this class and set fit(). See example scripts + such as prediction_models/CatboostRegressor.py for guidance. """ def train( @@ -49,21 +49,33 @@ class BaseRegressionModel(IFreqaiModel): logger.info(f"-------------------- Training on data from {start_date} to " f"{end_date} --------------------") # split data into train/test data. - data_dictionary = dk.make_train_test_datasets(features_filtered, labels_filtered) + dd = dk.make_train_test_datasets(features_filtered, labels_filtered) if not self.freqai_info.get("fit_live_predictions_candles", 0) or not self.live: dk.fit_labels() - # normalize all data based on train_dataset only - data_dictionary = dk.normalize_data(data_dictionary) + dk.feature_pipeline = self.define_data_pipeline(threads=dk.thread_count) + dk.label_pipeline = self.define_label_pipeline(threads=dk.thread_count) - # optional additional data cleaning/analysis - self.data_cleaning_train(dk) + (dd["train_features"], + dd["train_labels"], + dd["train_weights"]) = dk.feature_pipeline.fit_transform(dd["train_features"], + dd["train_labels"], + dd["train_weights"]) + dd["train_labels"], _, _ = dk.label_pipeline.fit_transform(dd["train_labels"]) + + if self.freqai_info.get('data_split_parameters', {}).get('test_size', 0.1) != 0: + (dd["test_features"], + dd["test_labels"], + dd["test_weights"]) = dk.feature_pipeline.transform(dd["test_features"], + dd["test_labels"], + dd["test_weights"]) + dd["test_labels"], _, _ = dk.label_pipeline.transform(dd["test_labels"]) logger.info( 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") + logger.info(f"Training model on {len(dd['train_features'])} data points") - model = self.fit(data_dictionary, dk) + model = self.fit(dd, dk) end_time = time() @@ -85,14 +97,12 @@ class BaseRegressionModel(IFreqaiModel): """ dk.find_features(unfiltered_df) - filtered_df, _ = dk.filter_features( + dk.data_dictionary["prediction_features"], _ = dk.filter_features( unfiltered_df, dk.training_features_list, training_filter=False ) - filtered_df = dk.normalize_data_from_metadata(filtered_df) - dk.data_dictionary["prediction_features"] = filtered_df - # optional additional data cleaning/analysis - self.data_cleaning_predict(dk) + dk.data_dictionary["prediction_features"], outliers, _ = dk.feature_pipeline.transform( + dk.data_dictionary["prediction_features"], outlier_check=True) predictions = self.model.predict(dk.data_dictionary["prediction_features"]) if self.CONV_WIDTH == 1: @@ -100,6 +110,11 @@ class BaseRegressionModel(IFreqaiModel): pred_df = DataFrame(predictions, columns=dk.label_list) - pred_df = dk.denormalize_labels_from_metadata(pred_df) + pred_df, _, _ = dk.label_pipeline.inverse_transform(pred_df) + if dk.feature_pipeline["di"]: + dk.DI_values = dk.feature_pipeline["di"].di_values + else: + dk.DI_values = np.zeros(outliers.shape[0]) + dk.do_predict = outliers return (pred_df, dk.do_predict) diff --git a/freqtrade/freqai/base_models/BaseTensorFlowModel.py b/freqtrade/freqai/base_models/BaseTensorFlowModel.py deleted file mode 100644 index b41ee0175..000000000 --- a/freqtrade/freqai/base_models/BaseTensorFlowModel.py +++ /dev/null @@ -1,70 +0,0 @@ -import logging -from time import time -from typing import Any - -from pandas import DataFrame - -from freqtrade.freqai.data_kitchen import FreqaiDataKitchen -from freqtrade.freqai.freqai_interface import IFreqaiModel - - -logger = logging.getLogger(__name__) - - -class BaseTensorFlowModel(IFreqaiModel): - """ - Base class for TensorFlow type models. - User *must* inherit from this class and set fit() and predict(). - """ - - def train( - self, unfiltered_df: DataFrame, pair: str, dk: FreqaiDataKitchen, **kwargs - ) -> Any: - """ - Filter the training data and train a model to it. Train makes heavy use of the datakitchen - for storing, saving, loading, and analyzing the data. - :param unfiltered_df: Full dataframe for the current training period - :param metadata: pair metadata from strategy. - :return: - :model: Trained model which can be used to inference (self.predict) - """ - - 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( - unfiltered_df, - dk.training_features_list, - dk.label_list, - training_filter=True, - ) - - 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} --------------------") - # 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_candles", 0) or not self.live: - dk.fit_labels() - # normalize all data based on train_dataset only - data_dictionary = dk.normalize_data(data_dictionary) - - # optional additional data cleaning/analysis - self.data_cleaning_train(dk) - - logger.info( - 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) - - end_time = time() - - logger.info(f"-------------------- Done training {pair} " - f"({end_time - start_time:.2f} secs) --------------------") - - return model diff --git a/freqtrade/freqai/data_drawer.py b/freqtrade/freqai/data_drawer.py index b68a9dcad..5fdb8be25 100644 --- a/freqtrade/freqai/data_drawer.py +++ b/freqtrade/freqai/data_drawer.py @@ -20,6 +20,7 @@ from pandas import DataFrame from freqtrade.configuration import TimeRange from freqtrade.constants import Config from freqtrade.data.history import load_pair_history +from freqtrade.enums import CandleType from freqtrade.exceptions import OperationalException from freqtrade.freqai.data_kitchen import FreqaiDataKitchen from freqtrade.strategy.interface import IStrategy @@ -27,6 +28,11 @@ from freqtrade.strategy.interface import IStrategy logger = logging.getLogger(__name__) +FEATURE_PIPELINE = "feature_pipeline" +LABEL_PIPELINE = "label_pipeline" +TRAINDF = "trained_df" +METADATA = "metadata" + class pair_info(TypedDict): model_filename: str @@ -424,7 +430,7 @@ class FreqaiDataDrawer: dk.data["training_features_list"] = list(dk.data_dictionary["train_features"].columns) dk.data["label_list"] = dk.label_list - with (save_path / f"{dk.model_filename}_metadata.json").open("w") as fp: + with (save_path / f"{dk.model_filename}_{METADATA}.json").open("w") as fp: rapidjson.dump(dk.data, fp, default=self.np_encoder, number_mode=rapidjson.NM_NATIVE) return @@ -449,39 +455,39 @@ class FreqaiDataDrawer: elif self.model_type in ["stable_baselines3", "sb3_contrib", "pytorch"]: model.save(save_path / f"{dk.model_filename}_model.zip") - if dk.svm_model is not None: - dump(dk.svm_model, save_path / f"{dk.model_filename}_svm_model.joblib") - dk.data["data_path"] = str(dk.data_path) dk.data["model_filename"] = str(dk.model_filename) dk.data["training_features_list"] = dk.training_features_list dk.data["label_list"] = dk.label_list # store the metadata - with (save_path / f"{dk.model_filename}_metadata.json").open("w") as fp: + with (save_path / f"{dk.model_filename}_{METADATA}.json").open("w") as fp: rapidjson.dump(dk.data, fp, default=self.np_encoder, number_mode=rapidjson.NM_NATIVE) - # save the train data to file so we can check preds for area of applicability later + # save the pipelines to pickle files + with (save_path / f"{dk.model_filename}_{FEATURE_PIPELINE}.pkl").open("wb") as fp: + cloudpickle.dump(dk.feature_pipeline, fp) + + with (save_path / f"{dk.model_filename}_{LABEL_PIPELINE}.pkl").open("wb") as fp: + cloudpickle.dump(dk.label_pipeline, fp) + + # save the train data to file for post processing if desired dk.data_dictionary["train_features"].to_pickle( - save_path / f"{dk.model_filename}_trained_df.pkl" + save_path / f"{dk.model_filename}_{TRAINDF}.pkl" ) dk.data_dictionary["train_dates"].to_pickle( save_path / f"{dk.model_filename}_trained_dates_df.pkl" ) - if self.freqai_info["feature_parameters"].get("principal_component_analysis"): - cloudpickle.dump( - dk.pca, (dk.data_path / f"{dk.model_filename}_pca_object.pkl").open("wb") - ) - self.model_dictionary[coin] = model self.pair_dict[coin]["model_filename"] = dk.model_filename self.pair_dict[coin]["data_path"] = str(dk.data_path) if coin not in self.meta_data_dictionary: self.meta_data_dictionary[coin] = {} - self.meta_data_dictionary[coin]["train_df"] = dk.data_dictionary["train_features"] - self.meta_data_dictionary[coin]["meta_data"] = dk.data + self.meta_data_dictionary[coin][METADATA] = dk.data + self.meta_data_dictionary[coin][FEATURE_PIPELINE] = dk.feature_pipeline + self.meta_data_dictionary[coin][LABEL_PIPELINE] = dk.label_pipeline self.save_drawer_to_disk() return @@ -491,7 +497,7 @@ class FreqaiDataDrawer: Load only metadata into datakitchen to increase performance during presaved backtesting (prediction file loading). """ - with (dk.data_path / f"{dk.model_filename}_metadata.json").open("r") as fp: + with (dk.data_path / f"{dk.model_filename}_{METADATA}.json").open("r") as fp: dk.data = rapidjson.load(fp, number_mode=rapidjson.NM_NATIVE) dk.training_features_list = dk.data["training_features_list"] dk.label_list = dk.data["label_list"] @@ -511,15 +517,17 @@ class FreqaiDataDrawer: dk.data_path = Path(self.pair_dict[coin]["data_path"]) if coin in self.meta_data_dictionary: - dk.data = self.meta_data_dictionary[coin]["meta_data"] - dk.data_dictionary["train_features"] = self.meta_data_dictionary[coin]["train_df"] + dk.data = self.meta_data_dictionary[coin][METADATA] + dk.feature_pipeline = self.meta_data_dictionary[coin][FEATURE_PIPELINE] + dk.label_pipeline = self.meta_data_dictionary[coin][LABEL_PIPELINE] else: - with (dk.data_path / f"{dk.model_filename}_metadata.json").open("r") as fp: + with (dk.data_path / f"{dk.model_filename}_{METADATA}.json").open("r") as fp: dk.data = rapidjson.load(fp, number_mode=rapidjson.NM_NATIVE) - dk.data_dictionary["train_features"] = pd.read_pickle( - dk.data_path / f"{dk.model_filename}_trained_df.pkl" - ) + with (dk.data_path / f"{dk.model_filename}_{FEATURE_PIPELINE}.pkl").open("rb") as fp: + dk.feature_pipeline = cloudpickle.load(fp) + with (dk.data_path / f"{dk.model_filename}_{LABEL_PIPELINE}.pkl").open("rb") as fp: + dk.label_pipeline = cloudpickle.load(fp) dk.training_features_list = dk.data["training_features_list"] dk.label_list = dk.data["label_list"] @@ -529,9 +537,6 @@ class FreqaiDataDrawer: model = self.model_dictionary[coin] elif self.model_type == 'joblib': model = load(dk.data_path / f"{dk.model_filename}_model.joblib") - elif self.model_type == 'keras': - from tensorflow import keras - model = keras.models.load_model(dk.data_path / f"{dk.model_filename}_model.h5") elif 'stable_baselines' in self.model_type or 'sb3_contrib' == self.model_type: mod = importlib.import_module( self.model_type, self.freqai_info['rl_config']['model_type']) @@ -543,9 +548,6 @@ class FreqaiDataDrawer: model = zip["pytrainer"] model = model.load_from_checkpoint(zip) - if Path(dk.data_path / f"{dk.model_filename}_svm_model.joblib").is_file(): - dk.svm_model = load(dk.data_path / f"{dk.model_filename}_svm_model.joblib") - if not model: raise OperationalException( f"Unable to load model, ensure model exists at " f"{dk.data_path} " @@ -555,11 +557,6 @@ class FreqaiDataDrawer: if coin not in self.model_dictionary: self.model_dictionary[coin] = model - if self.config["freqai"]["feature_parameters"]["principal_component_analysis"]: - dk.pca = cloudpickle.load( - (dk.data_path / f"{dk.model_filename}_pca_object.pkl").open("rb") - ) - return model def update_historic_data(self, strategy: IStrategy, dk: FreqaiDataKitchen) -> None: @@ -639,7 +636,7 @@ class FreqaiDataDrawer: pair=pair, timerange=timerange, data_format=self.config.get("dataformat_ohlcv", "json"), - candle_type=self.config.get("trading_mode", "spot"), + candle_type=self.config.get("candle_type_def", CandleType.SPOT), ) def get_base_and_corr_dataframes( diff --git a/freqtrade/freqai/data_kitchen.py b/freqtrade/freqai/data_kitchen.py index 21b41db2d..7d4bf39ca 100644 --- a/freqtrade/freqai/data_kitchen.py +++ b/freqtrade/freqai/data_kitchen.py @@ -4,7 +4,6 @@ import logging import random import shutil from datetime import datetime, timezone -from math import cos, sin from pathlib import Path from typing import Any, Dict, List, Optional, Tuple @@ -12,16 +11,12 @@ import numpy as np import numpy.typing as npt import pandas as pd import psutil +from datasieve.pipeline import Pipeline from pandas import DataFrame -from scipy import stats -from sklearn import linear_model -from sklearn.cluster import DBSCAN -from sklearn.metrics.pairwise import pairwise_distances from sklearn.model_selection import train_test_split -from sklearn.neighbors import NearestNeighbors from freqtrade.configuration import TimeRange -from freqtrade.constants import Config +from freqtrade.constants import DOCS_LINK, Config from freqtrade.data.converter import reduce_dataframe_footprint from freqtrade.exceptions import OperationalException from freqtrade.exchange import timeframe_to_seconds @@ -81,11 +76,12 @@ class FreqaiDataKitchen: self.backtest_predictions_folder: str = "backtesting_predictions" self.live = live self.pair = pair - - self.svm_model: linear_model.SGDOneClassSVM = None self.keras: bool = self.freqai_config.get("keras", False) self.set_all_pairs() self.backtest_live_models = config.get("freqai_backtest_live_models", False) + self.feature_pipeline = Pipeline() + self.label_pipeline = Pipeline() + self.DI_values: npt.NDArray = np.array([]) if not self.live: self.full_path = self.get_full_models_path(self.config) @@ -227,13 +223,7 @@ class FreqaiDataKitchen: 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) - if const_cols: - filtered_df = filtered_df.filter(filtered_df.columns.difference(const_cols)) - self.data['constant_features_list'] = const_cols - logger.warning(f"Removed features {const_cols} with constant values.") - else: - self.data['constant_features_list'] = [] + # we don't care about total row number (total no. datapoints) in training, we only care # about removing any row with NaNs # if labels has multiple columns (user wants to train multiple modelEs), we detect here @@ -264,8 +254,7 @@ class FreqaiDataKitchen: self.data["filter_drop_index_training"] = drop_index else: - if 'constant_features_list' in self.data and len(self.data['constant_features_list']): - filtered_df = self.check_pred_labels(filtered_df) + # 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(axis=1) @@ -307,107 +296,6 @@ class FreqaiDataKitchen: return self.data_dictionary - def normalize_data(self, data_dictionary: Dict) -> Dict[Any, Any]: - """ - Normalize all data in the data_dictionary according to the training dataset - :param data_dictionary: dictionary containing the cleaned and - split training/test data/labels - :returns: - :data_dictionary: updated dictionary with standardized values. - """ - - # standardize the data by training stats - train_max = data_dictionary["train_features"].max() - train_min = data_dictionary["train_features"].min() - data_dictionary["train_features"] = ( - 2 * (data_dictionary["train_features"] - train_min) / (train_max - train_min) - 1 - ) - data_dictionary["test_features"] = ( - 2 * (data_dictionary["test_features"] - train_min) / (train_max - train_min) - 1 - ) - - for item in train_max.keys(): - self.data[item + "_max"] = train_max[item] - self.data[item + "_min"] = train_min[item] - - for item in data_dictionary["train_labels"].keys(): - if data_dictionary["train_labels"][item].dtype == object: - continue - train_labels_max = data_dictionary["train_labels"][item].max() - train_labels_min = data_dictionary["train_labels"][item].min() - data_dictionary["train_labels"][item] = ( - 2 - * (data_dictionary["train_labels"][item] - train_labels_min) - / (train_labels_max - train_labels_min) - - 1 - ) - if self.freqai_config.get('data_split_parameters', {}).get('test_size', 0.1) != 0: - data_dictionary["test_labels"][item] = ( - 2 - * (data_dictionary["test_labels"][item] - train_labels_min) - / (train_labels_max - train_labels_min) - - 1 - ) - - self.data[f"{item}_max"] = train_labels_max - self.data[f"{item}_min"] = train_labels_min - return data_dictionary - - def normalize_single_dataframe(self, df: DataFrame) -> DataFrame: - - train_max = df.max() - train_min = df.min() - df = ( - 2 * (df - train_min) / (train_max - train_min) - 1 - ) - - for item in train_max.keys(): - self.data[item + "_max"] = train_max[item] - self.data[item + "_min"] = train_min[item] - - return df - - def normalize_data_from_metadata(self, df: DataFrame) -> DataFrame: - """ - Normalize a set of data using the mean and standard deviation from - the associated training data. - :param df: Dataframe to be standardized - """ - - train_max = [None] * len(df.keys()) - train_min = [None] * len(df.keys()) - - for i, item in enumerate(df.keys()): - train_max[i] = self.data[f"{item}_max"] - train_min[i] = self.data[f"{item}_min"] - - train_max_series = pd.Series(train_max, index=df.keys()) - train_min_series = pd.Series(train_min, index=df.keys()) - - df = ( - 2 * (df - train_min_series) / (train_max_series - train_min_series) - 1 - ) - - return df - - def denormalize_labels_from_metadata(self, df: DataFrame) -> DataFrame: - """ - Denormalize a set of data using the mean and standard deviation from - the associated training data. - :param df: Dataframe of predictions to be denormalized - """ - - for label in df.columns: - if df[label].dtype == object or label in self.unique_class_list: - continue - df[label] = ( - (df[label] + 1) - * (self.data[f"{label}_max"] - self.data[f"{label}_min"]) - / 2 - ) + self.data[f"{label}_min"] - - return df - def split_timerange( self, tr: str, train_split: int = 28, bt_split: float = 7 ) -> Tuple[list, list]: @@ -452,9 +340,7 @@ class FreqaiDataKitchen: tr_training_list_timerange.append(copy.deepcopy(timerange_train)) # associated backtest period - timerange_backtest.startts = timerange_train.stopts - timerange_backtest.stopts = timerange_backtest.startts + int(bt_period) if timerange_backtest.stopts > config_timerange.stopts: @@ -485,426 +371,6 @@ class FreqaiDataKitchen: return df - def check_pred_labels(self, df_predictions: DataFrame) -> DataFrame: - """ - Check that prediction feature labels match training feature labels. - :param df_predictions: incoming predictions - """ - constant_labels = self.data['constant_features_list'] - df_predictions = df_predictions.filter( - df_predictions.columns.difference(constant_labels) - ) - logger.warning( - f"Removed {len(constant_labels)} features from prediction features, " - f"these were considered constant values during most recent training." - ) - - return df_predictions - - def principal_component_analysis(self) -> None: - """ - Performs Principal Component Analysis on the data for dimensionality reduction - and outlier detection (see self.remove_outliers()) - No parameters or returns, it acts on the data_dictionary held by the DataHandler. - """ - - from sklearn.decomposition import PCA # avoid importing if we dont need it - - pca = PCA(0.999) - pca = pca.fit(self.data_dictionary["train_features"]) - n_keep_components = pca.n_components_ - self.data["n_kept_components"] = n_keep_components - n_components = self.data_dictionary["train_features"].shape[1] - logger.info("reduced feature dimension by %s", n_components - n_keep_components) - logger.info("explained variance %f", np.sum(pca.explained_variance_ratio_)) - - train_components = pca.transform(self.data_dictionary["train_features"]) - self.data_dictionary["train_features"] = pd.DataFrame( - data=train_components, - columns=["PC" + str(i) for i in range(0, n_keep_components)], - index=self.data_dictionary["train_features"].index, - ) - # normalsing transformed training features - self.data_dictionary["train_features"] = self.normalize_single_dataframe( - self.data_dictionary["train_features"]) - - # keeping a copy of the non-transformed features so we can check for errors during - # model load from disk - self.data["training_features_list_raw"] = copy.deepcopy(self.training_features_list) - self.training_features_list = self.data_dictionary["train_features"].columns - - if self.freqai_config.get('data_split_parameters', {}).get('test_size', 0.1) != 0: - test_components = pca.transform(self.data_dictionary["test_features"]) - self.data_dictionary["test_features"] = pd.DataFrame( - data=test_components, - columns=["PC" + str(i) for i in range(0, n_keep_components)], - index=self.data_dictionary["test_features"].index, - ) - # normalise transformed test feature to transformed training features - self.data_dictionary["test_features"] = self.normalize_data_from_metadata( - self.data_dictionary["test_features"]) - - self.data["n_kept_components"] = n_keep_components - self.pca = pca - - logger.info(f"PCA reduced total features from {n_components} to {n_keep_components}") - - if not self.data_path.is_dir(): - self.data_path.mkdir(parents=True, exist_ok=True) - - return None - - def pca_transform(self, filtered_dataframe: DataFrame) -> None: - """ - Use an existing pca transform to transform data into components - :param filtered_dataframe: DataFrame = the cleaned dataframe - """ - pca_components = self.pca.transform(filtered_dataframe) - self.data_dictionary["prediction_features"] = pd.DataFrame( - data=pca_components, - columns=["PC" + str(i) for i in range(0, self.data["n_kept_components"])], - index=filtered_dataframe.index, - ) - # normalise transformed predictions to transformed training features - self.data_dictionary["prediction_features"] = self.normalize_data_from_metadata( - self.data_dictionary["prediction_features"]) - - def compute_distances(self) -> float: - """ - Compute distances between each training point and every other training - point. This metric defines the neighborhood of trained data and is used - for prediction confidence in the Dissimilarity Index - """ - # logger.info("computing average mean distance for all training points") - pairwise = pairwise_distances( - self.data_dictionary["train_features"], n_jobs=self.thread_count) - # remove the diagonal distances which are itself distances ~0 - np.fill_diagonal(pairwise, np.NaN) - pairwise = pairwise.reshape(-1, 1) - avg_mean_dist = pairwise[~np.isnan(pairwise)].mean() - - return avg_mean_dist - - def get_outlier_percentage(self, dropped_pts: npt.NDArray) -> float: - """ - Check if more than X% of points werer dropped during outlier detection. - """ - outlier_protection_pct = self.freqai_config["feature_parameters"].get( - "outlier_protection_percentage", 30) - outlier_pct = (dropped_pts.sum() / len(dropped_pts)) * 100 - if outlier_pct >= outlier_protection_pct: - return outlier_pct - else: - return 0.0 - - def use_SVM_to_remove_outliers(self, predict: bool) -> None: - """ - Build/inference a Support Vector Machine to detect outliers - in training data and prediction - :param predict: bool = If true, inference an existing SVM model, else construct one - """ - - if self.keras: - logger.warning( - "SVM outlier removal not currently supported for Keras based models. " - "Skipping user requested function." - ) - if predict: - self.do_predict = np.ones(len(self.data_dictionary["prediction_features"])) - return - - if predict: - if not self.svm_model: - logger.warning("No svm model available for outlier removal") - return - y_pred = self.svm_model.predict(self.data_dictionary["prediction_features"]) - do_predict = np.where(y_pred == -1, 0, y_pred) - - if (len(do_predict) - do_predict.sum()) > 0: - logger.info(f"SVM tossed {len(do_predict) - do_predict.sum()} predictions.") - self.do_predict += do_predict - self.do_predict -= 1 - - else: - # use SGDOneClassSVM to increase speed? - svm_params = self.freqai_config["feature_parameters"].get( - "svm_params", {"shuffle": False, "nu": 0.1}) - self.svm_model = linear_model.SGDOneClassSVM(**svm_params).fit( - self.data_dictionary["train_features"] - ) - y_pred = self.svm_model.predict(self.data_dictionary["train_features"]) - kept_points = np.where(y_pred == -1, 0, y_pred) - # keep_index = np.where(y_pred == 1) - outlier_pct = self.get_outlier_percentage(1 - kept_points) - if outlier_pct: - logger.warning( - f"SVM detected {outlier_pct:.2f}% of the points as outliers. " - f"Keeping original dataset." - ) - self.svm_model = None - return - - self.data_dictionary["train_features"] = self.data_dictionary["train_features"][ - (y_pred == 1) - ] - self.data_dictionary["train_labels"] = self.data_dictionary["train_labels"][ - (y_pred == 1) - ] - self.data_dictionary["train_weights"] = self.data_dictionary["train_weights"][ - (y_pred == 1) - ] - - logger.info( - f"SVM tossed {len(y_pred) - kept_points.sum()}" - f" train points from {len(y_pred)} total points." - ) - - # same for test data - # TODO: This (and the part above) could be refactored into a separate function - # to reduce code duplication - if self.freqai_config['data_split_parameters'].get('test_size', 0.1) != 0: - y_pred = self.svm_model.predict(self.data_dictionary["test_features"]) - kept_points = np.where(y_pred == -1, 0, y_pred) - self.data_dictionary["test_features"] = self.data_dictionary["test_features"][ - (y_pred == 1) - ] - self.data_dictionary["test_labels"] = self.data_dictionary["test_labels"][( - y_pred == 1)] - self.data_dictionary["test_weights"] = self.data_dictionary["test_weights"][ - (y_pred == 1) - ] - - logger.info( - f"{self.pair}: SVM tossed {len(y_pred) - kept_points.sum()}" - f" test points from {len(y_pred)} total points." - ) - - return - - def use_DBSCAN_to_remove_outliers(self, predict: bool, eps=None) -> None: - """ - Use DBSCAN to cluster training data and remove "noisy" data (read outliers). - User controls this via the config param `DBSCAN_outlier_pct` which indicates the - pct of training data that they want to be considered outliers. - :param predict: bool = If False (training), iterate to find the best hyper parameters - to match user requested outlier percent target. - If True (prediction), use the parameters determined from - the previous training to estimate if the current prediction point - is an outlier. - """ - - if predict: - if not self.data['DBSCAN_eps']: - return - train_ft_df = self.data_dictionary['train_features'] - pred_ft_df = self.data_dictionary['prediction_features'] - num_preds = len(pred_ft_df) - df = pd.concat([train_ft_df, pred_ft_df], axis=0, ignore_index=True) - clustering = DBSCAN(eps=self.data['DBSCAN_eps'], - min_samples=self.data['DBSCAN_min_samples'], - n_jobs=self.thread_count - ).fit(df) - do_predict = np.where(clustering.labels_[-num_preds:] == -1, 0, 1) - - if (len(do_predict) - do_predict.sum()) > 0: - logger.info(f"DBSCAN tossed {len(do_predict) - do_predict.sum()} predictions") - self.do_predict += do_predict - self.do_predict -= 1 - - else: - - def normalise_distances(distances): - normalised_distances = (distances - distances.min()) / \ - (distances.max() - distances.min()) - return normalised_distances - - def rotate_point(origin, point, angle): - # rotate a point counterclockwise by a given angle (in radians) - # around a given origin - x = origin[0] + cos(angle) * (point[0] - origin[0]) - \ - sin(angle) * (point[1] - origin[1]) - y = origin[1] + sin(angle) * (point[0] - origin[0]) + \ - cos(angle) * (point[1] - origin[1]) - return (x, y) - - MinPts = int(len(self.data_dictionary['train_features'].index) * 0.25) - # measure pairwise distances to nearest neighbours - neighbors = NearestNeighbors( - n_neighbors=MinPts, n_jobs=self.thread_count) - neighbors_fit = neighbors.fit(self.data_dictionary['train_features']) - distances, _ = neighbors_fit.kneighbors(self.data_dictionary['train_features']) - distances = np.sort(distances, axis=0).mean(axis=1) - - normalised_distances = normalise_distances(distances) - x_range = np.linspace(0, 1, len(distances)) - line = np.linspace(normalised_distances[0], - normalised_distances[-1], len(normalised_distances)) - deflection = np.abs(normalised_distances - line) - max_deflection_loc = np.where(deflection == deflection.max())[0][0] - origin = x_range[max_deflection_loc], line[max_deflection_loc] - point = x_range[max_deflection_loc], normalised_distances[max_deflection_loc] - rot_angle = np.pi / 4 - elbow_loc = rotate_point(origin, point, rot_angle) - - epsilon = elbow_loc[1] * (distances[-1] - distances[0]) + distances[0] - - clustering = DBSCAN(eps=epsilon, min_samples=MinPts, - n_jobs=int(self.thread_count)).fit( - self.data_dictionary['train_features'] - ) - - logger.info(f'DBSCAN found eps of {epsilon:.2f}.') - - self.data['DBSCAN_eps'] = epsilon - self.data['DBSCAN_min_samples'] = MinPts - dropped_points = np.where(clustering.labels_ == -1, 1, 0) - - outlier_pct = self.get_outlier_percentage(dropped_points) - if outlier_pct: - logger.warning( - f"DBSCAN detected {outlier_pct:.2f}% of the points as outliers. " - f"Keeping original dataset." - ) - self.data['DBSCAN_eps'] = 0 - return - - self.data_dictionary['train_features'] = self.data_dictionary['train_features'][ - (clustering.labels_ != -1) - ] - self.data_dictionary["train_labels"] = self.data_dictionary["train_labels"][ - (clustering.labels_ != -1) - ] - self.data_dictionary["train_weights"] = self.data_dictionary["train_weights"][ - (clustering.labels_ != -1) - ] - - logger.info( - f"DBSCAN tossed {dropped_points.sum()}" - f" train points from {len(clustering.labels_)}" - ) - - return - - def compute_inlier_metric(self, set_='train') -> None: - """ - Compute inlier metric from backwards distance distributions. - This metric defines how well features from a timepoint fit - into previous timepoints. - """ - - def normalise(dataframe: DataFrame, key: str) -> DataFrame: - if set_ == 'train': - min_value = dataframe.min() - max_value = dataframe.max() - self.data[f'{key}_min'] = min_value - self.data[f'{key}_max'] = max_value - else: - min_value = self.data[f'{key}_min'] - max_value = self.data[f'{key}_max'] - return (dataframe - min_value) / (max_value - min_value) - - no_prev_pts = self.freqai_config["feature_parameters"]["inlier_metric_window"] - - if set_ == 'train': - compute_df = copy.deepcopy(self.data_dictionary['train_features']) - elif set_ == 'test': - compute_df = copy.deepcopy(self.data_dictionary['test_features']) - else: - compute_df = copy.deepcopy(self.data_dictionary['prediction_features']) - - compute_df_reindexed = compute_df.reindex( - index=np.flip(compute_df.index) - ) - - pairwise = pd.DataFrame( - np.triu( - pairwise_distances(compute_df_reindexed, n_jobs=self.thread_count) - ), - columns=compute_df_reindexed.index, - index=compute_df_reindexed.index - ) - pairwise = pairwise.round(5) - - column_labels = [ - '{}{}'.format('d', i) for i in range(1, no_prev_pts + 1) - ] - distances = pd.DataFrame( - columns=column_labels, index=compute_df.index - ) - - for index in compute_df.index[no_prev_pts:]: - current_row = pairwise.loc[[index]] - current_row_no_zeros = current_row.loc[ - :, (current_row != 0).any(axis=0) - ] - distances.loc[[index]] = current_row_no_zeros.iloc[ - :, :no_prev_pts - ] - distances = distances.replace([np.inf, -np.inf], np.nan) - drop_index = pd.isnull(distances).any(axis=1) - distances = distances[drop_index == 0] - - inliers = pd.DataFrame(index=distances.index) - for key in distances.keys(): - current_distances = distances[key].dropna() - current_distances = normalise(current_distances, key) - if set_ == 'train': - fit_params = stats.weibull_min.fit(current_distances) - self.data[f'{key}_fit_params'] = fit_params - else: - fit_params = self.data[f'{key}_fit_params'] - quantiles = stats.weibull_min.cdf(current_distances, *fit_params) - - df_inlier = pd.DataFrame( - {key: quantiles}, index=distances.index - ) - inliers = pd.concat( - [inliers, df_inlier], axis=1 - ) - - inlier_metric = pd.DataFrame( - data=inliers.sum(axis=1) / no_prev_pts, - columns=['%-inlier_metric'], - index=compute_df.index - ) - - inlier_metric = (2 * (inlier_metric - inlier_metric.min()) / - (inlier_metric.max() - inlier_metric.min()) - 1) - - if set_ in ('train', 'test'): - inlier_metric = inlier_metric.iloc[no_prev_pts:] - compute_df = compute_df.iloc[no_prev_pts:] - self.remove_beginning_points_from_data_dict(set_, no_prev_pts) - self.data_dictionary[f'{set_}_features'] = pd.concat( - [compute_df, inlier_metric], axis=1) - else: - self.data_dictionary['prediction_features'] = pd.concat( - [compute_df, inlier_metric], axis=1) - self.data_dictionary['prediction_features'].fillna(0, inplace=True) - - logger.info('Inlier metric computed and added to features.') - - return None - - def remove_beginning_points_from_data_dict(self, set_='train', no_prev_pts: int = 10): - features = self.data_dictionary[f'{set_}_features'] - weights = self.data_dictionary[f'{set_}_weights'] - labels = self.data_dictionary[f'{set_}_labels'] - self.data_dictionary[f'{set_}_weights'] = weights[no_prev_pts:] - self.data_dictionary[f'{set_}_features'] = features.iloc[no_prev_pts:] - self.data_dictionary[f'{set_}_labels'] = labels.iloc[no_prev_pts:] - - def add_noise_to_training_features(self) -> None: - """ - Add noise to train features to reduce the risk of overfitting. - """ - mu = 0 # no shift - sigma = self.freqai_config["feature_parameters"]["noise_standard_deviation"] - compute_df = self.data_dictionary['train_features'] - noise = np.random.normal(mu, sigma, [compute_df.shape[0], compute_df.shape[1]]) - self.data_dictionary['train_features'] += noise - return - def find_features(self, dataframe: DataFrame) -> None: """ Find features in the strategy provided dataframe @@ -925,37 +391,6 @@ class FreqaiDataKitchen: labels = [c for c in column_names if "&" in c] self.label_list = labels - def check_if_pred_in_training_spaces(self) -> None: - """ - Compares the distance from each prediction point to each training data - point. It uses this information to estimate a Dissimilarity Index (DI) - and avoid making predictions on any points that are too far away - from the training data set. - """ - - distance = pairwise_distances( - self.data_dictionary["train_features"], - self.data_dictionary["prediction_features"], - n_jobs=self.thread_count, - ) - - self.DI_values = distance.min(axis=0) / self.data["avg_mean_dist"] - - do_predict = np.where( - self.DI_values < self.freqai_config["feature_parameters"]["DI_threshold"], - 1, - 0, - ) - - if (len(do_predict) - do_predict.sum()) > 0: - logger.info( - f"{self.pair}: DI tossed {len(do_predict) - do_predict.sum()} predictions for " - "being too far from training data." - ) - - self.do_predict += do_predict - self.do_predict -= 1 - def set_weights_higher_recent(self, num_weights: int) -> npt.ArrayLike: """ Set weights so that recent data is more heavily weighted during @@ -1325,9 +760,9 @@ class FreqaiDataKitchen: " which was deprecated on March 1, 2023. Please refer " "to the strategy migration guide to use the new " "feature_engineering_* methods: \n" - "https://www.freqtrade.io/en/stable/strategy_migration/#freqai-strategy \n" + f"{DOCS_LINK}/strategy_migration/#freqai-strategy \n" "And the feature_engineering_* documentation: \n" - "https://www.freqtrade.io/en/latest/freqai-feature-engineering/" + f"{DOCS_LINK}/freqai-feature-engineering/" ) tfs: List[str] = self.freqai_config["feature_parameters"].get("include_timeframes") @@ -1515,3 +950,32 @@ class FreqaiDataKitchen: timerange.startts += buffer * timeframe_to_seconds(self.config["timeframe"]) return timerange + + # deprecated functions + def normalize_data(self, data_dictionary: Dict) -> Dict[Any, Any]: + """ + Deprecation warning, migration assistance + """ + logger.warning(f"Your custom IFreqaiModel relies on the deprecated" + " data pipeline. Please update your model to use the new data pipeline." + " This can be achieved by following the migration guide at " + f"{DOCS_LINK}/strategy_migration/#freqai-new-data-pipeline " + "We added a basic pipeline for you, but this will be removed " + "in a future version.") + + return data_dictionary + + def denormalize_labels_from_metadata(self, df: DataFrame) -> DataFrame: + """ + Deprecation warning, migration assistance + """ + logger.warning(f"Your custom IFreqaiModel relies on the deprecated" + " data pipeline. Please update your model to use the new data pipeline." + " This can be achieved by following the migration guide at " + f"{DOCS_LINK}/strategy_migration/#freqai-new-data-pipeline " + "We added a basic pipeline for you, but this will be removed " + "in a future version.") + + pred_df, _, _ = self.label_pipeline.inverse_transform(df) + + return pred_df diff --git a/freqtrade/freqai/freqai_interface.py b/freqtrade/freqai/freqai_interface.py index 7eaaeab3e..36c94130c 100644 --- a/freqtrade/freqai/freqai_interface.py +++ b/freqtrade/freqai/freqai_interface.py @@ -7,21 +7,25 @@ from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Literal, Optional, Tuple +import datasieve.transforms as ds import numpy as np import pandas as pd import psutil +from datasieve.pipeline import Pipeline +from datasieve.transforms import SKLearnWrapper from numpy.typing import NDArray from pandas import DataFrame +from sklearn.preprocessing import MinMaxScaler from freqtrade.configuration import TimeRange -from freqtrade.constants import Config +from freqtrade.constants import DOCS_LINK, Config from freqtrade.data.dataprovider import DataProvider from freqtrade.enums import RunMode from freqtrade.exceptions import OperationalException from freqtrade.exchange import timeframe_to_seconds from freqtrade.freqai.data_drawer import FreqaiDataDrawer from freqtrade.freqai.data_kitchen import FreqaiDataKitchen -from freqtrade.freqai.utils import plot_feature_importance, record_params +from freqtrade.freqai.utils import get_tb_logger, plot_feature_importance, record_params from freqtrade.strategy.interface import IStrategy @@ -80,6 +84,7 @@ class IFreqaiModel(ABC): if self.keras and self.ft_params.get("DI_threshold", 0): self.ft_params["DI_threshold"] = 0 logger.warning("DI threshold is not configured for Keras models yet. Deactivating.") + self.CONV_WIDTH = self.freqai_info.get('conv_width', 1) if self.ft_params.get("inlier_metric_window", 0): self.CONV_WIDTH = self.ft_params.get("inlier_metric_window", 0) * 2 @@ -109,6 +114,7 @@ class IFreqaiModel(ABC): if self.ft_params.get('principal_component_analysis', False) and self.continual_learning: self.ft_params.update({'principal_component_analysis': False}) logger.warning('User tried to use PCA with continual learning. Deactivating PCA.') + self.activate_tensorboard: bool = self.freqai_info.get('activate_tensorboard', True) record_params(config, self.full_path) @@ -242,8 +248,8 @@ class IFreqaiModel(ABC): new_trained_timerange, pair, strategy, dk, data_load_timerange ) except Exception as msg: - logger.warning(f"Training {pair} raised exception {msg.__class__.__name__}. " - f"Message: {msg}, skipping.") + logger.exception(f"Training {pair} raised exception {msg.__class__.__name__}. " + f"Message: {msg}, skipping.") self.train_timer('stop', pair) @@ -306,10 +312,11 @@ class IFreqaiModel(ABC): if dk.check_if_backtest_prediction_is_valid(len_backtest_df): if check_features: self.dd.load_metadata(dk) - dataframe_dummy_features = self.dk.use_strategy_to_populate_indicators( + df_fts = self.dk.use_strategy_to_populate_indicators( strategy, prediction_dataframe=dataframe.tail(1), pair=pair ) - dk.find_features(dataframe_dummy_features) + df_fts = dk.remove_special_chars_from_feature_names(df_fts) + dk.find_features(df_fts) self.check_if_feature_list_matches_strategy(dk) check_features = False append_df = dk.get_backtesting_prediction() @@ -342,7 +349,10 @@ class IFreqaiModel(ABC): dk.find_labels(dataframe_train) try: + self.tb_logger = get_tb_logger(self.dd.model_type, dk.data_path, + self.activate_tensorboard) self.model = self.train(dataframe_train, pair, dk) + self.tb_logger.close() except Exception as msg: logger.warning( f"Training {pair} raised exception {msg.__class__.__name__}. " @@ -489,76 +499,51 @@ class IFreqaiModel(ABC): 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" + "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. In case of --strategy-list, please be aware that FreqAI " "requires all strategies to maintain identical " "feature_engineering_* functions" ) - def data_cleaning_train(self, dk: FreqaiDataKitchen) -> None: - """ - Base data cleaning method for train. - Functions here improve/modify the input data by identifying outliers, - computing additional metrics, adding noise, reducing dimensionality etc. - """ - + def define_data_pipeline(self, threads=-1) -> Pipeline: ft_params = self.freqai_info["feature_parameters"] + pipe_steps = [ + ('const', ds.VarianceThreshold(threshold=0)), + ('scaler', SKLearnWrapper(MinMaxScaler(feature_range=(-1, 1)))) + ] - if ft_params.get('inlier_metric_window', 0): - dk.compute_inlier_metric(set_='train') - if self.freqai_info["data_split_parameters"]["test_size"] > 0: - dk.compute_inlier_metric(set_='test') - - if ft_params.get( - "principal_component_analysis", False - ): - dk.principal_component_analysis() + if ft_params.get("principal_component_analysis", False): + pipe_steps.append(('pca', ds.PCA(n_components=0.999))) + pipe_steps.append(('post-pca-scaler', + SKLearnWrapper(MinMaxScaler(feature_range=(-1, 1))))) if ft_params.get("use_SVM_to_remove_outliers", False): - dk.use_SVM_to_remove_outliers(predict=False) + svm_params = ft_params.get( + "svm_params", {"shuffle": False, "nu": 0.01}) + pipe_steps.append(('svm', ds.SVMOutlierExtractor(**svm_params))) - if ft_params.get("DI_threshold", 0): - dk.data["avg_mean_dist"] = dk.compute_distances() + di = ft_params.get("DI_threshold", 0) + if di: + pipe_steps.append(('di', ds.DissimilarityIndex(di_threshold=di, n_jobs=threads))) if ft_params.get("use_DBSCAN_to_remove_outliers", False): - if dk.pair in self.dd.old_DBSCAN_eps: - eps = self.dd.old_DBSCAN_eps[dk.pair] - else: - eps = None - dk.use_DBSCAN_to_remove_outliers(predict=False, eps=eps) - self.dd.old_DBSCAN_eps[dk.pair] = dk.data['DBSCAN_eps'] + pipe_steps.append(('dbscan', ds.DBSCAN(n_jobs=threads))) - if self.freqai_info["feature_parameters"].get('noise_standard_deviation', 0): - dk.add_noise_to_training_features() + sigma = self.freqai_info["feature_parameters"].get('noise_standard_deviation', 0) + if sigma: + pipe_steps.append(('noise', ds.Noise(sigma=sigma))) - 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"] + return Pipeline(pipe_steps) - # ensure user is feeding the correct indicators to the model - self.check_if_feature_list_matches_strategy(dk) + def define_label_pipeline(self, threads=-1) -> Pipeline: - if ft_params.get('inlier_metric_window', 0): - dk.compute_inlier_metric(set_='predict') + label_pipeline = Pipeline([ + ('scaler', SKLearnWrapper(MinMaxScaler(feature_range=(-1, 1)))) + ]) - if ft_params.get( - "principal_component_analysis", False - ): - 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) - - if ft_params.get("DI_threshold", 0): - dk.check_if_pred_in_training_spaces() - - if ft_params.get("use_DBSCAN_to_remove_outliers", False): - dk.use_DBSCAN_to_remove_outliers(predict=True) + return label_pipeline def model_exists(self, dk: FreqaiDataKitchen) -> bool: """ @@ -570,8 +555,6 @@ class IFreqaiModel(ABC): """ if self.dd.model_type == 'joblib': file_type = ".joblib" - elif self.dd.model_type == 'keras': - file_type = ".h5" elif self.dd.model_type in ["stable_baselines3", "sb3_contrib", "pytorch"]: file_type = ".zip" @@ -620,18 +603,23 @@ class IFreqaiModel(ABC): strategy, corr_dataframes, base_dataframes, pair ) - new_trained_timerange = dk.buffer_timerange(new_trained_timerange) + trained_timestamp = new_trained_timerange.stopts - unfiltered_dataframe = dk.slice_dataframe(new_trained_timerange, unfiltered_dataframe) + buffered_timerange = dk.buffer_timerange(new_trained_timerange) + + unfiltered_dataframe = dk.slice_dataframe(buffered_timerange, unfiltered_dataframe) # find the features indicated by strategy and store in datakitchen dk.find_features(unfiltered_dataframe) dk.find_labels(unfiltered_dataframe) + self.tb_logger = get_tb_logger(self.dd.model_type, dk.data_path, + self.activate_tensorboard) model = self.train(unfiltered_dataframe, pair, dk) + self.tb_logger.close() - self.dd.pair_dict[pair]["trained_timestamp"] = new_trained_timerange.stopts - dk.set_new_model_names(pair, new_trained_timerange.stopts) + self.dd.pair_dict[pair]["trained_timestamp"] = trained_timestamp + dk.set_new_model_names(pair, trained_timestamp) self.dd.save_data(model, pair, dk) if self.plot_features: @@ -690,7 +678,7 @@ class IFreqaiModel(ABC): # # for keras type models, the conv_window needs to be prepended so # # viewing is correct in frequi - if self.freqai_info.get('keras', False) or self.ft_params.get('inlier_metric_window', 0): + if self.ft_params.get('inlier_metric_window', 0): n_lost_points = self.freqai_info.get('conv_width', 2) zeros_df = DataFrame(np.zeros((n_lost_points, len(hist_preds_df.columns))), columns=hist_preds_df.columns) @@ -980,3 +968,50 @@ class IFreqaiModel(ABC): :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove data (NaNs) or felt uncertain about data (i.e. SVM and/or DI index) """ + + # deprecated functions + def data_cleaning_train(self, dk: FreqaiDataKitchen, pair: str): + """ + throw deprecation warning if this function is called + """ + logger.warning(f"Your model {self.__class__.__name__} relies on the deprecated" + " data pipeline. Please update your model to use the new data pipeline." + " This can be achieved by following the migration guide at " + f"{DOCS_LINK}/strategy_migration/#freqai-new-data-pipeline") + dk.feature_pipeline = self.define_data_pipeline(threads=dk.thread_count) + dd = dk.data_dictionary + (dd["train_features"], + dd["train_labels"], + dd["train_weights"]) = dk.feature_pipeline.fit_transform(dd["train_features"], + dd["train_labels"], + dd["train_weights"]) + + (dd["test_features"], + dd["test_labels"], + dd["test_weights"]) = dk.feature_pipeline.transform(dd["test_features"], + dd["test_labels"], + dd["test_weights"]) + + dk.label_pipeline = self.define_label_pipeline(threads=dk.thread_count) + + dd["train_labels"], _, _ = dk.label_pipeline.fit_transform(dd["train_labels"]) + dd["test_labels"], _, _ = dk.label_pipeline.transform(dd["test_labels"]) + return + + def data_cleaning_predict(self, dk: FreqaiDataKitchen, pair: str): + """ + throw deprecation warning if this function is called + """ + logger.warning(f"Your model {self.__class__.__name__} relies on the deprecated" + " data pipeline. Please update your model to use the new data pipeline." + " This can be achieved by following the migration guide at " + f"{DOCS_LINK}/strategy_migration/#freqai-new-data-pipeline") + dd = dk.data_dictionary + dd["predict_features"], outliers, _ = dk.feature_pipeline.transform( + dd["predict_features"], outlier_check=True) + if self.freqai_info.get("DI_threshold", 0) > 0: + dk.DI_values = dk.feature_pipeline["di"].di_values + else: + dk.DI_values = np.zeros(outliers.shape[0]) + dk.do_predict = outliers + return diff --git a/freqtrade/freqai/prediction_models/PyTorchMLPClassifier.py b/freqtrade/freqai/prediction_models/PyTorchMLPClassifier.py index ea7981405..71279dba9 100644 --- a/freqtrade/freqai/prediction_models/PyTorchMLPClassifier.py +++ b/freqtrade/freqai/prediction_models/PyTorchMLPClassifier.py @@ -74,16 +74,18 @@ class PyTorchMLPClassifier(BasePyTorchClassifier): model.to(self.device) optimizer = torch.optim.AdamW(model.parameters(), lr=self.learning_rate) criterion = torch.nn.CrossEntropyLoss() - init_model = self.get_init_model(dk.pair) - trainer = PyTorchModelTrainer( - model=model, - optimizer=optimizer, - criterion=criterion, - model_meta_data={"class_names": class_names}, - device=self.device, - init_model=init_model, - data_convertor=self.data_convertor, - **self.trainer_kwargs, - ) + # check if continual_learning is activated, and retreive the model to continue training + trainer = self.get_init_model(dk.pair) + if trainer is None: + trainer = PyTorchModelTrainer( + model=model, + optimizer=optimizer, + criterion=criterion, + model_meta_data={"class_names": class_names}, + device=self.device, + data_convertor=self.data_convertor, + tb_logger=self.tb_logger, + **self.trainer_kwargs, + ) trainer.fit(data_dictionary, self.splits) return trainer diff --git a/freqtrade/freqai/prediction_models/PyTorchMLPRegressor.py b/freqtrade/freqai/prediction_models/PyTorchMLPRegressor.py index 64f0f4b03..9f4534487 100644 --- a/freqtrade/freqai/prediction_models/PyTorchMLPRegressor.py +++ b/freqtrade/freqai/prediction_models/PyTorchMLPRegressor.py @@ -69,15 +69,17 @@ class PyTorchMLPRegressor(BasePyTorchRegressor): model.to(self.device) optimizer = torch.optim.AdamW(model.parameters(), lr=self.learning_rate) criterion = torch.nn.MSELoss() - init_model = self.get_init_model(dk.pair) - trainer = PyTorchModelTrainer( - model=model, - optimizer=optimizer, - criterion=criterion, - device=self.device, - init_model=init_model, - data_convertor=self.data_convertor, - **self.trainer_kwargs, - ) + # check if continual_learning is activated, and retreive the model to continue training + trainer = self.get_init_model(dk.pair) + if trainer is None: + trainer = PyTorchModelTrainer( + model=model, + optimizer=optimizer, + criterion=criterion, + device=self.device, + data_convertor=self.data_convertor, + tb_logger=self.tb_logger, + **self.trainer_kwargs, + ) trainer.fit(data_dictionary, self.splits) return trainer diff --git a/freqtrade/freqai/prediction_models/PyTorchTransformerRegressor.py b/freqtrade/freqai/prediction_models/PyTorchTransformerRegressor.py new file mode 100644 index 000000000..a76bab05c --- /dev/null +++ b/freqtrade/freqai/prediction_models/PyTorchTransformerRegressor.py @@ -0,0 +1,146 @@ +from typing import Any, Dict, Tuple + +import numpy as np +import numpy.typing as npt +import pandas as pd +import torch + +from freqtrade.freqai.base_models.BasePyTorchRegressor import BasePyTorchRegressor +from freqtrade.freqai.data_kitchen import FreqaiDataKitchen +from freqtrade.freqai.torch.PyTorchDataConvertor import (DefaultPyTorchDataConvertor, + PyTorchDataConvertor) +from freqtrade.freqai.torch.PyTorchModelTrainer import PyTorchTransformerTrainer +from freqtrade.freqai.torch.PyTorchTransformerModel import PyTorchTransformerModel + + +class PyTorchTransformerRegressor(BasePyTorchRegressor): + """ + This class implements the fit method of IFreqaiModel. + in the fit method we initialize the model and trainer objects. + the only requirement from the model is to be aligned to PyTorchRegressor + predict method that expects the model to predict tensor of type float. + the trainer defines the training loop. + + parameters are passed via `model_training_parameters` under the freqai + section in the config file. e.g: + { + ... + "freqai": { + ... + "model_training_parameters" : { + "learning_rate": 3e-4, + "trainer_kwargs": { + "max_iters": 5000, + "batch_size": 64, + "max_n_eval_batches": null + }, + "model_kwargs": { + "hidden_dim": 512, + "dropout_percent": 0.2, + "n_layer": 1, + }, + } + } + } + """ + + @property + def data_convertor(self) -> PyTorchDataConvertor: + return DefaultPyTorchDataConvertor(target_tensor_type=torch.float) + + def __init__(self, **kwargs) -> None: + super().__init__(**kwargs) + config = self.freqai_info.get("model_training_parameters", {}) + self.learning_rate: float = config.get("learning_rate", 3e-4) + self.model_kwargs: Dict[str, Any] = config.get("model_kwargs", {}) + self.trainer_kwargs: Dict[str, Any] = config.get("trainer_kwargs", {}) + + def fit(self, data_dictionary: Dict, dk: FreqaiDataKitchen, **kwargs) -> Any: + """ + User sets up the training and test data to fit their desired model here + :param data_dictionary: the dictionary holding all data for train, test, + labels, weights + :param dk: The datakitchen object for the current coin/model + """ + + n_features = data_dictionary["train_features"].shape[-1] + n_labels = data_dictionary["train_labels"].shape[-1] + model = PyTorchTransformerModel( + input_dim=n_features, + output_dim=n_labels, + time_window=self.window_size, + **self.model_kwargs + ) + model.to(self.device) + optimizer = torch.optim.AdamW(model.parameters(), lr=self.learning_rate) + criterion = torch.nn.MSELoss() + # check if continual_learning is activated, and retreive the model to continue training + trainer = self.get_init_model(dk.pair) + if trainer is None: + trainer = PyTorchTransformerTrainer( + model=model, + optimizer=optimizer, + criterion=criterion, + device=self.device, + data_convertor=self.data_convertor, + window_size=self.window_size, + tb_logger=self.tb_logger, + **self.trainer_kwargs, + ) + trainer.fit(data_dictionary, self.splits) + return trainer + + def predict( + self, unfiltered_df: pd.DataFrame, dk: FreqaiDataKitchen, **kwargs + ) -> Tuple[pd.DataFrame, npt.NDArray[np.int_]]: + """ + Filter the prediction features data and predict with it. + :param unfiltered_df: Full dataframe for the current backtest period. + :return: + :pred_df: dataframe containing the predictions + :do_predict: np.array of 1s and 0s to indicate places where freqai needed to remove + data (NaNs) or felt uncertain about data (PCA and DI index) + """ + + dk.find_features(unfiltered_df) + dk.data_dictionary["prediction_features"], _ = dk.filter_features( + unfiltered_df, dk.training_features_list, training_filter=False + ) + + dk.data_dictionary["prediction_features"], outliers, _ = dk.feature_pipeline.transform( + dk.data_dictionary["prediction_features"], outlier_check=True) + + x = self.data_convertor.convert_x( + dk.data_dictionary["prediction_features"], + device=self.device + ) + # if user is asking for multiple predictions, slide the window + # along the tensor + x = x.unsqueeze(0) + # create empty torch tensor + self.model.model.eval() + yb = torch.empty(0).to(self.device) + if x.shape[1] > 1: + ws = self.window_size + for i in range(0, x.shape[1] - ws): + xb = x[:, i:i + ws, :].to(self.device) + y = self.model.model(xb) + yb = torch.cat((yb, y), dim=0) + else: + yb = self.model.model(x) + + yb = yb.cpu().squeeze() + pred_df = pd.DataFrame(yb.detach().numpy(), columns=dk.label_list) + pred_df, _, _ = dk.label_pipeline.inverse_transform(pred_df) + + if self.freqai_info.get("DI_threshold", 0) > 0: + dk.DI_values = dk.feature_pipeline["di"].di_values + else: + dk.DI_values = np.zeros(outliers.shape[0]) + dk.do_predict = outliers + + if x.shape[1] > 1: + zeros_df = pd.DataFrame(np.zeros((x.shape[1] - len(pred_df), len(pred_df.columns))), + columns=pred_df.columns) + pred_df = pd.concat([zeros_df, pred_df], axis=0, ignore_index=True) + return (pred_df, dk.do_predict) diff --git a/freqtrade/freqai/prediction_models/ReinforcementLearner.py b/freqtrade/freqai/prediction_models/ReinforcementLearner.py index 65990da87..a11decc92 100644 --- a/freqtrade/freqai/prediction_models/ReinforcementLearner.py +++ b/freqtrade/freqai/prediction_models/ReinforcementLearner.py @@ -1,11 +1,12 @@ import logging from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, Type import torch as th from freqtrade.freqai.data_kitchen import FreqaiDataKitchen from freqtrade.freqai.RL.Base5ActionRLEnv import Actions, Base5ActionRLEnv, Positions +from freqtrade.freqai.RL.BaseEnvironment import BaseEnvironment from freqtrade.freqai.RL.BaseReinforcementLearningModel import BaseReinforcementLearningModel @@ -57,10 +58,14 @@ class ReinforcementLearner(BaseReinforcementLearningModel): policy_kwargs = dict(activation_fn=th.nn.ReLU, net_arch=self.net_arch) + if self.activate_tensorboard: + tb_path = Path(dk.full_path / "tensorboard" / dk.pair.split('/')[0]) + else: + tb_path = None + if dk.pair not in self.dd.model_dictionary or not self.continual_learning: model = self.MODELCLASS(self.policy_type, self.train_env, policy_kwargs=policy_kwargs, - tensorboard_log=Path( - dk.full_path / "tensorboard" / dk.pair.split('/')[0]), + tensorboard_log=tb_path, **self.freqai_info.get('model_training_parameters', {}) ) else: @@ -84,7 +89,9 @@ class ReinforcementLearner(BaseReinforcementLearningModel): return model - class MyRLEnv(Base5ActionRLEnv): + MyRLEnv: Type[BaseEnvironment] + + class MyRLEnv(Base5ActionRLEnv): # type: ignore[no-redef] """ User can override any function in BaseRLEnv and gym.Env. Here the user sets a custom reward based on profit and trade duration. @@ -94,6 +101,12 @@ class ReinforcementLearner(BaseReinforcementLearningModel): """ An example reward function. This is the one function that users will likely wish to inject their own creativity into. + + Warning! + This is function is a showcase of functionality designed to show as many possible + environment control features as possible. It is also designed to run quickly + on small computers. This is a benchmark, it is *not* for live production. + :param action: int = The action made by the agent for the current candle. :return: float = the reward to give to the agent for current step (used for optimization diff --git a/freqtrade/freqai/prediction_models/ReinforcementLearner_multiproc.py b/freqtrade/freqai/prediction_models/ReinforcementLearner_multiproc.py index b3b8c40e6..f014da602 100644 --- a/freqtrade/freqai/prediction_models/ReinforcementLearner_multiproc.py +++ b/freqtrade/freqai/prediction_models/ReinforcementLearner_multiproc.py @@ -2,13 +2,14 @@ import logging from typing import Any, Dict from pandas import DataFrame -from stable_baselines3.common.callbacks import EvalCallback -from stable_baselines3.common.vec_env import SubprocVecEnv +from sb3_contrib.common.maskable.callbacks import MaskableEvalCallback +from sb3_contrib.common.maskable.utils import is_masking_supported +from stable_baselines3.common.vec_env import SubprocVecEnv, VecMonitor from freqtrade.freqai.data_kitchen import FreqaiDataKitchen from freqtrade.freqai.prediction_models.ReinforcementLearner import ReinforcementLearner from freqtrade.freqai.RL.BaseReinforcementLearningModel import make_env -from freqtrade.freqai.RL.TensorboardCallback import TensorboardCallback +from freqtrade.freqai.tensorboard.TensorboardCallback import TensorboardCallback logger = logging.getLogger(__name__) @@ -41,22 +42,27 @@ class ReinforcementLearner_multiproc(ReinforcementLearner): env_info = self.pack_env_dict(dk.pair) + eval_freq = len(train_df) // self.max_threads + env_id = "train_env" - self.train_env = SubprocVecEnv([make_env(self.MyRLEnv, env_id, i, 1, - train_df, prices_train, - monitor=True, - env_info=env_info) for i - in range(self.max_threads)]) + self.train_env = VecMonitor(SubprocVecEnv([make_env(self.MyRLEnv, env_id, i, 1, + train_df, prices_train, + env_info=env_info) for i + in range(self.max_threads)])) eval_env_id = 'eval_env' - self.eval_env = SubprocVecEnv([make_env(self.MyRLEnv, eval_env_id, i, 1, - test_df, prices_test, - monitor=True, - env_info=env_info) for i - in range(self.max_threads)]) - self.eval_callback = EvalCallback(self.eval_env, deterministic=True, - render=False, eval_freq=len(train_df), - best_model_save_path=str(dk.data_path)) + self.eval_env = VecMonitor(SubprocVecEnv([make_env(self.MyRLEnv, eval_env_id, i, 1, + test_df, prices_test, + env_info=env_info) for i + in range(self.max_threads)])) + self.eval_callback = MaskableEvalCallback(self.eval_env, deterministic=True, + render=False, eval_freq=eval_freq, + best_model_save_path=str(dk.data_path), + use_masking=(self.model_type == 'MaskablePPO' and + is_masking_supported(self.eval_env))) + + # TENSORBOARD CALLBACK DOES NOT RECOMMENDED TO USE WITH MULTIPLE ENVS, + # IT WILL RETURN FALSE INFORMATIONS, NEVERTHLESS NOT THREAD SAFE WITH SB3!!! actions = self.train_env.env_method("get_actions")[0] self.tensorboard_callback = TensorboardCallback(verbose=1, actions=actions) diff --git a/freqtrade/freqai/prediction_models/XGBoostRFRegressor.py b/freqtrade/freqai/prediction_models/XGBoostRFRegressor.py index 1aefbf19a..f43585ab0 100644 --- a/freqtrade/freqai/prediction_models/XGBoostRFRegressor.py +++ b/freqtrade/freqai/prediction_models/XGBoostRFRegressor.py @@ -5,6 +5,7 @@ from xgboost import XGBRFRegressor from freqtrade.freqai.base_models.BaseRegressionModel import BaseRegressionModel from freqtrade.freqai.data_kitchen import FreqaiDataKitchen +from freqtrade.freqai.tensorboard import TBCallback logger = logging.getLogger(__name__) @@ -44,7 +45,10 @@ class XGBoostRFRegressor(BaseRegressionModel): model = XGBRFRegressor(**self.model_training_parameters) + model.set_params(callbacks=[TBCallback(dk.data_path)], activate=self.activate_tensorboard) model.fit(X=X, y=y, sample_weight=sample_weight, eval_set=eval_set, sample_weight_eval_set=eval_weights, xgb_model=xgb_model) + # set the callbacks to empty so that we can serialize to disk later + model.set_params(callbacks=[]) return model diff --git a/freqtrade/freqai/prediction_models/XGBoostRegressor.py b/freqtrade/freqai/prediction_models/XGBoostRegressor.py index 93dfb319e..f8b4d353d 100644 --- a/freqtrade/freqai/prediction_models/XGBoostRegressor.py +++ b/freqtrade/freqai/prediction_models/XGBoostRegressor.py @@ -5,6 +5,7 @@ from xgboost import XGBRegressor from freqtrade.freqai.base_models.BaseRegressionModel import BaseRegressionModel from freqtrade.freqai.data_kitchen import FreqaiDataKitchen +from freqtrade.freqai.tensorboard import TBCallback logger = logging.getLogger(__name__) @@ -44,7 +45,10 @@ class XGBoostRegressor(BaseRegressionModel): model = XGBRegressor(**self.model_training_parameters) + model.set_params(callbacks=[TBCallback(dk.data_path)], activate=self.activate_tensorboard) model.fit(X=X, y=y, sample_weight=sample_weight, eval_set=eval_set, sample_weight_eval_set=eval_weights, xgb_model=xgb_model) + # set the callbacks to empty so that we can serialize to disk later + model.set_params(callbacks=[]) return model diff --git a/freqtrade/freqai/RL/TensorboardCallback.py b/freqtrade/freqai/tensorboard/TensorboardCallback.py similarity index 85% rename from freqtrade/freqai/RL/TensorboardCallback.py rename to freqtrade/freqai/tensorboard/TensorboardCallback.py index 7f8c76956..61652c9c6 100644 --- a/freqtrade/freqai/RL/TensorboardCallback.py +++ b/freqtrade/freqai/tensorboard/TensorboardCallback.py @@ -3,8 +3,9 @@ from typing import Any, Dict, Type, Union from stable_baselines3.common.callbacks import BaseCallback from stable_baselines3.common.logger import HParam +from stable_baselines3.common.vec_env import VecEnv -from freqtrade.freqai.RL.BaseEnvironment import BaseActions, BaseEnvironment +from freqtrade.freqai.RL.BaseEnvironment import BaseActions class TensorboardCallback(BaseCallback): @@ -12,11 +13,13 @@ class TensorboardCallback(BaseCallback): Custom callback for plotting additional values in tensorboard and episodic summary reports. """ + # Override training_env type to fix type errors + training_env: Union[VecEnv, None] = None + def __init__(self, verbose=1, actions: Type[Enum] = BaseActions): super().__init__(verbose) self.model: Any = None - self.logger = None # type: Any - self.training_env: BaseEnvironment = None # type: ignore + self.logger: Any = None self.actions: Type[Enum] = actions def _on_training_start(self) -> None: @@ -44,6 +47,8 @@ class TensorboardCallback(BaseCallback): def _on_step(self) -> bool: local_info = self.locals["infos"][0] + if self.training_env is None: + return True tensorboard_metrics = self.training_env.get_attr("tensorboard_metrics")[0] for metric in local_info: diff --git a/freqtrade/freqai/tensorboard/__init__.py b/freqtrade/freqai/tensorboard/__init__.py new file mode 100644 index 000000000..59862bc0d --- /dev/null +++ b/freqtrade/freqai/tensorboard/__init__.py @@ -0,0 +1,15 @@ +# ensure users can still use a non-torch freqai version +try: + from freqtrade.freqai.tensorboard.tensorboard import TensorBoardCallback, TensorboardLogger + TBLogger = TensorboardLogger + TBCallback = TensorBoardCallback +except ModuleNotFoundError: + from freqtrade.freqai.tensorboard.base_tensorboard import (BaseTensorBoardCallback, + BaseTensorboardLogger) + TBLogger = BaseTensorboardLogger # type: ignore + TBCallback = BaseTensorBoardCallback # type: ignore + +__all__ = ( + "TBLogger", + "TBCallback" +) diff --git a/freqtrade/freqai/tensorboard/base_tensorboard.py b/freqtrade/freqai/tensorboard/base_tensorboard.py new file mode 100644 index 000000000..72f47111c --- /dev/null +++ b/freqtrade/freqai/tensorboard/base_tensorboard.py @@ -0,0 +1,33 @@ +import logging +from pathlib import Path +from typing import Any + +from xgboost.callback import TrainingCallback + + +logger = logging.getLogger(__name__) + + +class BaseTensorboardLogger: + def __init__(self, logdir: Path, activate: bool = True): + pass + + def log_scalar(self, tag: str, scalar_value: Any, step: int): + return + + def close(self): + return + + +class BaseTensorBoardCallback(TrainingCallback): + + def __init__(self, logdir: Path, activate: bool = True): + pass + + def after_iteration( + self, model, epoch: int, evals_log: TrainingCallback.EvalsLog + ) -> bool: + return False + + def after_training(self, model): + return model diff --git a/freqtrade/freqai/tensorboard/tensorboard.py b/freqtrade/freqai/tensorboard/tensorboard.py new file mode 100644 index 000000000..46bf8dc61 --- /dev/null +++ b/freqtrade/freqai/tensorboard/tensorboard.py @@ -0,0 +1,62 @@ +import logging +from pathlib import Path +from typing import Any + +from torch.utils.tensorboard import SummaryWriter +from xgboost import callback + +from freqtrade.freqai.tensorboard.base_tensorboard import (BaseTensorBoardCallback, + BaseTensorboardLogger) + + +logger = logging.getLogger(__name__) + + +class TensorboardLogger(BaseTensorboardLogger): + def __init__(self, logdir: Path, activate: bool = True): + self.activate = activate + if self.activate: + self.writer: SummaryWriter = SummaryWriter(f"{str(logdir)}/tensorboard") + + def log_scalar(self, tag: str, scalar_value: Any, step: int): + if self.activate: + self.writer.add_scalar(tag, scalar_value, step) + + def close(self): + if self.activate: + self.writer.flush() + self.writer.close() + + +class TensorBoardCallback(BaseTensorBoardCallback): + + def __init__(self, logdir: Path, activate: bool = True): + self.activate = activate + if self.activate: + self.writer: SummaryWriter = SummaryWriter(f"{str(logdir)}/tensorboard") + + def after_iteration( + self, model, epoch: int, evals_log: callback.TrainingCallback.EvalsLog + ) -> bool: + if not self.activate: + return False + if not evals_log: + return False + + for data, metric in evals_log.items(): + for metric_name, log in metric.items(): + score = log[-1][0] if isinstance(log[-1], tuple) else log[-1] + if data == "train": + self.writer.add_scalar("train_loss", score, epoch) + else: + self.writer.add_scalar("valid_loss", score, epoch) + + return False + + def after_training(self, model): + if not self.activate: + return model + self.writer.flush() + self.writer.close() + + return model diff --git a/freqtrade/freqai/torch/PyTorchDataConvertor.py b/freqtrade/freqai/torch/PyTorchDataConvertor.py index a31ccdc79..e6b815373 100644 --- a/freqtrade/freqai/torch/PyTorchDataConvertor.py +++ b/freqtrade/freqai/torch/PyTorchDataConvertor.py @@ -1,5 +1,5 @@ from abc import ABC, abstractmethod -from typing import List, Optional +from typing import Optional import pandas as pd import torch @@ -12,14 +12,14 @@ class PyTorchDataConvertor(ABC): """ @abstractmethod - def convert_x(self, df: pd.DataFrame, device: Optional[str] = None) -> List[torch.Tensor]: + def convert_x(self, df: pd.DataFrame, device: Optional[str] = None) -> torch.Tensor: """ :param df: "*_features" dataframe. :param device: The device to use for training (e.g. 'cpu', 'cuda'). """ @abstractmethod - def convert_y(self, df: pd.DataFrame, device: Optional[str] = None) -> List[torch.Tensor]: + def convert_y(self, df: pd.DataFrame, device: Optional[str] = None) -> torch.Tensor: """ :param df: "*_labels" dataframe. :param device: The device to use for training (e.g. 'cpu', 'cuda'). @@ -45,14 +45,14 @@ class DefaultPyTorchDataConvertor(PyTorchDataConvertor): self._target_tensor_type = target_tensor_type self._squeeze_target_tensor = squeeze_target_tensor - def convert_x(self, df: pd.DataFrame, device: Optional[str] = None) -> List[torch.Tensor]: + def convert_x(self, df: pd.DataFrame, device: Optional[str] = None) -> torch.Tensor: x = torch.from_numpy(df.values).float() if device: x = x.to(device) - return [x] + return x - def convert_y(self, df: pd.DataFrame, device: Optional[str] = None) -> List[torch.Tensor]: + def convert_y(self, df: pd.DataFrame, device: Optional[str] = None) -> torch.Tensor: y = torch.from_numpy(df.values) if self._target_tensor_type: @@ -64,4 +64,4 @@ class DefaultPyTorchDataConvertor(PyTorchDataConvertor): if device: y = y.to(device) - return [y] + return y diff --git a/freqtrade/freqai/torch/PyTorchMLPModel.py b/freqtrade/freqai/torch/PyTorchMLPModel.py index 62d3216df..0093388f8 100644 --- a/freqtrade/freqai/torch/PyTorchMLPModel.py +++ b/freqtrade/freqai/torch/PyTorchMLPModel.py @@ -1,5 +1,4 @@ import logging -from typing import List import torch from torch import nn @@ -47,8 +46,8 @@ class PyTorchMLPModel(nn.Module): self.relu = nn.ReLU() self.dropout = nn.Dropout(p=dropout_percent) - def forward(self, tensors: List[torch.Tensor]) -> torch.Tensor: - x: torch.Tensor = tensors[0] + def forward(self, x: torch.Tensor) -> torch.Tensor: + # x: torch.Tensor = tensors[0] x = self.relu(self.input_layer(x)) x = self.dropout(x) x = self.blocks(x) diff --git a/freqtrade/freqai/torch/PyTorchModelTrainer.py b/freqtrade/freqai/torch/PyTorchModelTrainer.py index 8277ba937..603e7ac12 100644 --- a/freqtrade/freqai/torch/PyTorchModelTrainer.py +++ b/freqtrade/freqai/torch/PyTorchModelTrainer.py @@ -12,6 +12,8 @@ from torch.utils.data import DataLoader, TensorDataset from freqtrade.freqai.torch.PyTorchDataConvertor import PyTorchDataConvertor from freqtrade.freqai.torch.PyTorchTrainerInterface import PyTorchTrainerInterface +from .datasets import WindowDataset + logger = logging.getLogger(__name__) @@ -23,9 +25,10 @@ class PyTorchModelTrainer(PyTorchTrainerInterface): optimizer: Optimizer, criterion: nn.Module, device: str, - init_model: Dict, data_convertor: PyTorchDataConvertor, model_meta_data: Dict[str, Any] = {}, + window_size: int = 1, + tb_logger: Any = None, **kwargs ): """ @@ -52,8 +55,8 @@ class PyTorchModelTrainer(PyTorchTrainerInterface): self.batch_size: int = kwargs.get("batch_size", 64) self.max_n_eval_batches: Optional[int] = kwargs.get("max_n_eval_batches", None) self.data_convertor = data_convertor - if init_model: - self.load_from_checkpoint(init_model) + self.window_size: int = window_size + self.tb_logger = tb_logger def fit(self, data_dictionary: Dict[str, pd.DataFrame], splits: List[str]): """ @@ -75,36 +78,28 @@ class PyTorchModelTrainer(PyTorchTrainerInterface): batch_size=self.batch_size, n_iters=self.max_iters ) + self.model.train() for epoch in range(1, epochs + 1): - # training - losses = [] for i, batch_data in enumerate(data_loaders_dictionary["train"]): - for tensor in batch_data: - tensor.to(self.device) - - xb = batch_data[:-1] - yb = batch_data[-1] + xb, yb = batch_data + xb.to(self.device) + yb.to(self.device) yb_pred = self.model(xb) loss = self.criterion(yb_pred, yb) self.optimizer.zero_grad(set_to_none=True) loss.backward() self.optimizer.step() - losses.append(loss.item()) - train_loss = sum(losses) / len(losses) - log_message = f"epoch {epoch}/{epochs}: train loss {train_loss:.4f}" + self.tb_logger.log_scalar("train_loss", loss.item(), i) # evaluation if "test" in splits: - test_loss = self.estimate_loss( + self.estimate_loss( data_loaders_dictionary, self.max_n_eval_batches, "test" ) - log_message += f" ; test loss {test_loss:.4f}" - - logger.info(log_message) @torch.no_grad() def estimate_loss( @@ -112,26 +107,22 @@ class PyTorchModelTrainer(PyTorchTrainerInterface): data_loader_dictionary: Dict[str, DataLoader], max_n_eval_batches: Optional[int], split: str, - ) -> float: + ) -> None: self.model.eval() n_batches = 0 - losses = [] for i, batch_data in enumerate(data_loader_dictionary[split]): if max_n_eval_batches and i > max_n_eval_batches: n_batches += 1 break + xb, yb = batch_data + xb.to(self.device) + yb.to(self.device) - for tensor in batch_data: - tensor.to(self.device) - - xb = batch_data[:-1] - yb = batch_data[-1] yb_pred = self.model(xb) loss = self.criterion(yb_pred, yb) - losses.append(loss.item()) + self.tb_logger.log_scalar(f"{split}_loss", loss.item(), i) self.model.train() - return sum(losses) / len(losses) def create_data_loaders_dictionary( self, @@ -145,7 +136,7 @@ class PyTorchModelTrainer(PyTorchTrainerInterface): for split in splits: x = self.data_convertor.convert_x(data_dictionary[f"{split}_features"], self.device) y = self.data_convertor.convert_y(data_dictionary[f"{split}_labels"], self.device) - dataset = TensorDataset(*x, *y) + dataset = TensorDataset(x, y) data_loader = DataLoader( dataset, batch_size=self.batch_size, @@ -206,3 +197,33 @@ class PyTorchModelTrainer(PyTorchTrainerInterface): self.optimizer.load_state_dict(checkpoint["optimizer_state_dict"]) self.model_meta_data = checkpoint["model_meta_data"] return self + + +class PyTorchTransformerTrainer(PyTorchModelTrainer): + """ + Creating a trainer for the Transformer model. + """ + + def create_data_loaders_dictionary( + self, + data_dictionary: Dict[str, pd.DataFrame], + splits: List[str] + ) -> Dict[str, DataLoader]: + """ + Converts the input data to PyTorch tensors using a data loader. + """ + data_loader_dictionary = {} + for split in splits: + x = self.data_convertor.convert_x(data_dictionary[f"{split}_features"], self.device) + y = self.data_convertor.convert_y(data_dictionary[f"{split}_labels"], self.device) + dataset = WindowDataset(x, y, self.window_size) + data_loader = DataLoader( + dataset, + batch_size=self.batch_size, + shuffle=False, + drop_last=True, + num_workers=0, + ) + data_loader_dictionary[split] = data_loader + + return data_loader_dictionary diff --git a/freqtrade/freqai/torch/PyTorchTransformerModel.py b/freqtrade/freqai/torch/PyTorchTransformerModel.py new file mode 100644 index 000000000..162459776 --- /dev/null +++ b/freqtrade/freqai/torch/PyTorchTransformerModel.py @@ -0,0 +1,93 @@ +import math + +import torch +from torch import nn + + +""" +The architecture is based on the paper “Attention Is All You Need”. +Ashish Vaswani, Noam Shazeer, Niki Parmar, Jakob Uszkoreit, Llion Jones, Aidan N Gomez, +Lukasz Kaiser, and Illia Polosukhin. 2017. +""" + + +class PyTorchTransformerModel(nn.Module): + """ + A transformer approach to time series modeling using positional encoding. + The architecture is based on the paper “Attention Is All You Need”. + Ashish Vaswani, Noam Shazeer, Niki Parmar, Jakob Uszkoreit, Llion Jones, Aidan N Gomez, + Lukasz Kaiser, and Illia Polosukhin. 2017. + """ + + def __init__(self, input_dim: int = 7, output_dim: int = 7, hidden_dim=1024, + n_layer=2, dropout_percent=0.1, time_window=10, nhead=8): + super().__init__() + self.time_window = time_window + # ensure the input dimension to the transformer is divisible by nhead + self.dim_val = input_dim - (input_dim % nhead) + self.input_net = nn.Sequential( + nn.Dropout(dropout_percent), nn.Linear(input_dim, self.dim_val) + ) + + # Encode the timeseries with Positional encoding + self.positional_encoding = PositionalEncoding(d_model=self.dim_val, max_len=self.dim_val) + + # Define the encoder block of the Transformer + self.encoder_layer = nn.TransformerEncoderLayer( + d_model=self.dim_val, nhead=nhead, dropout=dropout_percent, batch_first=True) + self.transformer = nn.TransformerEncoder(self.encoder_layer, num_layers=n_layer) + + # the pseudo decoding FC + self.output_net = nn.Sequential( + nn.Linear(self.dim_val * time_window, int(hidden_dim)), + nn.ReLU(), + nn.Dropout(dropout_percent), + nn.Linear(int(hidden_dim), int(hidden_dim / 2)), + nn.ReLU(), + nn.Dropout(dropout_percent), + nn.Linear(int(hidden_dim / 2), int(hidden_dim / 4)), + nn.ReLU(), + nn.Dropout(dropout_percent), + nn.Linear(int(hidden_dim / 4), output_dim) + ) + + def forward(self, x, mask=None, add_positional_encoding=True): + """ + Args: + x: Input features of shape [Batch, SeqLen, input_dim] + mask: Mask to apply on the attention outputs (optional) + add_positional_encoding: If True, we add the positional encoding to the input. + Might not be desired for some tasks. + """ + x = self.input_net(x) + if add_positional_encoding: + x = self.positional_encoding(x) + x = self.transformer(x, mask=mask) + x = x.reshape(-1, 1, self.time_window * x.shape[-1]) + x = self.output_net(x) + return x + + +class PositionalEncoding(nn.Module): + def __init__(self, d_model, max_len=5000): + """ + Args + d_model: Hidden dimensionality of the input. + max_len: Maximum length of a sequence to expect. + """ + super().__init__() + + # Create matrix of [SeqLen, HiddenDim] representing the positional encoding + # for max_len inputs + pe = torch.zeros(max_len, d_model) + position = torch.arange(0, max_len, dtype=torch.float).unsqueeze(1) + div_term = torch.exp(torch.arange(0, d_model, 2).float() * (-math.log(10000.0) / d_model)) + pe[:, 0::2] = torch.sin(position * div_term) + pe[:, 1::2] = torch.cos(position * div_term) + pe = pe.unsqueeze(0) + + self.register_buffer("pe", pe, persistent=False) + + def forward(self, x): + x = x + self.pe[:, : x.size(1)] + return x diff --git a/freqtrade/freqai/torch/datasets.py b/freqtrade/freqai/torch/datasets.py new file mode 100644 index 000000000..120d8a116 --- /dev/null +++ b/freqtrade/freqai/torch/datasets.py @@ -0,0 +1,19 @@ +import torch + + +class WindowDataset(torch.utils.data.Dataset): + def __init__(self, xs, ys, window_size): + self.xs = xs + self.ys = ys + self.window_size = window_size + + def __len__(self): + return len(self.xs) - self.window_size + + def __getitem__(self, index): + idx_rev = len(self.xs) - self.window_size - index - 1 + window_x = self.xs[idx_rev:idx_rev + self.window_size, :] + # Beware of indexing, these two window_x and window_y are aimed at the same row! + # this is what happens when you use : + window_y = self.ys[idx_rev + self.window_size - 1, :].unsqueeze(0) + return window_x, window_y diff --git a/freqtrade/freqai/utils.py b/freqtrade/freqai/utils.py index 2ba49ac40..b670a2aad 100644 --- a/freqtrade/freqai/utils.py +++ b/freqtrade/freqai/utils.py @@ -92,55 +92,6 @@ def get_required_data_timerange(config: Config) -> TimeRange: return data_load_timerange -# Keep below for when we wish to download heterogeneously lengthed data for FreqAI. -# def download_all_data_for_training(dp: DataProvider, config: Config) -> None: -# """ -# Called only once upon start of bot to download the necessary data for -# populating indicators and training a FreqAI model. -# :param timerange: TimeRange = The full data timerange for populating the indicators -# and training the model. -# :param dp: DataProvider instance attached to the strategy -# """ - -# if dp._exchange is not None: -# markets = [p for p, m in dp._exchange.markets.items() if market_is_active(m) -# or config.get('include_inactive')] -# else: -# # This should not occur: -# raise OperationalException('No exchange object found.') - -# all_pairs = dynamic_expand_pairlist(config, markets) - -# if not dp._exchange: -# # Not realistic - this is only called in live mode. -# raise OperationalException("Dataprovider did not have an exchange attached.") - -# time = datetime.now(tz=timezone.utc).timestamp() - -# for tf in config["freqai"]["feature_parameters"].get("include_timeframes"): -# timerange = TimeRange() -# timerange.startts = int(time) -# timerange.stopts = int(time) -# startup_candles = dp.get_required_startup(str(tf)) -# tf_seconds = timeframe_to_seconds(str(tf)) -# timerange.subtract_start(tf_seconds * startup_candles) -# new_pairs_days = int((timerange.stopts - timerange.startts) / 86400) -# # FIXME: now that we are looping on `refresh_backtest_ohlcv_data`, the function -# # redownloads the funding rate for each pair. -# refresh_backtest_ohlcv_data( -# dp._exchange, -# pairs=all_pairs, -# timeframes=[tf], -# datadir=config["datadir"], -# timerange=timerange, -# new_pairs_days=new_pairs_days, -# erase=False, -# data_format=config.get("dataformat_ohlcv", "json"), -# trading_mode=config.get("trading_mode", "spot"), -# prepend=config.get("prepend_data", False), -# ) - - def plot_feature_importance(model: Any, pair: str, dk: FreqaiDataKitchen, count_max: int = 25) -> None: """ @@ -233,3 +184,13 @@ def get_timerange_backtest_live_models(config: Config) -> str: dd = FreqaiDataDrawer(models_path, config) timerange = dd.get_timerange_from_live_historic_predictions() return timerange.timerange_str + + +def get_tb_logger(model_type: str, path: Path, activate: bool) -> Any: + + if model_type == "pytorch" and activate: + from freqtrade.freqai.tensorboard import TBLogger + return TBLogger(path, activate) + else: + from freqtrade.freqai.tensorboard.base_tensorboard import BaseTensorboardLogger + return BaseTensorboardLogger(path, activate) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 73b25a7a1..1fdf79b68 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -1,9 +1,9 @@ """ Freqtrade is the main module of this bot. It contains the class Freqtrade() """ -import copy import logging import traceback +from copy import deepcopy from datetime import datetime, time, timedelta, timezone from math import isclose from threading import Lock @@ -13,7 +13,7 @@ from schedule import Scheduler from freqtrade import constants from freqtrade.configuration import validate_config_consistency -from freqtrade.constants import BuySell, Config, LongShort +from freqtrade.constants import BuySell, Config, ExchangeConfig, LongShort from freqtrade.data.converter import order_book_to_dataframe from freqtrade.data.dataprovider import DataProvider from freqtrade.edge import Edge @@ -23,6 +23,7 @@ from freqtrade.exceptions import (DependencyException, ExchangeError, Insufficie InvalidOrderException, PricingError) from freqtrade.exchange import (ROUND_DOWN, ROUND_UP, timeframe_to_minutes, timeframe_to_next_date, timeframe_to_seconds) +from freqtrade.exchange.common import remove_exchange_credentials from freqtrade.misc import safe_value_fallback, safe_value_fallback2 from freqtrade.mixins import LoggingMixin from freqtrade.persistence import Order, PairLocks, Trade, init_db @@ -63,6 +64,9 @@ class FreqtradeBot(LoggingMixin): # Init objects self.config = config + exchange_config: ExchangeConfig = deepcopy(config['exchange']) + # Remove credentials from original exchange config to avoid accidental credentail exposure + remove_exchange_credentials(config['exchange'], True) self.strategy: IStrategy = StrategyResolver.load_strategy(self.config) @@ -70,7 +74,7 @@ class FreqtradeBot(LoggingMixin): validate_config_consistency(config) self.exchange = ExchangeResolver.load_exchange( - self.config['exchange']['name'], self.config, load_leverage_tiers=True) + self.config, exchange_config=exchange_config, load_leverage_tiers=True) init_db(self.config['db_url']) @@ -227,7 +231,7 @@ class FreqtradeBot(LoggingMixin): self.manage_open_orders() # Protect from collisions with force_exit. - # Without this, freqtrade my try to recreate stoploss_on_exchange orders + # Without this, freqtrade may try to recreate stoploss_on_exchange orders # while exiting is in process, since telegram messages arrive in an different thread. with self._exit_lock: trades = Trade.get_open_trades() @@ -420,7 +424,7 @@ class FreqtradeBot(LoggingMixin): """ Try refinding a lost trade. Only used when InsufficientFunds appears on exit orders (stoploss or long sell/short buy). - Tries to walk the stored orders and sell them off eventually. + Tries to walk the stored orders and updates the trade state if necessary. """ logger.info(f"Trying to refind lost order for {trade}") for order in trade.orders: @@ -451,6 +455,42 @@ class FreqtradeBot(LoggingMixin): except ExchangeError: logger.warning(f"Error updating {order.order_id}.") + def handle_onexchange_order(self, trade: Trade): + """ + Try refinding a order that is not in the database. + Only used balance disappeared, which would make exiting impossible. + """ + try: + orders = self.exchange.fetch_orders(trade.pair, trade.open_date_utc) + for order in orders: + trade_order = [o for o in trade.orders if o.order_id == order['id']] + if trade_order: + continue + logger.info(f"Found previously unknown order {order['id']} for {trade.pair}.") + + order_obj = Order.parse_from_ccxt_object(order, trade.pair, order['side']) + order_obj.order_filled_date = datetime.fromtimestamp( + safe_value_fallback(order, 'lastTradeTimestamp', 'timestamp') // 1000, + tz=timezone.utc) + trade.orders.append(order_obj) + # TODO: how do we handle open_order_id ... + Trade.commit() + prev_exit_reason = trade.exit_reason + trade.exit_reason = ExitType.SOLD_ON_EXCHANGE.value + self.update_trade_state(trade, order['id'], order) + + logger.info(f"handled order {order['id']}") + if not trade.is_open: + # Trade was just closed + trade.close_date = order_obj.order_filled_date + Trade.commit() + break + else: + trade.exit_reason = prev_exit_reason + Trade.commit() + + except ExchangeError: + logger.warning("Error finding onexchange order") # # BUY / enter positions / open trades logic and methods # @@ -461,7 +501,7 @@ class FreqtradeBot(LoggingMixin): """ trades_created = 0 - whitelist = copy.deepcopy(self.active_pair_whitelist) + whitelist = deepcopy(self.active_pair_whitelist) if not whitelist: self.log_once("Active pair whitelist is empty.", logger.info) return trades_created @@ -490,7 +530,8 @@ class FreqtradeBot(LoggingMixin): # Create entity and execute trade for each pair from whitelist for pair in whitelist: try: - trades_created += self.create_trade(pair) + with self._exit_lock: + trades_created += self.create_trade(pair) except DependencyException as exception: logger.warning('Unable to create trade for %s: %s', pair, exception) @@ -981,7 +1022,7 @@ class FreqtradeBot(LoggingMixin): 'base_currency': self.exchange.get_pair_base_currency(trade.pair), 'fiat_currency': self.config.get('fiat_display_currency', None), 'amount': order.safe_amount_after_fee if fill else (order.amount or trade.amount), - 'open_date': trade.open_date or datetime.utcnow(), + 'open_date': trade.open_date_utc or datetime.now(timezone.utc), 'current_rate': current_rate, 'sub_trade': sub_trade, } @@ -1033,6 +1074,13 @@ class FreqtradeBot(LoggingMixin): """ trades_closed = 0 for trade in trades: + + if trade.open_order_id is None and not self.wallets.check_exit_amount(trade): + logger.warning( + f'Not enough {trade.safe_base_currency} in wallet to exit {trade}. ' + 'Trying to recover.') + self.handle_onexchange_order(trade) + try: try: if (self.strategy.order_types.get('stoploss_on_exchange') and @@ -1254,6 +1302,10 @@ class FreqtradeBot(LoggingMixin): f"(orderid:{order['id']}) in order to add another one ...") self.cancel_stoploss_on_exchange(trade) + if not trade.is_open: + logger.warning( + f"Trade {trade} is closed, not creating trailing stoploss order.") + return # Create new stoploss order if not self.create_stoploss_order(trade=trade, stop_price=stoploss_norm): @@ -1425,7 +1477,7 @@ class FreqtradeBot(LoggingMixin): corder = order reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE'] - logger.info('%s order %s for %s.', side, reason, trade) + logger.info(f'{side} order {reason} for {trade}.') # Using filled to determine the filled amount filled_amount = safe_value_fallback2(corder, order, 'filled', 'filled') @@ -1507,7 +1559,7 @@ class FreqtradeBot(LoggingMixin): trade.exit_reason = None trade.open_order_id = None - self.update_trade_state(trade, trade.open_order_id, order) + self.update_trade_state(trade, order['id'], order) logger.info(f'{trade.exit_side.capitalize()} order {reason} for {trade}.') trade.close_rate = None @@ -1535,13 +1587,13 @@ class FreqtradeBot(LoggingMixin): # Update wallets to ensure amounts tied up in a stoploss is now free! self.wallets.update() if self.trading_mode == TradingMode.FUTURES: + # A safe exit amount isn't needed for futures, you can just exit/close the position return amount trade_base_currency = self.exchange.get_pair_base_currency(pair) wallet_amount = self.wallets.get_free(trade_base_currency) logger.debug(f"{pair} - Wallet: {wallet_amount} - Trade-amount: {amount}") if wallet_amount >= amount: - # A safe exit amount isn't needed for futures, you can just exit/close the position return amount elif wallet_amount > amount * 0.98: logger.info(f"{pair} - Falling back to wallet-amount {wallet_amount} -> {amount}.") @@ -1697,8 +1749,8 @@ class FreqtradeBot(LoggingMixin): 'enter_tag': trade.enter_tag, 'sell_reason': trade.exit_reason, # Deprecated 'exit_reason': trade.exit_reason, - 'open_date': trade.open_date, - 'close_date': trade.close_date or datetime.utcnow(), + 'open_date': trade.open_date_utc, + 'close_date': trade.close_date_utc or datetime.now(timezone.utc), 'stake_amount': trade.stake_amount, 'stake_currency': self.config['stake_currency'], 'base_currency': self.exchange.get_pair_base_currency(trade.pair), @@ -1720,10 +1772,8 @@ class FreqtradeBot(LoggingMixin): else: trade.exit_order_status = reason - order = trade.select_order_by_order_id(order_id) - if not order: - raise DependencyException( - f"Order_obj not found for {order_id}. This should not have happened.") + order_or_none = trade.select_order_by_order_id(order_id) + order = self.order_obj_or_raise(order_id, order_or_none) profit_rate: float = trade.safe_close_rate profit_trade = trade.calc_profit(rate=profit_rate) @@ -1764,6 +1814,12 @@ class FreqtradeBot(LoggingMixin): # Send the message self.rpc.send_msg(msg) + def order_obj_or_raise(self, order_id: str, order_obj: Optional[Order]) -> Order: + if not order_obj: + raise DependencyException( + f"Order_obj not found for {order_id}. This should not have happened.") + return order_obj + # # Common update trade state methods # @@ -1802,10 +1858,8 @@ class FreqtradeBot(LoggingMixin): # Handling of this will happen in check_handle_timedout. return True - order_obj = trade.select_order_by_order_id(order_id) - if not order_obj: - raise DependencyException( - f"Order_obj not found for {order_id}. This should not have happened.") + order_obj_or_none = trade.select_order_by_order_id(order_id) + order_obj = self.order_obj_or_raise(order_id, order_obj_or_none) self.handle_order_fee(trade, order_obj, order) @@ -1823,16 +1877,18 @@ class FreqtradeBot(LoggingMixin): # Must also run for partial exits # TODO: Margin will need to use interest_rate as well. # interest_rate = self.exchange.get_interest_rate() - trade.set_liquidation_price(self.exchange.get_liquidation_price( - pair=trade.pair, - open_rate=trade.open_rate, - is_short=trade.is_short, - amount=trade.amount, - stake_amount=trade.stake_amount, - leverage=trade.leverage, - wallet_balance=trade.stake_amount, - )) - + try: + trade.set_liquidation_price(self.exchange.get_liquidation_price( + pair=trade.pair, + open_rate=trade.open_rate, + is_short=trade.is_short, + amount=trade.amount, + stake_amount=trade.stake_amount, + leverage=trade.leverage, + wallet_balance=trade.stake_amount, + )) + except DependencyException: + logger.warning('Unable to calculate liquidation price') # Updating wallets when order is closed self.wallets.update() Trade.commit() @@ -1883,6 +1939,7 @@ class FreqtradeBot(LoggingMixin): """ Applies the fee to amount (either from Order or from Trades). Can eat into dust if more than the required asset is available. + In case of trade adjustment orders, trade.amount will not have been adjusted yet. Can't happen in Futures mode - where Fees are always in settlement currency, never in base currency. """ @@ -1892,6 +1949,10 @@ class FreqtradeBot(LoggingMixin): # check against remaining amount! amount_ = trade.amount - amount + if trade.nr_of_successful_entries >= 1 and order_obj.ft_order_side == trade.entry_side: + # In case of rebuy's, trade.amount doesn't contain the amount of the last entry. + amount_ = trade.amount + amount + if fee_abs != 0 and self.wallets.get_free(trade_base_currency) >= amount_: # Eat into dust if we own more than base currency logger.info(f"Fee amount for {trade} was in base currency - " @@ -1921,7 +1982,11 @@ class FreqtradeBot(LoggingMixin): # Init variables order_amount = safe_value_fallback(order, 'filled', 'amount') # Only run for closed orders - if trade.fee_updated(order.get('side', '')) or order['status'] == 'open': + if ( + trade.fee_updated(order.get('side', '')) + or order['status'] == 'open' + or order_obj.ft_fee_base + ): return None trade_base_currency = self.exchange.get_pair_base_currency(trade.pair) diff --git a/freqtrade/loggers/__init__.py b/freqtrade/loggers/__init__.py index 528d274f2..390f210c0 100644 --- a/freqtrade/loggers/__init__.py +++ b/freqtrade/loggers/__init__.py @@ -5,6 +5,7 @@ from logging.handlers import RotatingFileHandler, SysLogHandler from freqtrade.constants import Config from freqtrade.exceptions import OperationalException from freqtrade.loggers.buffering_handler import FTBufferingHandler +from freqtrade.loggers.set_log_levels import set_loggers from freqtrade.loggers.std_err_stream_handler import FTStdErrStreamHandler @@ -16,28 +17,6 @@ bufferHandler = FTBufferingHandler(1000) bufferHandler.setFormatter(Formatter(LOGFORMAT)) -def _set_loggers(verbosity: int = 0, api_verbosity: str = 'info') -> None: - """ - Set the logging level for third party libraries - :return: None - """ - - logging.getLogger('requests').setLevel( - logging.INFO if verbosity <= 1 else logging.DEBUG - ) - logging.getLogger("urllib3").setLevel( - logging.INFO if verbosity <= 1 else logging.DEBUG - ) - logging.getLogger('ccxt.base.exchange').setLevel( - logging.INFO if verbosity <= 2 else logging.DEBUG - ) - logging.getLogger('telegram').setLevel(logging.INFO) - - logging.getLogger('werkzeug').setLevel( - logging.ERROR if api_verbosity == 'error' else logging.INFO - ) - - def get_existing_handlers(handlertype): """ Returns Existing handler or None (if the handler has not yet been added to the root handlers). @@ -114,6 +93,6 @@ def setup_logging(config: Config) -> None: logging.root.addHandler(handler_rf) logging.root.setLevel(logging.INFO if verbosity < 1 else logging.DEBUG) - _set_loggers(verbosity, config.get('api_server', {}).get('verbosity', 'info')) + set_loggers(verbosity, config.get('api_server', {}).get('verbosity', 'info')) logger.info('Verbosity set to %s', verbosity) diff --git a/freqtrade/loggers/set_log_levels.py b/freqtrade/loggers/set_log_levels.py new file mode 100644 index 000000000..7311fa0a0 --- /dev/null +++ b/freqtrade/loggers/set_log_levels.py @@ -0,0 +1,55 @@ + +import logging + + +logger = logging.getLogger(__name__) + + +def set_loggers(verbosity: int = 0, api_verbosity: str = 'info') -> None: + """ + Set the logging level for third party libraries + :return: None + """ + + logging.getLogger('requests').setLevel( + logging.INFO if verbosity <= 1 else logging.DEBUG + ) + logging.getLogger("urllib3").setLevel( + logging.INFO if verbosity <= 1 else logging.DEBUG + ) + logging.getLogger('ccxt.base.exchange').setLevel( + logging.INFO if verbosity <= 2 else logging.DEBUG + ) + logging.getLogger('telegram').setLevel(logging.INFO) + logging.getLogger('httpx').setLevel(logging.WARNING) + + logging.getLogger('werkzeug').setLevel( + logging.ERROR if api_verbosity == 'error' else logging.INFO + ) + + +__BIAS_TESTER_LOGGERS = [ + 'freqtrade.resolvers', + 'freqtrade.strategy.hyper', + 'freqtrade.configuration.config_validation', +] + + +def reduce_verbosity_for_bias_tester() -> None: + """ + Reduce verbosity for bias tester. + It loads the same strategy several times, which would spam the log. + """ + logger.info("Reducing verbosity for bias tester.") + for logger_name in __BIAS_TESTER_LOGGERS: + logging.getLogger(logger_name).setLevel(logging.WARNING) + + +def restore_verbosity_for_bias_tester() -> None: + """ + Restore verbosity after bias tester. + """ + logger.info("Restoring log verbosity.") + log_level = logging.NOTSET + for logger_name in __BIAS_TESTER_LOGGERS: + logging.getLogger(logger_name).setLevel(log_level) diff --git a/freqtrade/misc.py b/freqtrade/misc.py index 0cd5c6ffd..350ac5eef 100644 --- a/freqtrade/misc.py +++ b/freqtrade/misc.py @@ -3,13 +3,10 @@ Various tool function for Freqtrade and scripts """ import gzip import logging -import re -from datetime import datetime from pathlib import Path from typing import Any, Dict, Iterator, List, Mapping, Optional, TextIO, Union from urllib.parse import urlparse -import orjson import pandas as pd import rapidjson @@ -48,18 +45,6 @@ def round_coin_value( return val -def shorten_date(_date: str) -> str: - """ - Trim the date so it fits on small screens - """ - new_date = re.sub('seconds?', 'sec', _date) - new_date = re.sub('minutes?', 'min', new_date) - new_date = re.sub('hours?', 'h', new_date) - new_date = re.sub('days?', 'd', new_date) - new_date = re.sub('^an?', '1', new_date) - return new_date - - def file_dump_json(filename: Path, data: Any, is_zip: bool = False, log: bool = True) -> None: """ Dump JSON data into a file @@ -137,14 +122,6 @@ def pair_to_filename(pair: str) -> str: return pair -def format_ms_time(date: int) -> str: - """ - convert MS date to readable format. - : epoch-string in ms - """ - return datetime.fromtimestamp(date / 1000.0).strftime('%Y-%m-%dT%H:%M:%S') - - def deep_merge_dicts(source, destination, allow_null_overrides: bool = True): """ Values from Source override destination, destination is returned (and modified!!) @@ -262,17 +239,7 @@ def dataframe_to_json(dataframe: pd.DataFrame) -> str: :param dataframe: A pandas DataFrame :returns: A JSON string of the pandas DataFrame """ - # https://github.com/pandas-dev/pandas/issues/24889 - # https://github.com/pandas-dev/pandas/issues/40443 - # We need to convert to a dict to avoid mem leak - def default(z): - if isinstance(z, pd.Timestamp): - return z.timestamp() * 1e3 - if z is pd.NaT: - return 'NaT' - raise TypeError - - return str(orjson.dumps(dataframe.to_dict(orient='split'), default=default), 'utf-8') + return dataframe.to_json(orient='split') def json_to_dataframe(data: str) -> pd.DataFrame: diff --git a/freqtrade/mixins/logging_mixin.py b/freqtrade/mixins/logging_mixin.py index 06935d5f6..31b49ba55 100644 --- a/freqtrade/mixins/logging_mixin.py +++ b/freqtrade/mixins/logging_mixin.py @@ -3,7 +3,7 @@ from typing import Callable from cachetools import TTLCache, cached -class LoggingMixin(): +class LoggingMixin: """ Logging Mixin Shows similar messages only once every `refresh_period`. diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index c7b2a0d3c..4a5536e84 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -9,7 +9,6 @@ from copy import deepcopy from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, Optional, Tuple -import pandas as pd from numpy import nan from pandas import DataFrame @@ -25,11 +24,14 @@ from freqtrade.enums import (BacktestState, CandleType, ExitCheckTuple, ExitType from freqtrade.exceptions import DependencyException, OperationalException from freqtrade.exchange import (amount_to_contract_precision, price_to_precision, timeframe_to_minutes, timeframe_to_seconds) +from freqtrade.exchange.exchange import Exchange from freqtrade.mixins import LoggingMixin from freqtrade.optimize.backtest_caching import get_strategy_run_id from freqtrade.optimize.bt_progress import BTProgress -from freqtrade.optimize.optimize_reports import (generate_backtest_stats, show_backtest_results, - store_backtest_signal_candles, +from freqtrade.optimize.optimize_reports import (generate_backtest_stats, generate_rejected_signals, + generate_trade_signal_candles, + show_backtest_results, + store_backtest_analysis_results, store_backtest_stats) from freqtrade.persistence import LocalTrade, Order, PairLocks, Trade from freqtrade.plugins.pairlistmanager import PairListManager @@ -71,7 +73,7 @@ class Backtesting: backtesting.start() """ - def __init__(self, config: Config) -> None: + def __init__(self, config: Config, exchange: Optional[Exchange] = None) -> None: LoggingMixin.show_output = False self.config = config @@ -84,10 +86,14 @@ class Backtesting: self.strategylist: List[IStrategy] = [] self.all_results: Dict[str, Dict] = {} self.processed_dfs: Dict[str, Dict] = {} + self.rejected_dict: Dict[str, List] = {} + self.rejected_df: Dict[str, Dict] = {} self._exchange_name = self.config['exchange']['name'] - self.exchange = ExchangeResolver.load_exchange( - self._exchange_name, self.config, load_leverage_tiers=True) + if not exchange: + exchange = ExchangeResolver.load_exchange(self.config, load_leverage_tiers=True) + self.exchange = exchange + self.dataprovider = DataProvider(self.config, self.exchange) if self.config.get('strategy_list'): @@ -112,16 +118,7 @@ class Backtesting: self.timeframe_min = timeframe_to_minutes(self.timeframe) self.init_backtest_detail() 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.") - if 'PerformanceFilter' in self.pairlists.name_list: - raise OperationalException("PerformanceFilter not allowed for backtesting.") - - if len(self.strategylist) > 1 and 'PrecisionFilter' in self.pairlists.name_list: - raise OperationalException( - "PrecisionFilter not allowed for backtesting multiple strategies." - ) + self._validate_pairlists_for_backtesting() self.dataprovider.add_pairlisthandler(self.pairlists) self.pairlists.refresh_pairlist() @@ -162,6 +159,18 @@ class Backtesting: self.init_backtest() + def _validate_pairlists_for_backtesting(self): + if 'VolumePairList' in self.pairlists.name_list: + raise OperationalException("VolumePairList not allowed for backtesting. " + "Please use StaticPairList instead.") + if 'PerformanceFilter' in self.pairlists.name_list: + raise OperationalException("PerformanceFilter not allowed for backtesting.") + + if len(self.strategylist) > 1 and 'PrecisionFilter' in self.pairlists.name_list: + raise OperationalException( + "PrecisionFilter not allowed for backtesting multiple strategies." + ) + @staticmethod def cleanup(): LoggingMixin.show_output = True @@ -1056,6 +1065,18 @@ class Backtesting: return None return row + def _collate_rejected(self, pair, row): + """ + Temporarily store rejected signal information for downstream use in backtesting_analysis + """ + # It could be fun to enable hyperopt mode to write + # a loss function to reduce rejected signals + if (self.config.get('export', 'none') == 'signals' and + self.dataprovider.runmode == RunMode.BACKTEST): + if pair not in self.rejected_dict: + self.rejected_dict[pair] = [] + self.rejected_dict[pair].append([row[DATE_IDX], row[ENTER_TAG_IDX]]) + def backtest_loop( self, row: Tuple, pair: str, current_time: datetime, end_date: datetime, open_trade_count_start: int, trade_dir: Optional[LongShort], @@ -1081,20 +1102,22 @@ class Backtesting: if ( (self._position_stacking or len(LocalTrade.bt_trades_open_pp[pair]) == 0) and is_first - and self.trade_slot_available(open_trade_count_start) and current_time != end_date and trade_dir is not None and not PairLocks.is_pair_locked(pair, row[DATE_IDX], trade_dir) ): - trade = self._enter_trade(pair, row, trade_dir) - if trade: - # TODO: hacky workaround to avoid opening > max_open_trades - # This emulates previous behavior - not sure if this is correct - # Prevents entering if the trade-slot was freed in this candle - open_trade_count_start += 1 - # logger.debug(f"{pair} - Emulate creation of new trade: {trade}.") - LocalTrade.add_bt_trade(trade) - self.wallets.update() + if (self.trade_slot_available(open_trade_count_start)): + trade = self._enter_trade(pair, row, trade_dir) + if trade: + # TODO: hacky workaround to avoid opening > max_open_trades + # This emulates previous behavior - not sure if this is correct + # Prevents entering if the trade-slot was freed in this candle + open_trade_count_start += 1 + # logger.debug(f"{pair} - Emulate creation of new trade: {trade}.") + LocalTrade.add_bt_trade(trade) + self.wallets.update() + else: + self._collate_rejected(pair, row) for trade in list(LocalTrade.bt_trades_open_pp[pair]): # 3. Process entry orders. @@ -1236,8 +1259,8 @@ class Backtesting: def backtest_one_strategy(self, strat: IStrategy, data: Dict[str, DataFrame], timerange: TimeRange): self.progress.init_step(BacktestState.ANALYZE, 0) - - logger.info(f"Running backtesting for Strategy {strat.get_strategy_name()}") + strategy_name = strat.get_strategy_name() + logger.info(f"Running backtesting for Strategy {strategy_name}") backtest_start_time = datetime.now(timezone.utc) self._set_strategy(strat) @@ -1272,37 +1295,21 @@ class Backtesting: ) backtest_end_time = datetime.now(timezone.utc) results.update({ - 'run_id': self.run_ids.get(strat.get_strategy_name(), ''), + 'run_id': self.run_ids.get(strategy_name, ''), 'backtest_start_time': int(backtest_start_time.timestamp()), 'backtest_end_time': int(backtest_end_time.timestamp()), }) - self.all_results[self.strategy.get_strategy_name()] = results + self.all_results[strategy_name] = results if (self.config.get('export', 'none') == 'signals' and self.dataprovider.runmode == RunMode.BACKTEST): - self._generate_trade_signal_candles(preprocessed_tmp, results) + self.processed_dfs[strategy_name] = generate_trade_signal_candles( + preprocessed_tmp, results) + self.rejected_df[strategy_name] = generate_rejected_signals( + preprocessed_tmp, self.rejected_dict) return min_date, max_date - def _generate_trade_signal_candles(self, preprocessed_df, bt_results): - signal_candles_only = {} - for pair in preprocessed_df.keys(): - signal_candles_only_df = DataFrame() - - pairdf = preprocessed_df[pair] - resdf = bt_results['results'] - pairresults = resdf.loc[(resdf["pair"] == pair)] - - if pairdf.shape[0] > 0: - for t, v in pairresults.open_date.items(): - allinds = pairdf.loc[(pairdf['date'] < v)] - signal_inds = allinds.iloc[[-1]] - signal_candles_only_df = pd.concat([signal_candles_only_df, signal_inds]) - - signal_candles_only[pair] = signal_candles_only_df - - self.processed_dfs[self.strategy.get_strategy_name()] = signal_candles_only - def _get_min_cached_backtest_date(self): min_backtest_date = None backtest_cache_age = self.config.get('backtest_cache', constants.BACKTEST_CACHE_DEFAULT) @@ -1365,8 +1372,9 @@ class Backtesting: if (self.config.get('export', 'none') == 'signals' and self.dataprovider.runmode == RunMode.BACKTEST): - store_backtest_signal_candles( - self.config['exportfilename'], self.processed_dfs, dt_appendix) + store_backtest_analysis_results( + self.config['exportfilename'], self.processed_dfs, self.rejected_df, + dt_appendix) # Results may be mixed up now. Sort them so they follow --strategy-list order. if 'strategy_list' in self.config and len(self.results) > 0: diff --git a/freqtrade/optimize/edge_cli.py b/freqtrade/optimize/edge_cli.py index 2eb1c53f5..07c54d720 100644 --- a/freqtrade/optimize/edge_cli.py +++ b/freqtrade/optimize/edge_cli.py @@ -32,7 +32,7 @@ class EdgeCli: # Ensure using dry-run self.config['dry_run'] = True self.config['stake_amount'] = constants.UNLIMITED_STAKE_AMOUNT - self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config) + self.exchange = ExchangeResolver.load_exchange(self.config) self.strategy = StrategyResolver.load_strategy(self.config) self.strategy.dp = DataProvider(config, self.exchange) diff --git a/freqtrade/optimize/hyperopt.py b/freqtrade/optimize/hyperopt.py index ee5599e20..fe590f0d2 100644 --- a/freqtrade/optimize/hyperopt.py +++ b/freqtrade/optimize/hyperopt.py @@ -379,7 +379,8 @@ class Hyperopt: strat_stats = generate_strategy_stats( self.pairlist, self.backtesting.strategy.get_strategy_name(), - backtesting_results, min_date, max_date, market_change=self.market_change + backtesting_results, min_date, max_date, market_change=self.market_change, + is_hyperopt=True, ) results_explanation = HyperoptTools.format_results_explanation_string( strat_stats, self.config['stake_currency']) diff --git a/freqtrade/optimize/hyperopt_tools.py b/freqtrade/optimize/hyperopt_tools.py index 1e7befdf6..bc5b85309 100644 --- a/freqtrade/optimize/hyperopt_tools.py +++ b/freqtrade/optimize/hyperopt_tools.py @@ -35,7 +35,7 @@ def hyperopt_serializer(x): return str(x) -class HyperoptStateContainer(): +class HyperoptStateContainer: """ Singleton class to track state of hyperopt""" state: HyperoptState = HyperoptState.OPTIMIZE @@ -44,7 +44,7 @@ class HyperoptStateContainer(): cls.state = value -class HyperoptTools(): +class HyperoptTools: @staticmethod def get_strategy_filename(config: Config, strategy_name: str) -> Optional[Path]: diff --git a/freqtrade/optimize/lookahead_analysis.py b/freqtrade/optimize/lookahead_analysis.py new file mode 100755 index 000000000..dcc1088b3 --- /dev/null +++ b/freqtrade/optimize/lookahead_analysis.py @@ -0,0 +1,275 @@ +import logging +import shutil +from copy import deepcopy +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from pandas import DataFrame + +from freqtrade.configuration import TimeRange +from freqtrade.data.history import get_timerange +from freqtrade.exchange import timeframe_to_minutes +from freqtrade.loggers.set_log_levels import (reduce_verbosity_for_bias_tester, + restore_verbosity_for_bias_tester) +from freqtrade.optimize.backtesting import Backtesting + + +logger = logging.getLogger(__name__) + + +class VarHolder: + timerange: TimeRange + data: DataFrame + indicators: Dict[str, DataFrame] + result: DataFrame + compared: DataFrame + from_dt: datetime + to_dt: datetime + compared_dt: datetime + timeframe: str + + +class Analysis: + def __init__(self) -> None: + self.total_signals = 0 + self.false_entry_signals = 0 + self.false_exit_signals = 0 + self.false_indicators: List[str] = [] + self.has_bias = False + + +class LookaheadAnalysis: + + def __init__(self, config: Dict[str, Any], strategy_obj: Dict): + self.failed_bias_check = True + self.full_varHolder = VarHolder() + + self.entry_varHolders: List[VarHolder] = [] + self.exit_varHolders: List[VarHolder] = [] + self.exchange: Optional[Any] = None + + # pull variables the scope of the lookahead_analysis-instance + self.local_config = deepcopy(config) + self.local_config['strategy'] = strategy_obj['name'] + self.current_analysis = Analysis() + self.minimum_trade_amount = config['minimum_trade_amount'] + self.targeted_trade_amount = config['targeted_trade_amount'] + self.strategy_obj = strategy_obj + + @staticmethod + def dt_to_timestamp(dt: datetime): + timestamp = int(dt.replace(tzinfo=timezone.utc).timestamp()) + return timestamp + + @staticmethod + def get_result(backtesting: Backtesting, processed: DataFrame): + min_date, max_date = get_timerange(processed) + + result = backtesting.backtest( + processed=deepcopy(processed), + start_date=min_date, + end_date=max_date + ) + return result + + @staticmethod + def report_signal(result: dict, column_name: str, checked_timestamp: datetime): + df = result['results'] + row_count = df[column_name].shape[0] + + if row_count == 0: + return False + else: + + df_cut = df[(df[column_name] == checked_timestamp)] + if df_cut[column_name].shape[0] == 0: + return False + else: + return True + return False + + # analyzes two data frames with processed indicators and shows differences between them. + def analyze_indicators(self, full_vars: VarHolder, cut_vars: VarHolder, current_pair: str): + # extract dataframes + cut_df: DataFrame = cut_vars.indicators[current_pair] + full_df: DataFrame = full_vars.indicators[current_pair] + + # cut longer dataframe to length of the shorter + full_df_cut = full_df[ + (full_df.date == cut_vars.compared_dt) + ].reset_index(drop=True) + cut_df_cut = cut_df[ + (cut_df.date == cut_vars.compared_dt) + ].reset_index(drop=True) + + # check if dataframes are not empty + if full_df_cut.shape[0] != 0 and cut_df_cut.shape[0] != 0: + + # compare dataframes + compare_df = full_df_cut.compare(cut_df_cut) + + if compare_df.shape[0] > 0: + for col_name, values in compare_df.items(): + col_idx = compare_df.columns.get_loc(col_name) + compare_df_row = compare_df.iloc[0] + # compare_df now comprises tuples with [1] having either 'self' or 'other' + if 'other' in col_name[1]: + continue + self_value = compare_df_row[col_idx] + other_value = compare_df_row[col_idx + 1] + + # output differences + if self_value != other_value: + + if not self.current_analysis.false_indicators.__contains__(col_name[0]): + self.current_analysis.false_indicators.append(col_name[0]) + logger.info(f"=> found look ahead bias in indicator " + f"{col_name[0]}. " + f"{str(self_value)} != {str(other_value)}") + + def prepare_data(self, varholder: VarHolder, pairs_to_load: List[DataFrame]): + + if 'freqai' in self.local_config and 'identifier' in self.local_config['freqai']: + # purge previous data if the freqai model is defined + # (to be sure nothing is carried over from older backtests) + path_to_current_identifier = ( + Path(f"{self.local_config['user_data_dir']}/models/" + f"{self.local_config['freqai']['identifier']}").resolve()) + # remove folder and its contents + if Path.exists(path_to_current_identifier): + shutil.rmtree(path_to_current_identifier) + + prepare_data_config = deepcopy(self.local_config) + prepare_data_config['timerange'] = (str(self.dt_to_timestamp(varholder.from_dt)) + "-" + + str(self.dt_to_timestamp(varholder.to_dt))) + prepare_data_config['exchange']['pair_whitelist'] = pairs_to_load + + backtesting = Backtesting(prepare_data_config, self.exchange) + self.exchange = backtesting.exchange + backtesting._set_strategy(backtesting.strategylist[0]) + + varholder.data, varholder.timerange = backtesting.load_bt_data() + backtesting.load_bt_data_detail() + varholder.timeframe = backtesting.timeframe + + varholder.indicators = backtesting.strategy.advise_all_indicators(varholder.data) + varholder.result = self.get_result(backtesting, varholder.indicators) + + def fill_full_varholder(self): + self.full_varHolder = VarHolder() + + # define datetime in human-readable format + parsed_timerange = TimeRange.parse_timerange(self.local_config['timerange']) + + if parsed_timerange.startdt is None: + self.full_varHolder.from_dt = datetime.fromtimestamp(0, tz=timezone.utc) + else: + self.full_varHolder.from_dt = parsed_timerange.startdt + + if parsed_timerange.stopdt is None: + self.full_varHolder.to_dt = datetime.utcnow() + else: + self.full_varHolder.to_dt = parsed_timerange.stopdt + + self.prepare_data(self.full_varHolder, self.local_config['pairs']) + + def fill_entry_and_exit_varHolders(self, result_row): + # entry_varHolder + entry_varHolder = VarHolder() + self.entry_varHolders.append(entry_varHolder) + entry_varHolder.from_dt = self.full_varHolder.from_dt + entry_varHolder.compared_dt = result_row['open_date'] + # to_dt needs +1 candle since it won't buy on the last candle + entry_varHolder.to_dt = ( + result_row['open_date'] + + timedelta(minutes=timeframe_to_minutes(self.full_varHolder.timeframe))) + self.prepare_data(entry_varHolder, [result_row['pair']]) + + # exit_varHolder + exit_varHolder = VarHolder() + self.exit_varHolders.append(exit_varHolder) + # to_dt needs +1 candle since it will always exit/force-exit trades on the last candle + exit_varHolder.from_dt = self.full_varHolder.from_dt + exit_varHolder.to_dt = ( + result_row['close_date'] + + timedelta(minutes=timeframe_to_minutes(self.full_varHolder.timeframe))) + exit_varHolder.compared_dt = result_row['close_date'] + self.prepare_data(exit_varHolder, [result_row['pair']]) + + # now we analyze a full trade of full_varholder and look for analyze its bias + def analyze_row(self, idx, result_row): + # if force-sold, ignore this signal since here it will unconditionally exit. + if result_row.close_date == self.dt_to_timestamp(self.full_varHolder.to_dt): + return + + # keep track of how many signals are processed at total + self.current_analysis.total_signals += 1 + + # fill entry_varHolder and exit_varHolder + self.fill_entry_and_exit_varHolders(result_row) + + # register if buy signal is broken + if not self.report_signal( + self.entry_varHolders[idx].result, + "open_date", + self.entry_varHolders[idx].compared_dt): + self.current_analysis.false_entry_signals += 1 + + # register if buy or sell signal is broken + if not self.report_signal( + self.exit_varHolders[idx].result, + "close_date", + self.exit_varHolders[idx].compared_dt): + self.current_analysis.false_exit_signals += 1 + + # check if the indicators themselves contain biased data + self.analyze_indicators(self.full_varHolder, self.entry_varHolders[idx], result_row['pair']) + self.analyze_indicators(self.full_varHolder, self.exit_varHolders[idx], result_row['pair']) + + def start(self) -> None: + + # first make a single backtest + self.fill_full_varholder() + + reduce_verbosity_for_bias_tester() + + # check if requirements have been met of full_varholder + found_signals: int = self.full_varHolder.result['results'].shape[0] + 1 + if found_signals >= self.targeted_trade_amount: + logger.info(f"Found {found_signals} trades, " + f"calculating {self.targeted_trade_amount} trades.") + elif self.targeted_trade_amount >= found_signals >= self.minimum_trade_amount: + logger.info(f"Only found {found_signals} trades. Calculating all available trades.") + else: + logger.info(f"found {found_signals} trades " + f"which is less than minimum_trade_amount {self.minimum_trade_amount}. " + f"Cancelling this backtest lookahead bias test.") + return + + # now we loop through all signals + # starting from the same datetime to avoid miss-reports of bias + for idx, result_row in self.full_varHolder.result['results'].iterrows(): + if self.current_analysis.total_signals == self.targeted_trade_amount: + break + self.analyze_row(idx, result_row) + + # Restore verbosity, so it's not too quiet for the next strategy + restore_verbosity_for_bias_tester() + # check and report signals + if self.current_analysis.total_signals < self.local_config['minimum_trade_amount']: + logger.info(f" -> {self.local_config['strategy']} : too few trades. " + f"We only found {self.current_analysis.total_signals} trades. " + f"Hint: Extend the timerange " + f"to get at least {self.local_config['minimum_trade_amount']} " + f"or lower the value of minimum_trade_amount.") + self.failed_bias_check = True + elif (self.current_analysis.false_entry_signals > 0 or + self.current_analysis.false_exit_signals > 0 or + len(self.current_analysis.false_indicators) > 0): + logger.info(f" => {self.local_config['strategy']} : bias detected!") + self.current_analysis.has_bias = True + self.failed_bias_check = False + else: + logger.info(self.local_config['strategy'] + ": no bias detected") + self.failed_bias_check = False diff --git a/freqtrade/optimize/lookahead_analysis_helpers.py b/freqtrade/optimize/lookahead_analysis_helpers.py new file mode 100644 index 000000000..702eee774 --- /dev/null +++ b/freqtrade/optimize/lookahead_analysis_helpers.py @@ -0,0 +1,202 @@ +import logging +import time +from pathlib import Path +from typing import Any, Dict, List + +import pandas as pd + +from freqtrade.constants import Config +from freqtrade.exceptions import OperationalException +from freqtrade.optimize.lookahead_analysis import LookaheadAnalysis +from freqtrade.resolvers import StrategyResolver + + +logger = logging.getLogger(__name__) + + +class LookaheadAnalysisSubFunctions: + + @staticmethod + def text_table_lookahead_analysis_instances( + config: Dict[str, Any], + lookahead_instances: List[LookaheadAnalysis]): + headers = ['filename', 'strategy', 'has_bias', 'total_signals', + 'biased_entry_signals', 'biased_exit_signals', 'biased_indicators'] + data = [] + for inst in lookahead_instances: + if config['minimum_trade_amount'] > inst.current_analysis.total_signals: + data.append( + [ + inst.strategy_obj['location'].parts[-1], + inst.strategy_obj['name'], + "too few trades caught " + f"({inst.current_analysis.total_signals}/{config['minimum_trade_amount']})." + f"Test failed." + ] + ) + elif inst.failed_bias_check: + data.append( + [ + inst.strategy_obj['location'].parts[-1], + inst.strategy_obj['name'], + 'error while checking' + ] + ) + else: + data.append( + [ + inst.strategy_obj['location'].parts[-1], + inst.strategy_obj['name'], + inst.current_analysis.has_bias, + inst.current_analysis.total_signals, + inst.current_analysis.false_entry_signals, + inst.current_analysis.false_exit_signals, + ", ".join(inst.current_analysis.false_indicators) + ] + ) + from tabulate import tabulate + table = tabulate(data, headers=headers, tablefmt="orgtbl") + print(table) + return table, headers, data + + @staticmethod + def export_to_csv(config: Dict[str, Any], lookahead_analysis: List[LookaheadAnalysis]): + def add_or_update_row(df, row_data): + if ( + (df['filename'] == row_data['filename']) & + (df['strategy'] == row_data['strategy']) + ).any(): + # Update existing row + pd_series = pd.DataFrame([row_data]) + df.loc[ + (df['filename'] == row_data['filename']) & + (df['strategy'] == row_data['strategy']) + ] = pd_series + else: + # Add new row + df = pd.concat([df, pd.DataFrame([row_data], columns=df.columns)]) + + return df + + if Path(config['lookahead_analysis_exportfilename']).exists(): + # Read CSV file into a pandas dataframe + csv_df = pd.read_csv(config['lookahead_analysis_exportfilename']) + else: + # Create a new empty DataFrame with the desired column names and set the index + csv_df = pd.DataFrame(columns=[ + 'filename', 'strategy', 'has_bias', 'total_signals', + 'biased_entry_signals', 'biased_exit_signals', 'biased_indicators' + ], + index=None) + + for inst in lookahead_analysis: + # only update if + if (inst.current_analysis.total_signals > config['minimum_trade_amount'] + and inst.failed_bias_check is not True): + new_row_data = {'filename': inst.strategy_obj['location'].parts[-1], + 'strategy': inst.strategy_obj['name'], + 'has_bias': inst.current_analysis.has_bias, + 'total_signals': + int(inst.current_analysis.total_signals), + 'biased_entry_signals': + int(inst.current_analysis.false_entry_signals), + 'biased_exit_signals': + int(inst.current_analysis.false_exit_signals), + 'biased_indicators': + ",".join(inst.current_analysis.false_indicators)} + csv_df = add_or_update_row(csv_df, new_row_data) + + # Fill NaN values with a default value (e.g., 0) + csv_df['total_signals'] = csv_df['total_signals'].fillna(0) + csv_df['biased_entry_signals'] = csv_df['biased_entry_signals'].fillna(0) + csv_df['biased_exit_signals'] = csv_df['biased_exit_signals'].fillna(0) + + # Convert columns to integers + csv_df['total_signals'] = csv_df['total_signals'].astype(int) + csv_df['biased_entry_signals'] = csv_df['biased_entry_signals'].astype(int) + csv_df['biased_exit_signals'] = csv_df['biased_exit_signals'].astype(int) + + logger.info(f"saving {config['lookahead_analysis_exportfilename']}") + csv_df.to_csv(config['lookahead_analysis_exportfilename'], index=False) + + @staticmethod + def calculate_config_overrides(config: Config): + if config['targeted_trade_amount'] < config['minimum_trade_amount']: + # this combo doesn't make any sense. + raise OperationalException( + "Targeted trade amount can't be smaller than minimum trade amount." + ) + if len(config['pairs']) > config['max_open_trades']: + logger.info('Max_open_trades were less than amount of pairs. ' + 'Set max_open_trades to amount of pairs just to avoid false positives.') + config['max_open_trades'] = len(config['pairs']) + + min_dry_run_wallet = 1000000000 + if config['dry_run_wallet'] < min_dry_run_wallet: + logger.info('Dry run wallet was not set to 1 billion, pushing it up there ' + 'just to avoid false positives') + config['dry_run_wallet'] = min_dry_run_wallet + + # enforce cache to be 'none', shift it to 'none' if not already + # (since the default value is 'day') + if config.get('backtest_cache') is None: + config['backtest_cache'] = 'none' + elif config['backtest_cache'] != 'none': + logger.info(f"backtest_cache = " + f"{config['backtest_cache']} detected. " + f"Inside lookahead-analysis it is enforced to be 'none'. " + f"Changed it to 'none'") + config['backtest_cache'] = 'none' + return config + + @staticmethod + def initialize_single_lookahead_analysis(config: Config, strategy_obj: Dict[str, Any]): + + logger.info(f"Bias test of {Path(strategy_obj['location']).name} started.") + start = time.perf_counter() + current_instance = LookaheadAnalysis(config, strategy_obj) + current_instance.start() + elapsed = time.perf_counter() - start + logger.info(f"Checking look ahead bias via backtests " + f"of {Path(strategy_obj['location']).name} " + f"took {elapsed:.0f} seconds.") + return current_instance + + @staticmethod + def start(config: Config): + config = LookaheadAnalysisSubFunctions.calculate_config_overrides(config) + + strategy_objs = StrategyResolver.search_all_objects( + config, enum_failed=False, recursive=config.get('recursive_strategy_search', False)) + + lookaheadAnalysis_instances = [] + + # unify --strategy and --strategy_list to one list + if not (strategy_list := config.get('strategy_list', [])): + if config.get('strategy') is None: + raise OperationalException( + "No Strategy specified. Please specify a strategy via --strategy or " + "--strategy_list" + ) + strategy_list = [config['strategy']] + + # check if strategies can be properly loaded, only check them if they can be. + for strat in strategy_list: + for strategy_obj in strategy_objs: + if strategy_obj['name'] == strat and strategy_obj not in strategy_list: + lookaheadAnalysis_instances.append( + LookaheadAnalysisSubFunctions.initialize_single_lookahead_analysis( + config, strategy_obj)) + break + + # report the results + if lookaheadAnalysis_instances: + LookaheadAnalysisSubFunctions.text_table_lookahead_analysis_instances( + config, lookaheadAnalysis_instances) + if config.get('lookahead_analysis_exportfilename') is not None: + LookaheadAnalysisSubFunctions.export_to_csv(config, lookaheadAnalysis_instances) + else: + logger.error("There were no strategies specified neither through " + "--strategy nor through " + "--strategy_list " + "or timeframe was not specified.") diff --git a/freqtrade/optimize/optimize_reports.py b/freqtrade/optimize/optimize_reports.py deleted file mode 100644 index b4925770d..000000000 --- a/freqtrade/optimize/optimize_reports.py +++ /dev/null @@ -1,939 +0,0 @@ -import logging -from copy import deepcopy -from datetime import datetime, timedelta, timezone -from pathlib import Path -from typing import Any, Dict, List, Union - -from pandas import DataFrame, to_datetime -from tabulate import tabulate - -from freqtrade.constants import (DATETIME_PRINT_FORMAT, LAST_BT_RESULT_FN, UNLIMITED_STAKE_AMOUNT, - Config, IntOrInf) -from freqtrade.data.metrics import (calculate_cagr, calculate_calmar, calculate_csum, - calculate_expectancy, calculate_market_change, - calculate_max_drawdown, calculate_sharpe, calculate_sortino) -from freqtrade.misc import decimals_per_coin, file_dump_joblib, file_dump_json, round_coin_value -from freqtrade.optimize.backtest_caching import get_backtest_metadata_filename - - -logger = logging.getLogger(__name__) - - -def store_backtest_stats( - recordfilename: Path, stats: Dict[str, DataFrame], dtappendix: str) -> None: - """ - Stores backtest results - :param recordfilename: Path object, which can either be a filename or a directory. - Filenames will be appended with a timestamp right before the suffix - while for directories, /backtest-result-.json will be used as filename - :param stats: Dataframe containing the backtesting statistics - :param dtappendix: Datetime to use for the filename - """ - if recordfilename.is_dir(): - filename = (recordfilename / f'backtest-result-{dtappendix}.json') - else: - filename = Path.joinpath( - recordfilename.parent, f'{recordfilename.stem}-{dtappendix}' - ).with_suffix(recordfilename.suffix) - - # Store metadata separately. - file_dump_json(get_backtest_metadata_filename(filename), stats['metadata']) - del stats['metadata'] - - file_dump_json(filename, stats) - - latest_filename = Path.joinpath(filename.parent, LAST_BT_RESULT_FN) - file_dump_json(latest_filename, {'latest_backtest': str(filename.name)}) - - -def store_backtest_signal_candles( - recordfilename: Path, candles: Dict[str, Dict], dtappendix: str) -> Path: - """ - Stores backtest trade signal candles - :param recordfilename: Path object, which can either be a filename or a directory. - Filenames will be appended with a timestamp right before the suffix - while for directories, /backtest-result-_signals.pkl will be used - as filename - :param stats: Dict containing the backtesting signal candles - :param dtappendix: Datetime to use for the filename - """ - if recordfilename.is_dir(): - filename = (recordfilename / f'backtest-result-{dtappendix}_signals.pkl') - else: - filename = Path.joinpath( - recordfilename.parent, f'{recordfilename.stem}-{dtappendix}_signals.pkl' - ) - - file_dump_joblib(filename, candles) - - return filename - - -def _get_line_floatfmt(stake_currency: str) -> List[str]: - """ - Generate floatformat (goes in line with _generate_result_line()) - """ - return ['s', 'd', '.2f', '.2f', f'.{decimals_per_coin(stake_currency)}f', - '.2f', 'd', 's', 's'] - - -def _get_line_header(first_column: str, stake_currency: str, - direction: str = 'Entries') -> List[str]: - """ - Generate header lines (goes in line with _generate_result_line()) - """ - return [first_column, direction, 'Avg Profit %', 'Cum Profit %', - f'Tot Profit {stake_currency}', 'Tot Profit %', 'Avg Duration', - 'Win Draw Loss Win%'] - - -def generate_wins_draws_losses(wins, draws, losses): - if wins > 0 and losses == 0: - wl_ratio = '100' - elif wins == 0: - wl_ratio = '0' - else: - wl_ratio = f'{100.0 / (wins + draws + losses) * wins:.1f}' if losses > 0 else '100' - return f'{wins:>4} {draws:>4} {losses:>4} {wl_ratio:>4}' - - -def _generate_result_line(result: DataFrame, starting_balance: int, first_column: str) -> Dict: - """ - Generate one result dict, with "first_column" as key. - """ - profit_sum = result['profit_ratio'].sum() - # (end-capital - starting capital) / starting capital - profit_total = result['profit_abs'].sum() / starting_balance - - return { - 'key': first_column, - 'trades': len(result), - 'profit_mean': result['profit_ratio'].mean() if len(result) > 0 else 0.0, - 'profit_mean_pct': result['profit_ratio'].mean() * 100.0 if len(result) > 0 else 0.0, - 'profit_sum': profit_sum, - 'profit_sum_pct': round(profit_sum * 100.0, 2), - 'profit_total_abs': result['profit_abs'].sum(), - 'profit_total': profit_total, - 'profit_total_pct': round(profit_total * 100.0, 2), - 'duration_avg': str(timedelta( - minutes=round(result['trade_duration'].mean())) - ) if not result.empty else '0:00', - # 'duration_max': str(timedelta( - # minutes=round(result['trade_duration'].max())) - # ) if not result.empty else '0:00', - # 'duration_min': str(timedelta( - # minutes=round(result['trade_duration'].min())) - # ) if not result.empty else '0:00', - 'wins': len(result[result['profit_abs'] > 0]), - 'draws': len(result[result['profit_abs'] == 0]), - 'losses': len(result[result['profit_abs'] < 0]), - } - - -def generate_pair_metrics(pairlist: List[str], stake_currency: str, starting_balance: int, - results: DataFrame, skip_nan: bool = False) -> List[Dict]: - """ - Generates and returns a list for the given backtest data and the results dataframe - :param pairlist: Pairlist used - :param stake_currency: stake-currency - used to correctly name headers - :param starting_balance: Starting balance - :param results: Dataframe containing the backtest results - :param skip_nan: Print "left open" open trades - :return: List of Dicts containing the metrics per pair - """ - - tabular_data = [] - - for pair in pairlist: - result = results[results['pair'] == pair] - if skip_nan and result['profit_abs'].isnull().all(): - continue - - tabular_data.append(_generate_result_line(result, starting_balance, pair)) - - # Sort by total profit %: - tabular_data = sorted(tabular_data, key=lambda k: k['profit_total_abs'], reverse=True) - - # Append Total - tabular_data.append(_generate_result_line(results, starting_balance, 'TOTAL')) - return tabular_data - - -def generate_tag_metrics(tag_type: str, - starting_balance: int, - results: DataFrame, - skip_nan: bool = False) -> List[Dict]: - """ - Generates and returns a list of metrics for the given tag trades and the results dataframe - :param starting_balance: Starting balance - :param results: Dataframe containing the backtest results - :param skip_nan: Print "left open" open trades - :return: List of Dicts containing the metrics per pair - """ - - tabular_data = [] - - if tag_type in results.columns: - 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 - - tabular_data.append(_generate_result_line(result, starting_balance, tag)) - - # Sort by total profit %: - tabular_data = sorted(tabular_data, key=lambda k: k['profit_total_abs'], reverse=True) - - # Append Total - tabular_data.append(_generate_result_line(results, starting_balance, 'TOTAL')) - return tabular_data - else: - return [] - - -def generate_exit_reason_stats(max_open_trades: IntOrInf, results: DataFrame) -> List[Dict]: - """ - Generate small table outlining Backtest results - :param max_open_trades: Max_open_trades parameter - :param results: Dataframe containing the backtest result for one strategy - :return: List of Dicts containing the metrics per Sell reason - """ - tabular_data = [] - - for reason, count in results['exit_reason'].value_counts().items(): - result = results.loc[results['exit_reason'] == reason] - - profit_mean = result['profit_ratio'].mean() - profit_sum = result['profit_ratio'].sum() - profit_total = profit_sum / max_open_trades - - tabular_data.append( - { - 'exit_reason': reason, - 'trades': count, - 'wins': len(result[result['profit_abs'] > 0]), - 'draws': len(result[result['profit_abs'] == 0]), - 'losses': len(result[result['profit_abs'] < 0]), - 'profit_mean': profit_mean, - 'profit_mean_pct': round(profit_mean * 100, 2), - 'profit_sum': profit_sum, - 'profit_sum_pct': round(profit_sum * 100, 2), - 'profit_total_abs': result['profit_abs'].sum(), - 'profit_total': profit_total, - 'profit_total_pct': round(profit_total * 100, 2), - } - ) - return tabular_data - - -def generate_strategy_comparison(bt_stats: Dict) -> List[Dict]: - """ - Generate summary per strategy - :param bt_stats: Dict of containing results for all strategies - :return: List of Dicts containing the metrics per Strategy - """ - - tabular_data = [] - for strategy, result in bt_stats.items(): - tabular_data.append(deepcopy(result['results_per_pair'][-1])) - # Update "key" to strategy (results_per_pair has it as "Total"). - tabular_data[-1]['key'] = strategy - tabular_data[-1]['max_drawdown_account'] = result['max_drawdown_account'] - tabular_data[-1]['max_drawdown_abs'] = round_coin_value( - result['max_drawdown_abs'], result['stake_currency'], False) - return tabular_data - - -def generate_edge_table(results: dict) -> str: - floatfmt = ('s', '.10g', '.2f', '.2f', '.2f', '.2f', 'd', 'd', 'd') - tabular_data = [] - headers = ['Pair', 'Stoploss', 'Win Rate', 'Risk Reward Ratio', - 'Required Risk Reward', 'Expectancy', 'Total Number of Trades', - 'Average Duration (min)'] - - for result in results.items(): - if result[1].nb_trades > 0: - tabular_data.append([ - result[0], - result[1].stoploss, - result[1].winrate, - result[1].risk_reward_ratio, - result[1].required_risk_reward, - result[1].expectancy, - result[1].nb_trades, - round(result[1].avg_trade_duration) - ]) - - # Ignore type as floatfmt does allow tuples but mypy does not know that - return tabulate(tabular_data, headers=headers, - floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") - - -def _get_resample_from_period(period: str) -> str: - if period == 'day': - return '1d' - if period == 'week': - return '1w' - if period == 'month': - return '1M' - raise ValueError(f"Period {period} is not supported.") - - -def generate_periodic_breakdown_stats(trade_list: List, period: str) -> List[Dict[str, Any]]: - results = DataFrame.from_records(trade_list) - if len(results) == 0: - return [] - results['close_date'] = to_datetime(results['close_date'], utc=True) - resample_period = _get_resample_from_period(period) - resampled = results.resample(resample_period, on='close_date') - stats = [] - for name, day in resampled: - profit_abs = day['profit_abs'].sum().round(10) - wins = sum(day['profit_abs'] > 0) - draws = sum(day['profit_abs'] == 0) - loses = sum(day['profit_abs'] < 0) - stats.append( - { - 'date': name.strftime('%d/%m/%Y'), - 'profit_abs': profit_abs, - 'wins': wins, - 'draws': draws, - 'loses': loses - } - ) - return stats - - -def generate_trading_stats(results: DataFrame) -> Dict[str, Any]: - """ Generate overall trade statistics """ - if len(results) == 0: - return { - 'wins': 0, - 'losses': 0, - 'draws': 0, - 'holding_avg': timedelta(), - 'winner_holding_avg': timedelta(), - 'loser_holding_avg': timedelta(), - } - - winning_trades = results.loc[results['profit_ratio'] > 0] - draw_trades = results.loc[results['profit_ratio'] == 0] - losing_trades = results.loc[results['profit_ratio'] < 0] - - holding_avg = (timedelta(minutes=round(results['trade_duration'].mean())) - if not results.empty else timedelta()) - winner_holding_avg = (timedelta(minutes=round(winning_trades['trade_duration'].mean())) - if not winning_trades.empty else timedelta()) - loser_holding_avg = (timedelta(minutes=round(losing_trades['trade_duration'].mean())) - if not losing_trades.empty else timedelta()) - - return { - 'wins': len(winning_trades), - 'losses': len(losing_trades), - 'draws': len(draw_trades), - 'holding_avg': holding_avg, - 'holding_avg_s': holding_avg.total_seconds(), - 'winner_holding_avg': winner_holding_avg, - 'winner_holding_avg_s': winner_holding_avg.total_seconds(), - 'loser_holding_avg': loser_holding_avg, - 'loser_holding_avg_s': loser_holding_avg.total_seconds(), - } - - -def generate_daily_stats(results: DataFrame) -> Dict[str, Any]: - """ Generate daily statistics """ - if len(results) == 0: - return { - 'backtest_best_day': 0, - 'backtest_worst_day': 0, - 'backtest_best_day_abs': 0, - 'backtest_worst_day_abs': 0, - 'winning_days': 0, - 'draw_days': 0, - 'losing_days': 0, - 'daily_profit_list': [], - } - daily_profit_rel = results.resample('1d', on='close_date')['profit_ratio'].sum() - daily_profit = results.resample('1d', on='close_date')['profit_abs'].sum().round(10) - worst_rel = min(daily_profit_rel) - best_rel = max(daily_profit_rel) - worst = min(daily_profit) - best = max(daily_profit) - 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.items()] - - return { - 'backtest_best_day': best_rel, - 'backtest_worst_day': worst_rel, - 'backtest_best_day_abs': best, - 'backtest_worst_day_abs': worst, - 'winning_days': winning_days, - 'draw_days': draw_days, - 'losing_days': losing_days, - 'daily_profit': daily_profit_list, - } - - -def generate_strategy_stats(pairlist: List[str], - strategy: str, - content: Dict[str, Any], - min_date: datetime, max_date: datetime, - market_change: float - ) -> Dict[str, Any]: - """ - :param pairlist: List of pairs to backtest - :param strategy: Strategy name - :param content: Backtest result data in the format: - {'results: results, 'config: config}}. - :param min_date: Backtest start date - :param max_date: Backtest end date - :param market_change: float indicating the market change - :return: Dictionary containing results per strategy and a strategy summary. - """ - results: Dict[str, DataFrame] = content['results'] - if not isinstance(results, DataFrame): - return {} - config = content['config'] - max_open_trades = min(config['max_open_trades'], len(pairlist)) - start_balance = config['dry_run_wallet'] - stake_currency = config['stake_currency'] - - pair_results = generate_pair_metrics(pairlist, stake_currency=stake_currency, - starting_balance=start_balance, - results=results, skip_nan=False) - - enter_tag_results = generate_tag_metrics("enter_tag", starting_balance=start_balance, - results=results, skip_nan=False) - - exit_reason_stats = generate_exit_reason_stats(max_open_trades=max_open_trades, - results=results) - left_open_results = generate_pair_metrics( - pairlist, stake_currency=stake_currency, starting_balance=start_balance, - results=results.loc[results['exit_reason'] == 'force_exit'], skip_nan=True) - - daily_stats = generate_daily_stats(results) - trade_stats = generate_trading_stats(results) - best_pair = max([pair for pair in pair_results if pair['key'] != 'TOTAL'], - key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None - worst_pair = min([pair for pair in pair_results if pair['key'] != 'TOTAL'], - key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None - winning_profit = results.loc[results['profit_abs'] > 0, 'profit_abs'].sum() - losing_profit = results.loc[results['profit_abs'] < 0, 'profit_abs'].sum() - profit_factor = winning_profit / abs(losing_profit) if losing_profit else 0.0 - - backtest_days = (max_date - min_date).days or 1 - strat_stats = { - 'trades': results.to_dict(orient='records'), - 'locks': [lock.to_json() for lock in content['locks']], - 'best_pair': best_pair, - 'worst_pair': worst_pair, - 'results_per_pair': pair_results, - 'results_per_enter_tag': enter_tag_results, - 'exit_reason_summary': exit_reason_stats, - 'left_open_trades': left_open_results, - # 'days_breakdown_stats': days_breakdown_stats, - - 'total_trades': len(results), - 'trade_count_long': len(results.loc[~results['is_short']]), - 'trade_count_short': len(results.loc[results['is_short']]), - 'total_volume': float(results['stake_amount'].sum()), - 'avg_stake_amount': results['stake_amount'].mean() if len(results) > 0 else 0, - 'profit_mean': results['profit_ratio'].mean() if len(results) > 0 else 0, - 'profit_median': results['profit_ratio'].median() if len(results) > 0 else 0, - 'profit_total': results['profit_abs'].sum() / start_balance, - 'profit_total_long': results.loc[~results['is_short'], 'profit_abs'].sum() / start_balance, - 'profit_total_short': results.loc[results['is_short'], 'profit_abs'].sum() / start_balance, - 'profit_total_abs': results['profit_abs'].sum(), - 'profit_total_long_abs': results.loc[~results['is_short'], 'profit_abs'].sum(), - 'profit_total_short_abs': results.loc[results['is_short'], 'profit_abs'].sum(), - 'cagr': calculate_cagr(backtest_days, start_balance, content['final_balance']), - 'expectancy': calculate_expectancy(results), - 'sortino': calculate_sortino(results, min_date, max_date, start_balance), - 'sharpe': calculate_sharpe(results, min_date, max_date, start_balance), - 'calmar': calculate_calmar(results, min_date, max_date, start_balance), - 'profit_factor': profit_factor, - 'backtest_start': min_date.strftime(DATETIME_PRINT_FORMAT), - 'backtest_start_ts': int(min_date.timestamp() * 1000), - 'backtest_end': max_date.strftime(DATETIME_PRINT_FORMAT), - 'backtest_end_ts': int(max_date.timestamp() * 1000), - 'backtest_days': backtest_days, - - 'backtest_run_start_ts': content['backtest_start_time'], - 'backtest_run_end_ts': content['backtest_end_time'], - - 'trades_per_day': round(len(results) / backtest_days, 2), - 'market_change': market_change, - 'pairlist': pairlist, - 'stake_amount': config['stake_amount'], - 'stake_currency': config['stake_currency'], - 'stake_currency_decimals': decimals_per_coin(config['stake_currency']), - 'starting_balance': start_balance, - 'dry_run_wallet': start_balance, - 'final_balance': content['final_balance'], - 'rejected_signals': content['rejected_signals'], - 'timedout_entry_orders': content['timedout_entry_orders'], - 'timedout_exit_orders': content['timedout_exit_orders'], - 'canceled_trade_entries': content['canceled_trade_entries'], - 'canceled_entry_orders': content['canceled_entry_orders'], - 'replaced_entry_orders': content['replaced_entry_orders'], - 'max_open_trades': max_open_trades, - 'max_open_trades_setting': (config['max_open_trades'] - if config['max_open_trades'] != float('inf') else -1), - 'timeframe': config['timeframe'], - 'timeframe_detail': config.get('timeframe_detail', ''), - 'timerange': config.get('timerange', ''), - 'enable_protections': config.get('enable_protections', False), - 'strategy_name': strategy, - # Parameters relevant for backtesting - 'stoploss': config['stoploss'], - 'trailing_stop': config.get('trailing_stop', False), - 'trailing_stop_positive': config.get('trailing_stop_positive'), - 'trailing_stop_positive_offset': config.get('trailing_stop_positive_offset', 0.0), - 'trailing_only_offset_is_reached': config.get('trailing_only_offset_is_reached', False), - 'use_custom_stoploss': config.get('use_custom_stoploss', False), - 'minimal_roi': config['minimal_roi'], - 'use_exit_signal': config['use_exit_signal'], - 'exit_profit_only': config['exit_profit_only'], - 'exit_profit_offset': config['exit_profit_offset'], - 'ignore_roi_if_entry_signal': config['ignore_roi_if_entry_signal'], - **daily_stats, - **trade_stats - } - - try: - max_drawdown_legacy, _, _, _, _, _ = calculate_max_drawdown( - results, value_col='profit_ratio') - (drawdown_abs, drawdown_start, drawdown_end, high_val, low_val, - max_drawdown) = calculate_max_drawdown( - results, value_col='profit_abs', starting_balance=start_balance) - # max_relative_drawdown = Underwater - (_, _, _, _, _, max_relative_drawdown) = calculate_max_drawdown( - results, value_col='profit_abs', starting_balance=start_balance, relative=True) - - strat_stats.update({ - 'max_drawdown': max_drawdown_legacy, # Deprecated - do not use - 'max_drawdown_account': max_drawdown, - 'max_relative_drawdown': max_relative_drawdown, - 'max_drawdown_abs': drawdown_abs, - 'drawdown_start': drawdown_start.strftime(DATETIME_PRINT_FORMAT), - 'drawdown_start_ts': drawdown_start.timestamp() * 1000, - 'drawdown_end': drawdown_end.strftime(DATETIME_PRINT_FORMAT), - 'drawdown_end_ts': drawdown_end.timestamp() * 1000, - - 'max_drawdown_low': low_val, - 'max_drawdown_high': high_val, - }) - - csum_min, csum_max = calculate_csum(results, start_balance) - strat_stats.update({ - 'csum_min': csum_min, - 'csum_max': csum_max - }) - - except ValueError: - strat_stats.update({ - 'max_drawdown': 0.0, - 'max_drawdown_account': 0.0, - 'max_relative_drawdown': 0.0, - 'max_drawdown_abs': 0.0, - 'max_drawdown_low': 0.0, - 'max_drawdown_high': 0.0, - 'drawdown_start': datetime(1970, 1, 1, tzinfo=timezone.utc), - 'drawdown_start_ts': 0, - 'drawdown_end': datetime(1970, 1, 1, tzinfo=timezone.utc), - 'drawdown_end_ts': 0, - 'csum_min': 0, - 'csum_max': 0 - }) - - return strat_stats - - -def generate_backtest_stats(btdata: Dict[str, DataFrame], - all_results: Dict[str, Dict[str, Union[DataFrame, Dict]]], - min_date: datetime, max_date: datetime - ) -> Dict[str, Any]: - """ - :param btdata: Backtest data - :param all_results: backtest result - dictionary in the form: - { Strategy: {'results: results, 'config: config}}. - :param min_date: Backtest start date - :param max_date: Backtest end date - :return: Dictionary containing results per strategy and a strategy summary. - """ - result: Dict[str, Any] = { - 'metadata': {}, - 'strategy': {}, - 'strategy_comparison': [], - } - market_change = calculate_market_change(btdata, 'close') - metadata = {} - pairlist = list(btdata.keys()) - for strategy, content in all_results.items(): - strat_stats = generate_strategy_stats(pairlist, strategy, content, - min_date, max_date, market_change=market_change) - metadata[strategy] = { - 'run_id': content['run_id'], - 'backtest_start_time': content['backtest_start_time'], - } - result['strategy'][strategy] = strat_stats - - strategy_results = generate_strategy_comparison(bt_stats=result['strategy']) - - result['metadata'] = metadata - result['strategy_comparison'] = strategy_results - - return result - - -### -# Start output section -### - -def text_table_bt_results(pair_results: List[Dict[str, Any]], stake_currency: str) -> str: - """ - Generates and returns a text table for the given backtest data and the results dataframe - :param pair_results: List of Dictionaries - one entry per pair + final TOTAL row - :param stake_currency: stake-currency - used to correctly name headers - :return: pretty printed table with tabulate as string - """ - - headers = _get_line_header('Pair', stake_currency) - floatfmt = _get_line_floatfmt(stake_currency) - output = [[ - t['key'], t['trades'], t['profit_mean_pct'], t['profit_sum_pct'], t['profit_total_abs'], - t['profit_total_pct'], t['duration_avg'], - generate_wins_draws_losses(t['wins'], t['draws'], t['losses']) - ] for t in pair_results] - # Ignore type as floatfmt does allow tuples but mypy does not know that - return tabulate(output, headers=headers, - floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") - - -def text_table_exit_reason(exit_reason_stats: List[Dict[str, Any]], stake_currency: str) -> str: - """ - Generate small table outlining Backtest results - :param sell_reason_stats: Exit reason metrics - :param stake_currency: Stakecurrency used - :return: pretty printed table with tabulate as string - """ - headers = [ - 'Exit Reason', - 'Exits', - 'Win Draws Loss Win%', - 'Avg Profit %', - 'Cum Profit %', - f'Tot Profit {stake_currency}', - 'Tot Profit %', - ] - - output = [[ - t.get('exit_reason', t.get('sell_reason')), t['trades'], - generate_wins_draws_losses(t['wins'], t['draws'], t['losses']), - t['profit_mean_pct'], t['profit_sum_pct'], - round_coin_value(t['profit_total_abs'], stake_currency, False), - t['profit_total_pct'], - ] for t in exit_reason_stats] - return tabulate(output, headers=headers, tablefmt="orgtbl", stralign="right") - - -def text_table_tags(tag_type: str, tag_results: List[Dict[str, Any]], stake_currency: str) -> str: - """ - Generates and returns a text table for the given backtest data and the results dataframe - :param pair_results: List of Dictionaries - one entry per pair + final TOTAL row - :param stake_currency: stake-currency - used to correctly name headers - :return: pretty printed table with tabulate as string - """ - if (tag_type == "enter_tag"): - headers = _get_line_header("TAG", stake_currency) - else: - headers = _get_line_header("TAG", stake_currency, 'Exits') - floatfmt = _get_line_floatfmt(stake_currency) - output = [ - [ - t['key'] if t['key'] is not None and len( - t['key']) > 0 else "OTHER", - t['trades'], - t['profit_mean_pct'], - t['profit_sum_pct'], - t['profit_total_abs'], - t['profit_total_pct'], - t['duration_avg'], - generate_wins_draws_losses( - t['wins'], - t['draws'], - t['losses'])] for t in tag_results] - # Ignore type as floatfmt does allow tuples but mypy does not know that - return tabulate(output, headers=headers, - floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") - - -def text_table_periodic_breakdown(days_breakdown_stats: List[Dict[str, Any]], - stake_currency: str, period: str) -> str: - """ - Generate small table with Backtest results by days - :param days_breakdown_stats: Days breakdown metrics - :param stake_currency: Stakecurrency used - :return: pretty printed table with tabulate as string - """ - headers = [ - period.capitalize(), - f'Tot Profit {stake_currency}', - 'Wins', - 'Draws', - 'Losses', - ] - output = [[ - d['date'], round_coin_value(d['profit_abs'], stake_currency, False), - d['wins'], d['draws'], d['loses'], - ] for d in days_breakdown_stats] - return tabulate(output, headers=headers, tablefmt="orgtbl", stralign="right") - - -def text_table_strategy(strategy_results, stake_currency: str) -> str: - """ - Generate summary table per strategy - :param strategy_results: Dict of containing results for all strategies - :param stake_currency: stake-currency - used to correctly name headers - :return: pretty printed table with tabulate as string - """ - floatfmt = _get_line_floatfmt(stake_currency) - headers = _get_line_header('Strategy', stake_currency) - # _get_line_header() is also used for per-pair summary. Per-pair drawdown is mostly useless - # therefore we slip this column in only for strategy summary here. - headers.append('Drawdown') - - # Align drawdown string on the center two space separator. - if 'max_drawdown_account' in strategy_results[0]: - drawdown = [f'{t["max_drawdown_account"] * 100:.2f}' for t in strategy_results] - else: - # Support for prior backtest results - drawdown = [f'{t["max_drawdown_per"]:.2f}' for t in strategy_results] - - dd_pad_abs = max([len(t['max_drawdown_abs']) for t in strategy_results]) - dd_pad_per = max([len(dd) for dd in drawdown]) - drawdown = [f'{t["max_drawdown_abs"]:>{dd_pad_abs}} {stake_currency} {dd:>{dd_pad_per}}%' - for t, dd in zip(strategy_results, drawdown)] - - output = [[ - t['key'], t['trades'], t['profit_mean_pct'], t['profit_sum_pct'], t['profit_total_abs'], - t['profit_total_pct'], t['duration_avg'], - generate_wins_draws_losses(t['wins'], t['draws'], t['losses']), drawdown] - for t, drawdown in zip(strategy_results, drawdown)] - # Ignore type as floatfmt does allow tuples but mypy does not know that - return tabulate(output, headers=headers, - floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") - - -def text_table_add_metrics(strat_results: Dict) -> str: - if len(strat_results['trades']) > 0: - best_trade = max(strat_results['trades'], key=lambda x: x['profit_ratio']) - worst_trade = min(strat_results['trades'], key=lambda x: x['profit_ratio']) - - short_metrics = [ - ('', ''), # Empty line to improve readability - ('Long / Short', - f"{strat_results.get('trade_count_long', 'total_trades')} / " - f"{strat_results.get('trade_count_short', 0)}"), - ('Total profit Long %', f"{strat_results['profit_total_long']:.2%}"), - ('Total profit Short %', f"{strat_results['profit_total_short']:.2%}"), - ('Absolute profit Long', round_coin_value(strat_results['profit_total_long_abs'], - strat_results['stake_currency'])), - ('Absolute profit Short', round_coin_value(strat_results['profit_total_short_abs'], - strat_results['stake_currency'])), - ] if strat_results.get('trade_count_short', 0) > 0 else [] - - drawdown_metrics = [] - if 'max_relative_drawdown' in strat_results: - # Compatibility to show old hyperopt results - drawdown_metrics.append( - ('Max % of account underwater', f"{strat_results['max_relative_drawdown']:.2%}") - ) - drawdown_metrics.extend([ - ('Absolute Drawdown (Account)', f"{strat_results['max_drawdown_account']:.2%}") - if 'max_drawdown_account' in strat_results else ( - 'Drawdown', f"{strat_results['max_drawdown']:.2%}"), - ('Absolute Drawdown', round_coin_value(strat_results['max_drawdown_abs'], - strat_results['stake_currency'])), - ('Drawdown high', round_coin_value(strat_results['max_drawdown_high'], - strat_results['stake_currency'])), - ('Drawdown low', round_coin_value(strat_results['max_drawdown_low'], - strat_results['stake_currency'])), - ('Drawdown Start', strat_results['drawdown_start']), - ('Drawdown End', strat_results['drawdown_end']), - ]) - - entry_adjustment_metrics = [ - ('Canceled Trade Entries', strat_results.get('canceled_trade_entries', 'N/A')), - ('Canceled Entry Orders', strat_results.get('canceled_entry_orders', 'N/A')), - ('Replaced Entry Orders', strat_results.get('replaced_entry_orders', 'N/A')), - ] if strat_results.get('canceled_entry_orders', 0) > 0 else [] - - # Newly added fields should be ignored if they are missing in strat_results. hyperopt-show - # command stores these results and newer version of freqtrade must be able to handle old - # results with missing new fields. - metrics = [ - ('Backtesting from', strat_results['backtest_start']), - ('Backtesting to', strat_results['backtest_end']), - ('Max open trades', strat_results['max_open_trades']), - ('', ''), # Empty line to improve readability - ('Total/Daily Avg Trades', - f"{strat_results['total_trades']} / {strat_results['trades_per_day']}"), - - ('Starting balance', round_coin_value(strat_results['starting_balance'], - strat_results['stake_currency'])), - ('Final balance', round_coin_value(strat_results['final_balance'], - strat_results['stake_currency'])), - ('Absolute profit ', round_coin_value(strat_results['profit_total_abs'], - strat_results['stake_currency'])), - ('Total profit %', f"{strat_results['profit_total']:.2%}"), - ('CAGR %', f"{strat_results['cagr']:.2%}" if 'cagr' in strat_results else 'N/A'), - ('Sortino', f"{strat_results['sortino']:.2f}" if 'sortino' in strat_results else 'N/A'), - ('Sharpe', f"{strat_results['sharpe']:.2f}" if 'sharpe' in strat_results else 'N/A'), - ('Calmar', f"{strat_results['calmar']:.2f}" if 'calmar' in strat_results else 'N/A'), - ('Profit factor', f'{strat_results["profit_factor"]:.2f}' if 'profit_factor' - in strat_results else 'N/A'), - ('Expectancy', f"{strat_results['expectancy']:.2f}" if 'expectancy' - in strat_results else 'N/A'), - ('Trades per day', strat_results['trades_per_day']), - ('Avg. daily profit %', - f"{(strat_results['profit_total'] / strat_results['backtest_days']):.2%}"), - ('Avg. stake amount', round_coin_value(strat_results['avg_stake_amount'], - strat_results['stake_currency'])), - ('Total trade volume', round_coin_value(strat_results['total_volume'], - strat_results['stake_currency'])), - *short_metrics, - ('', ''), # Empty line to improve readability - ('Best Pair', f"{strat_results['best_pair']['key']} " - f"{strat_results['best_pair']['profit_sum']:.2%}"), - ('Worst Pair', f"{strat_results['worst_pair']['key']} " - f"{strat_results['worst_pair']['profit_sum']:.2%}"), - ('Best trade', f"{best_trade['pair']} {best_trade['profit_ratio']:.2%}"), - ('Worst trade', f"{worst_trade['pair']} " - f"{worst_trade['profit_ratio']:.2%}"), - - ('Best day', round_coin_value(strat_results['backtest_best_day_abs'], - strat_results['stake_currency'])), - ('Worst day', round_coin_value(strat_results['backtest_worst_day_abs'], - strat_results['stake_currency'])), - ('Days win/draw/lose', f"{strat_results['winning_days']} / " - f"{strat_results['draw_days']} / {strat_results['losing_days']}"), - ('Avg. Duration Winners', f"{strat_results['winner_holding_avg']}"), - ('Avg. Duration Loser', f"{strat_results['loser_holding_avg']}"), - ('Rejected Entry signals', strat_results.get('rejected_signals', 'N/A')), - ('Entry/Exit Timeouts', - f"{strat_results.get('timedout_entry_orders', 'N/A')} / " - f"{strat_results.get('timedout_exit_orders', 'N/A')}"), - *entry_adjustment_metrics, - ('', ''), # Empty line to improve readability - - ('Min balance', round_coin_value(strat_results['csum_min'], - strat_results['stake_currency'])), - ('Max balance', round_coin_value(strat_results['csum_max'], - strat_results['stake_currency'])), - - *drawdown_metrics, - ('Market change', f"{strat_results['market_change']:.2%}"), - ] - - return tabulate(metrics, headers=["Metric", "Value"], tablefmt="orgtbl") - else: - start_balance = round_coin_value(strat_results['starting_balance'], - strat_results['stake_currency']) - stake_amount = round_coin_value( - strat_results['stake_amount'], strat_results['stake_currency'] - ) if strat_results['stake_amount'] != UNLIMITED_STAKE_AMOUNT else 'unlimited' - - message = ("No trades made. " - f"Your starting balance was {start_balance}, " - f"and your stake was {stake_amount}." - ) - return message - - -def show_backtest_result(strategy: str, results: Dict[str, Any], stake_currency: str, - backtest_breakdown=[]): - """ - Print results for one strategy - """ - # Print results - print(f"Result for strategy {strategy}") - table = text_table_bt_results(results['results_per_pair'], stake_currency=stake_currency) - if isinstance(table, str): - print(' BACKTESTING REPORT '.center(len(table.splitlines()[0]), '=')) - print(table) - - table = text_table_bt_results(results['left_open_trades'], stake_currency=stake_currency) - if isinstance(table, str) and len(table) > 0: - print(' LEFT OPEN TRADES REPORT '.center(len(table.splitlines()[0]), '=')) - print(table) - - if (results.get('results_per_enter_tag') is not None - or results.get('results_per_buy_tag') is not None): - # results_per_buy_tag is deprecated and should be removed 2 versions after short golive. - table = text_table_tags( - "enter_tag", - results.get('results_per_enter_tag', results.get('results_per_buy_tag')), - stake_currency=stake_currency) - - if isinstance(table, str) and len(table) > 0: - print(' ENTER TAG STATS '.center(len(table.splitlines()[0]), '=')) - print(table) - - exit_reasons = results.get('exit_reason_summary', results.get('sell_reason_summary')) - table = text_table_exit_reason(exit_reason_stats=exit_reasons, - stake_currency=stake_currency) - if isinstance(table, str) and len(table) > 0: - print(' EXIT REASON STATS '.center(len(table.splitlines()[0]), '=')) - print(table) - - for period in backtest_breakdown: - days_breakdown_stats = generate_periodic_breakdown_stats( - trade_list=results['trades'], period=period) - table = text_table_periodic_breakdown(days_breakdown_stats=days_breakdown_stats, - stake_currency=stake_currency, period=period) - if isinstance(table, str) and len(table) > 0: - print(f' {period.upper()} BREAKDOWN '.center(len(table.splitlines()[0]), '=')) - print(table) - - table = text_table_add_metrics(results) - if isinstance(table, str) and len(table) > 0: - print(' SUMMARY METRICS '.center(len(table.splitlines()[0]), '=')) - print(table) - - if isinstance(table, str) and len(table) > 0: - print('=' * len(table.splitlines()[0])) - - print() - - -def show_backtest_results(config: Config, backtest_stats: Dict): - stake_currency = config['stake_currency'] - - for strategy, results in backtest_stats['strategy'].items(): - show_backtest_result( - strategy, results, stake_currency, - config.get('backtest_breakdown', [])) - - if len(backtest_stats['strategy']) > 0: - # Print Strategy summary table - - table = text_table_strategy(backtest_stats['strategy_comparison'], stake_currency) - print(f"Backtested {results['backtest_start']} -> {results['backtest_end']} |" - f" Max open trades : {results['max_open_trades']}") - print(' STRATEGY SUMMARY '.center(len(table.splitlines()[0]), '=')) - print(table) - print('=' * len(table.splitlines()[0])) - print('\nFor more details, please look at the detail tables above') - - -def show_sorted_pairlist(config: Config, backtest_stats: Dict): - if config.get('backtest_show_pair_list', False): - for strategy, results in backtest_stats['strategy'].items(): - print(f"Pairs for Strategy {strategy}: \n[") - for result in results['results_per_pair']: - if result["key"] != 'TOTAL': - print(f'"{result["key"]}", // {result["profit_mean"]:.2%}') - print("]") diff --git a/freqtrade/optimize/optimize_reports/__init__.py b/freqtrade/optimize/optimize_reports/__init__.py new file mode 100644 index 000000000..68e222d00 --- /dev/null +++ b/freqtrade/optimize/optimize_reports/__init__.py @@ -0,0 +1,18 @@ +# flake8: noqa: F401 +from freqtrade.optimize.optimize_reports.bt_output import (generate_edge_table, + show_backtest_result, + show_backtest_results, + show_sorted_pairlist, + text_table_add_metrics, + text_table_bt_results, + text_table_exit_reason, + text_table_periodic_breakdown, + text_table_strategy, text_table_tags) +from freqtrade.optimize.optimize_reports.bt_storage import (store_backtest_analysis_results, + store_backtest_stats) +from freqtrade.optimize.optimize_reports.optimize_reports import ( + generate_all_periodic_breakdown_stats, generate_backtest_stats, generate_daily_stats, + generate_exit_reason_stats, generate_pair_metrics, generate_periodic_breakdown_stats, + generate_rejected_signals, generate_strategy_comparison, generate_strategy_stats, + generate_tag_metrics, generate_trade_signal_candles, generate_trading_stats, + generate_wins_draws_losses) diff --git a/freqtrade/optimize/optimize_reports/bt_output.py b/freqtrade/optimize/optimize_reports/bt_output.py new file mode 100644 index 000000000..1fd1f7a34 --- /dev/null +++ b/freqtrade/optimize/optimize_reports/bt_output.py @@ -0,0 +1,405 @@ +import logging +from typing import Any, Dict, List + +from tabulate import tabulate + +from freqtrade.constants import UNLIMITED_STAKE_AMOUNT, Config +from freqtrade.misc import decimals_per_coin, round_coin_value +from freqtrade.optimize.optimize_reports.optimize_reports import (generate_periodic_breakdown_stats, + generate_wins_draws_losses) + + +logger = logging.getLogger(__name__) + + +def _get_line_floatfmt(stake_currency: str) -> List[str]: + """ + Generate floatformat (goes in line with _generate_result_line()) + """ + return ['s', 'd', '.2f', '.2f', f'.{decimals_per_coin(stake_currency)}f', + '.2f', 'd', 's', 's'] + + +def _get_line_header(first_column: str, stake_currency: str, + direction: str = 'Entries') -> List[str]: + """ + Generate header lines (goes in line with _generate_result_line()) + """ + return [first_column, direction, 'Avg Profit %', 'Cum Profit %', + f'Tot Profit {stake_currency}', 'Tot Profit %', 'Avg Duration', + 'Win Draw Loss Win%'] + + +def text_table_bt_results(pair_results: List[Dict[str, Any]], stake_currency: str) -> str: + """ + Generates and returns a text table for the given backtest data and the results dataframe + :param pair_results: List of Dictionaries - one entry per pair + final TOTAL row + :param stake_currency: stake-currency - used to correctly name headers + :return: pretty printed table with tabulate as string + """ + + headers = _get_line_header('Pair', stake_currency) + floatfmt = _get_line_floatfmt(stake_currency) + output = [[ + t['key'], t['trades'], t['profit_mean_pct'], t['profit_sum_pct'], t['profit_total_abs'], + t['profit_total_pct'], t['duration_avg'], + generate_wins_draws_losses(t['wins'], t['draws'], t['losses']) + ] for t in pair_results] + # Ignore type as floatfmt does allow tuples but mypy does not know that + return tabulate(output, headers=headers, + floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") + + +def text_table_exit_reason(exit_reason_stats: List[Dict[str, Any]], stake_currency: str) -> str: + """ + Generate small table outlining Backtest results + :param sell_reason_stats: Exit reason metrics + :param stake_currency: Stakecurrency used + :return: pretty printed table with tabulate as string + """ + headers = [ + 'Exit Reason', + 'Exits', + 'Win Draws Loss Win%', + 'Avg Profit %', + 'Cum Profit %', + f'Tot Profit {stake_currency}', + 'Tot Profit %', + ] + + output = [[ + t.get('exit_reason', t.get('sell_reason')), t['trades'], + generate_wins_draws_losses(t['wins'], t['draws'], t['losses']), + t['profit_mean_pct'], t['profit_sum_pct'], + round_coin_value(t['profit_total_abs'], stake_currency, False), + t['profit_total_pct'], + ] for t in exit_reason_stats] + return tabulate(output, headers=headers, tablefmt="orgtbl", stralign="right") + + +def text_table_tags(tag_type: str, tag_results: List[Dict[str, Any]], stake_currency: str) -> str: + """ + Generates and returns a text table for the given backtest data and the results dataframe + :param pair_results: List of Dictionaries - one entry per pair + final TOTAL row + :param stake_currency: stake-currency - used to correctly name headers + :return: pretty printed table with tabulate as string + """ + if (tag_type == "enter_tag"): + headers = _get_line_header("TAG", stake_currency) + else: + headers = _get_line_header("TAG", stake_currency, 'Exits') + floatfmt = _get_line_floatfmt(stake_currency) + output = [ + [ + t['key'] if t['key'] is not None and len( + t['key']) > 0 else "OTHER", + t['trades'], + t['profit_mean_pct'], + t['profit_sum_pct'], + t['profit_total_abs'], + t['profit_total_pct'], + t['duration_avg'], + generate_wins_draws_losses( + t['wins'], + t['draws'], + t['losses'])] for t in tag_results] + # Ignore type as floatfmt does allow tuples but mypy does not know that + return tabulate(output, headers=headers, + floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") + + +def text_table_periodic_breakdown(days_breakdown_stats: List[Dict[str, Any]], + stake_currency: str, period: str) -> str: + """ + Generate small table with Backtest results by days + :param days_breakdown_stats: Days breakdown metrics + :param stake_currency: Stakecurrency used + :return: pretty printed table with tabulate as string + """ + headers = [ + period.capitalize(), + f'Tot Profit {stake_currency}', + 'Wins', + 'Draws', + 'Losses', + ] + output = [[ + d['date'], round_coin_value(d['profit_abs'], stake_currency, False), + d['wins'], d['draws'], d['loses'], + ] for d in days_breakdown_stats] + return tabulate(output, headers=headers, tablefmt="orgtbl", stralign="right") + + +def text_table_strategy(strategy_results, stake_currency: str) -> str: + """ + Generate summary table per strategy + :param strategy_results: Dict of containing results for all strategies + :param stake_currency: stake-currency - used to correctly name headers + :return: pretty printed table with tabulate as string + """ + floatfmt = _get_line_floatfmt(stake_currency) + headers = _get_line_header('Strategy', stake_currency) + # _get_line_header() is also used for per-pair summary. Per-pair drawdown is mostly useless + # therefore we slip this column in only for strategy summary here. + headers.append('Drawdown') + + # Align drawdown string on the center two space separator. + if 'max_drawdown_account' in strategy_results[0]: + drawdown = [f'{t["max_drawdown_account"] * 100:.2f}' for t in strategy_results] + else: + # Support for prior backtest results + drawdown = [f'{t["max_drawdown_per"]:.2f}' for t in strategy_results] + + dd_pad_abs = max([len(t['max_drawdown_abs']) for t in strategy_results]) + dd_pad_per = max([len(dd) for dd in drawdown]) + drawdown = [f'{t["max_drawdown_abs"]:>{dd_pad_abs}} {stake_currency} {dd:>{dd_pad_per}}%' + for t, dd in zip(strategy_results, drawdown)] + + output = [[ + t['key'], t['trades'], t['profit_mean_pct'], t['profit_sum_pct'], t['profit_total_abs'], + t['profit_total_pct'], t['duration_avg'], + generate_wins_draws_losses(t['wins'], t['draws'], t['losses']), drawdown] + for t, drawdown in zip(strategy_results, drawdown)] + # Ignore type as floatfmt does allow tuples but mypy does not know that + return tabulate(output, headers=headers, + floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") + + +def text_table_add_metrics(strat_results: Dict) -> str: + if len(strat_results['trades']) > 0: + best_trade = max(strat_results['trades'], key=lambda x: x['profit_ratio']) + worst_trade = min(strat_results['trades'], key=lambda x: x['profit_ratio']) + + short_metrics = [ + ('', ''), # Empty line to improve readability + ('Long / Short', + f"{strat_results.get('trade_count_long', 'total_trades')} / " + f"{strat_results.get('trade_count_short', 0)}"), + ('Total profit Long %', f"{strat_results['profit_total_long']:.2%}"), + ('Total profit Short %', f"{strat_results['profit_total_short']:.2%}"), + ('Absolute profit Long', round_coin_value(strat_results['profit_total_long_abs'], + strat_results['stake_currency'])), + ('Absolute profit Short', round_coin_value(strat_results['profit_total_short_abs'], + strat_results['stake_currency'])), + ] if strat_results.get('trade_count_short', 0) > 0 else [] + + drawdown_metrics = [] + if 'max_relative_drawdown' in strat_results: + # Compatibility to show old hyperopt results + drawdown_metrics.append( + ('Max % of account underwater', f"{strat_results['max_relative_drawdown']:.2%}") + ) + drawdown_metrics.extend([ + ('Absolute Drawdown (Account)', f"{strat_results['max_drawdown_account']:.2%}") + if 'max_drawdown_account' in strat_results else ( + 'Drawdown', f"{strat_results['max_drawdown']:.2%}"), + ('Absolute Drawdown', round_coin_value(strat_results['max_drawdown_abs'], + strat_results['stake_currency'])), + ('Drawdown high', round_coin_value(strat_results['max_drawdown_high'], + strat_results['stake_currency'])), + ('Drawdown low', round_coin_value(strat_results['max_drawdown_low'], + strat_results['stake_currency'])), + ('Drawdown Start', strat_results['drawdown_start']), + ('Drawdown End', strat_results['drawdown_end']), + ]) + + entry_adjustment_metrics = [ + ('Canceled Trade Entries', strat_results.get('canceled_trade_entries', 'N/A')), + ('Canceled Entry Orders', strat_results.get('canceled_entry_orders', 'N/A')), + ('Replaced Entry Orders', strat_results.get('replaced_entry_orders', 'N/A')), + ] if strat_results.get('canceled_entry_orders', 0) > 0 else [] + + # Newly added fields should be ignored if they are missing in strat_results. hyperopt-show + # command stores these results and newer version of freqtrade must be able to handle old + # results with missing new fields. + metrics = [ + ('Backtesting from', strat_results['backtest_start']), + ('Backtesting to', strat_results['backtest_end']), + ('Max open trades', strat_results['max_open_trades']), + ('', ''), # Empty line to improve readability + ('Total/Daily Avg Trades', + f"{strat_results['total_trades']} / {strat_results['trades_per_day']}"), + + ('Starting balance', round_coin_value(strat_results['starting_balance'], + strat_results['stake_currency'])), + ('Final balance', round_coin_value(strat_results['final_balance'], + strat_results['stake_currency'])), + ('Absolute profit ', round_coin_value(strat_results['profit_total_abs'], + strat_results['stake_currency'])), + ('Total profit %', f"{strat_results['profit_total']:.2%}"), + ('CAGR %', f"{strat_results['cagr']:.2%}" if 'cagr' in strat_results else 'N/A'), + ('Sortino', f"{strat_results['sortino']:.2f}" if 'sortino' in strat_results else 'N/A'), + ('Sharpe', f"{strat_results['sharpe']:.2f}" if 'sharpe' in strat_results else 'N/A'), + ('Calmar', f"{strat_results['calmar']:.2f}" if 'calmar' in strat_results else 'N/A'), + ('Profit factor', f'{strat_results["profit_factor"]:.2f}' if 'profit_factor' + in strat_results else 'N/A'), + ('Expectancy', f"{strat_results['expectancy']:.2f}" if 'expectancy' + in strat_results else 'N/A'), + ('Trades per day', strat_results['trades_per_day']), + ('Avg. daily profit %', + f"{(strat_results['profit_total'] / strat_results['backtest_days']):.2%}"), + ('Avg. stake amount', round_coin_value(strat_results['avg_stake_amount'], + strat_results['stake_currency'])), + ('Total trade volume', round_coin_value(strat_results['total_volume'], + strat_results['stake_currency'])), + *short_metrics, + ('', ''), # Empty line to improve readability + ('Best Pair', f"{strat_results['best_pair']['key']} " + f"{strat_results['best_pair']['profit_sum']:.2%}"), + ('Worst Pair', f"{strat_results['worst_pair']['key']} " + f"{strat_results['worst_pair']['profit_sum']:.2%}"), + ('Best trade', f"{best_trade['pair']} {best_trade['profit_ratio']:.2%}"), + ('Worst trade', f"{worst_trade['pair']} " + f"{worst_trade['profit_ratio']:.2%}"), + + ('Best day', round_coin_value(strat_results['backtest_best_day_abs'], + strat_results['stake_currency'])), + ('Worst day', round_coin_value(strat_results['backtest_worst_day_abs'], + strat_results['stake_currency'])), + ('Days win/draw/lose', f"{strat_results['winning_days']} / " + f"{strat_results['draw_days']} / {strat_results['losing_days']}"), + ('Avg. Duration Winners', f"{strat_results['winner_holding_avg']}"), + ('Avg. Duration Loser', f"{strat_results['loser_holding_avg']}"), + ('Rejected Entry signals', strat_results.get('rejected_signals', 'N/A')), + ('Entry/Exit Timeouts', + f"{strat_results.get('timedout_entry_orders', 'N/A')} / " + f"{strat_results.get('timedout_exit_orders', 'N/A')}"), + *entry_adjustment_metrics, + ('', ''), # Empty line to improve readability + + ('Min balance', round_coin_value(strat_results['csum_min'], + strat_results['stake_currency'])), + ('Max balance', round_coin_value(strat_results['csum_max'], + strat_results['stake_currency'])), + + *drawdown_metrics, + ('Market change', f"{strat_results['market_change']:.2%}"), + ] + + return tabulate(metrics, headers=["Metric", "Value"], tablefmt="orgtbl") + else: + start_balance = round_coin_value(strat_results['starting_balance'], + strat_results['stake_currency']) + stake_amount = round_coin_value( + strat_results['stake_amount'], strat_results['stake_currency'] + ) if strat_results['stake_amount'] != UNLIMITED_STAKE_AMOUNT else 'unlimited' + + message = ("No trades made. " + f"Your starting balance was {start_balance}, " + f"and your stake was {stake_amount}." + ) + return message + + +def show_backtest_result(strategy: str, results: Dict[str, Any], stake_currency: str, + backtest_breakdown=[]): + """ + Print results for one strategy + """ + # Print results + print(f"Result for strategy {strategy}") + table = text_table_bt_results(results['results_per_pair'], stake_currency=stake_currency) + if isinstance(table, str): + print(' BACKTESTING REPORT '.center(len(table.splitlines()[0]), '=')) + print(table) + + table = text_table_bt_results(results['left_open_trades'], stake_currency=stake_currency) + if isinstance(table, str) and len(table) > 0: + print(' LEFT OPEN TRADES REPORT '.center(len(table.splitlines()[0]), '=')) + print(table) + + if (results.get('results_per_enter_tag') is not None + or results.get('results_per_buy_tag') is not None): + # results_per_buy_tag is deprecated and should be removed 2 versions after short golive. + table = text_table_tags( + "enter_tag", + results.get('results_per_enter_tag', results.get('results_per_buy_tag')), + stake_currency=stake_currency) + + if isinstance(table, str) and len(table) > 0: + print(' ENTER TAG STATS '.center(len(table.splitlines()[0]), '=')) + print(table) + + exit_reasons = results.get('exit_reason_summary', results.get('sell_reason_summary')) + table = text_table_exit_reason(exit_reason_stats=exit_reasons, + stake_currency=stake_currency) + if isinstance(table, str) and len(table) > 0: + print(' EXIT REASON STATS '.center(len(table.splitlines()[0]), '=')) + print(table) + + for period in backtest_breakdown: + if period in results.get('periodic_breakdown', {}): + days_breakdown_stats = results['periodic_breakdown'][period] + else: + days_breakdown_stats = generate_periodic_breakdown_stats( + trade_list=results['trades'], period=period) + table = text_table_periodic_breakdown(days_breakdown_stats=days_breakdown_stats, + stake_currency=stake_currency, period=period) + if isinstance(table, str) and len(table) > 0: + print(f' {period.upper()} BREAKDOWN '.center(len(table.splitlines()[0]), '=')) + print(table) + + table = text_table_add_metrics(results) + if isinstance(table, str) and len(table) > 0: + print(' SUMMARY METRICS '.center(len(table.splitlines()[0]), '=')) + print(table) + + if isinstance(table, str) and len(table) > 0: + print('=' * len(table.splitlines()[0])) + + print() + + +def show_backtest_results(config: Config, backtest_stats: Dict): + stake_currency = config['stake_currency'] + + for strategy, results in backtest_stats['strategy'].items(): + show_backtest_result( + strategy, results, stake_currency, + config.get('backtest_breakdown', [])) + + if len(backtest_stats['strategy']) > 0: + # Print Strategy summary table + + table = text_table_strategy(backtest_stats['strategy_comparison'], stake_currency) + print(f"Backtested {results['backtest_start']} -> {results['backtest_end']} |" + f" Max open trades : {results['max_open_trades']}") + print(' STRATEGY SUMMARY '.center(len(table.splitlines()[0]), '=')) + print(table) + print('=' * len(table.splitlines()[0])) + print('\nFor more details, please look at the detail tables above') + + +def show_sorted_pairlist(config: Config, backtest_stats: Dict): + if config.get('backtest_show_pair_list', False): + for strategy, results in backtest_stats['strategy'].items(): + print(f"Pairs for Strategy {strategy}: \n[") + for result in results['results_per_pair']: + if result["key"] != 'TOTAL': + print(f'"{result["key"]}", // {result["profit_mean"]:.2%}') + print("]") + + +def generate_edge_table(results: dict) -> str: + floatfmt = ('s', '.10g', '.2f', '.2f', '.2f', '.2f', 'd', 'd', 'd') + tabular_data = [] + headers = ['Pair', 'Stoploss', 'Win Rate', 'Risk Reward Ratio', + 'Required Risk Reward', 'Expectancy', 'Total Number of Trades', + 'Average Duration (min)'] + + for result in results.items(): + if result[1].nb_trades > 0: + tabular_data.append([ + result[0], + result[1].stoploss, + result[1].winrate, + result[1].risk_reward_ratio, + result[1].required_risk_reward, + result[1].expectancy, + result[1].nb_trades, + round(result[1].avg_trade_duration) + ]) + + # Ignore type as floatfmt does allow tuples but mypy does not know that + return tabulate(tabular_data, headers=headers, + floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") diff --git a/freqtrade/optimize/optimize_reports/bt_storage.py b/freqtrade/optimize/optimize_reports/bt_storage.py new file mode 100644 index 000000000..af97753e3 --- /dev/null +++ b/freqtrade/optimize/optimize_reports/bt_storage.py @@ -0,0 +1,71 @@ +import logging +from pathlib import Path +from typing import Dict + +from pandas import DataFrame + +from freqtrade.constants import LAST_BT_RESULT_FN +from freqtrade.misc import file_dump_joblib, file_dump_json +from freqtrade.optimize.backtest_caching import get_backtest_metadata_filename + + +logger = logging.getLogger(__name__) + + +def store_backtest_stats( + recordfilename: Path, stats: Dict[str, DataFrame], dtappendix: str) -> None: + """ + Stores backtest results + :param recordfilename: Path object, which can either be a filename or a directory. + Filenames will be appended with a timestamp right before the suffix + while for directories, /backtest-result-.json will be used as filename + :param stats: Dataframe containing the backtesting statistics + :param dtappendix: Datetime to use for the filename + """ + if recordfilename.is_dir(): + filename = (recordfilename / f'backtest-result-{dtappendix}.json') + else: + filename = Path.joinpath( + recordfilename.parent, f'{recordfilename.stem}-{dtappendix}' + ).with_suffix(recordfilename.suffix) + + # Store metadata separately. + file_dump_json(get_backtest_metadata_filename(filename), stats['metadata']) + del stats['metadata'] + + file_dump_json(filename, stats) + + latest_filename = Path.joinpath(filename.parent, LAST_BT_RESULT_FN) + file_dump_json(latest_filename, {'latest_backtest': str(filename.name)}) + + +def _store_backtest_analysis_data( + recordfilename: Path, data: Dict[str, Dict], + dtappendix: str, name: str) -> Path: + """ + Stores backtest trade candles for analysis + :param recordfilename: Path object, which can either be a filename or a directory. + Filenames will be appended with a timestamp right before the suffix + while for directories, /backtest-result-_.pkl will be used + as filename + :param candles: Dict containing the backtesting data for analysis + :param dtappendix: Datetime to use for the filename + :param name: Name to use for the file, e.g. signals, rejected + """ + if recordfilename.is_dir(): + filename = (recordfilename / f'backtest-result-{dtappendix}_{name}.pkl') + else: + filename = Path.joinpath( + recordfilename.parent, f'{recordfilename.stem}-{dtappendix}_{name}.pkl' + ) + + file_dump_joblib(filename, data) + + return filename + + +def store_backtest_analysis_results( + recordfilename: Path, candles: Dict[str, Dict], trades: Dict[str, Dict], + dtappendix: str) -> None: + _store_backtest_analysis_data(recordfilename, candles, dtappendix, "signals") + _store_backtest_analysis_data(recordfilename, trades, dtappendix, "rejected") diff --git a/freqtrade/optimize/optimize_reports/optimize_reports.py b/freqtrade/optimize/optimize_reports/optimize_reports.py new file mode 100644 index 000000000..015f163e3 --- /dev/null +++ b/freqtrade/optimize/optimize_reports/optimize_reports.py @@ -0,0 +1,548 @@ +import logging +from copy import deepcopy +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Union + +from pandas import DataFrame, concat, to_datetime + +from freqtrade.constants import BACKTEST_BREAKDOWNS, DATETIME_PRINT_FORMAT, IntOrInf +from freqtrade.data.metrics import (calculate_cagr, calculate_calmar, calculate_csum, + calculate_expectancy, calculate_market_change, + calculate_max_drawdown, calculate_sharpe, calculate_sortino) +from freqtrade.misc import decimals_per_coin, round_coin_value + + +logger = logging.getLogger(__name__) + + +def generate_trade_signal_candles(preprocessed_df: Dict[str, DataFrame], + bt_results: Dict[str, Any]) -> DataFrame: + signal_candles_only = {} + for pair in preprocessed_df.keys(): + signal_candles_only_df = DataFrame() + + pairdf = preprocessed_df[pair] + resdf = bt_results['results'] + pairresults = resdf.loc[(resdf["pair"] == pair)] + + if pairdf.shape[0] > 0: + for t, v in pairresults.open_date.items(): + allinds = pairdf.loc[(pairdf['date'] < v)] + signal_inds = allinds.iloc[[-1]] + signal_candles_only_df = concat([ + signal_candles_only_df.infer_objects(), + signal_inds.infer_objects()]) + + signal_candles_only[pair] = signal_candles_only_df + return signal_candles_only + + +def generate_rejected_signals(preprocessed_df: Dict[str, DataFrame], + rejected_dict: Dict[str, DataFrame]) -> Dict[str, DataFrame]: + rejected_candles_only = {} + for pair, signals in rejected_dict.items(): + rejected_signals_only_df = DataFrame() + pairdf = preprocessed_df[pair] + + for t in signals: + data_df_row = pairdf.loc[(pairdf['date'] == t[0])].copy() + data_df_row['pair'] = pair + data_df_row['enter_tag'] = t[1] + + rejected_signals_only_df = concat([ + rejected_signals_only_df.infer_objects(), + data_df_row.infer_objects()]) + + rejected_candles_only[pair] = rejected_signals_only_df + return rejected_candles_only + + +def generate_wins_draws_losses(wins, draws, losses): + if wins > 0 and losses == 0: + wl_ratio = '100' + elif wins == 0: + wl_ratio = '0' + else: + wl_ratio = f'{100.0 / (wins + draws + losses) * wins:.1f}' if losses > 0 else '100' + return f'{wins:>4} {draws:>4} {losses:>4} {wl_ratio:>4}' + + +def _generate_result_line(result: DataFrame, starting_balance: int, first_column: str) -> Dict: + """ + Generate one result dict, with "first_column" as key. + """ + profit_sum = result['profit_ratio'].sum() + # (end-capital - starting capital) / starting capital + profit_total = result['profit_abs'].sum() / starting_balance + + return { + 'key': first_column, + 'trades': len(result), + 'profit_mean': result['profit_ratio'].mean() if len(result) > 0 else 0.0, + 'profit_mean_pct': result['profit_ratio'].mean() * 100.0 if len(result) > 0 else 0.0, + 'profit_sum': profit_sum, + 'profit_sum_pct': round(profit_sum * 100.0, 2), + 'profit_total_abs': result['profit_abs'].sum(), + 'profit_total': profit_total, + 'profit_total_pct': round(profit_total * 100.0, 2), + 'duration_avg': str(timedelta( + minutes=round(result['trade_duration'].mean())) + ) if not result.empty else '0:00', + # 'duration_max': str(timedelta( + # minutes=round(result['trade_duration'].max())) + # ) if not result.empty else '0:00', + # 'duration_min': str(timedelta( + # minutes=round(result['trade_duration'].min())) + # ) if not result.empty else '0:00', + 'wins': len(result[result['profit_abs'] > 0]), + 'draws': len(result[result['profit_abs'] == 0]), + 'losses': len(result[result['profit_abs'] < 0]), + } + + +def generate_pair_metrics(pairlist: List[str], stake_currency: str, starting_balance: int, + results: DataFrame, skip_nan: bool = False) -> List[Dict]: + """ + Generates and returns a list for the given backtest data and the results dataframe + :param pairlist: Pairlist used + :param stake_currency: stake-currency - used to correctly name headers + :param starting_balance: Starting balance + :param results: Dataframe containing the backtest results + :param skip_nan: Print "left open" open trades + :return: List of Dicts containing the metrics per pair + """ + + tabular_data = [] + + for pair in pairlist: + result = results[results['pair'] == pair] + if skip_nan and result['profit_abs'].isnull().all(): + continue + + tabular_data.append(_generate_result_line(result, starting_balance, pair)) + + # Sort by total profit %: + tabular_data = sorted(tabular_data, key=lambda k: k['profit_total_abs'], reverse=True) + + # Append Total + tabular_data.append(_generate_result_line(results, starting_balance, 'TOTAL')) + return tabular_data + + +def generate_tag_metrics(tag_type: str, + starting_balance: int, + results: DataFrame, + skip_nan: bool = False) -> List[Dict]: + """ + Generates and returns a list of metrics for the given tag trades and the results dataframe + :param starting_balance: Starting balance + :param results: Dataframe containing the backtest results + :param skip_nan: Print "left open" open trades + :return: List of Dicts containing the metrics per pair + """ + + tabular_data = [] + + if tag_type in results.columns: + 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 + + tabular_data.append(_generate_result_line(result, starting_balance, tag)) + + # Sort by total profit %: + tabular_data = sorted(tabular_data, key=lambda k: k['profit_total_abs'], reverse=True) + + # Append Total + tabular_data.append(_generate_result_line(results, starting_balance, 'TOTAL')) + return tabular_data + else: + return [] + + +def generate_exit_reason_stats(max_open_trades: IntOrInf, results: DataFrame) -> List[Dict]: + """ + Generate small table outlining Backtest results + :param max_open_trades: Max_open_trades parameter + :param results: Dataframe containing the backtest result for one strategy + :return: List of Dicts containing the metrics per Sell reason + """ + tabular_data = [] + + for reason, count in results['exit_reason'].value_counts().items(): + result = results.loc[results['exit_reason'] == reason] + + profit_mean = result['profit_ratio'].mean() + profit_sum = result['profit_ratio'].sum() + profit_total = profit_sum / max_open_trades + + tabular_data.append( + { + 'exit_reason': reason, + 'trades': count, + 'wins': len(result[result['profit_abs'] > 0]), + 'draws': len(result[result['profit_abs'] == 0]), + 'losses': len(result[result['profit_abs'] < 0]), + 'profit_mean': profit_mean, + 'profit_mean_pct': round(profit_mean * 100, 2), + 'profit_sum': profit_sum, + 'profit_sum_pct': round(profit_sum * 100, 2), + 'profit_total_abs': result['profit_abs'].sum(), + 'profit_total': profit_total, + 'profit_total_pct': round(profit_total * 100, 2), + } + ) + return tabular_data + + +def generate_strategy_comparison(bt_stats: Dict) -> List[Dict]: + """ + Generate summary per strategy + :param bt_stats: Dict of containing results for all strategies + :return: List of Dicts containing the metrics per Strategy + """ + + tabular_data = [] + for strategy, result in bt_stats.items(): + tabular_data.append(deepcopy(result['results_per_pair'][-1])) + # Update "key" to strategy (results_per_pair has it as "Total"). + tabular_data[-1]['key'] = strategy + tabular_data[-1]['max_drawdown_account'] = result['max_drawdown_account'] + tabular_data[-1]['max_drawdown_abs'] = round_coin_value( + result['max_drawdown_abs'], result['stake_currency'], False) + return tabular_data + + +def _get_resample_from_period(period: str) -> str: + if period == 'day': + return '1d' + if period == 'week': + # Weekly defaulting to Monday. + return '1W-MON' + if period == 'month': + return '1M' + raise ValueError(f"Period {period} is not supported.") + + +def generate_periodic_breakdown_stats(trade_list: List, period: str) -> List[Dict[str, Any]]: + results = DataFrame.from_records(trade_list) + if len(results) == 0: + return [] + results['close_date'] = to_datetime(results['close_date'], utc=True) + resample_period = _get_resample_from_period(period) + resampled = results.resample(resample_period, on='close_date') + stats = [] + for name, day in resampled: + profit_abs = day['profit_abs'].sum().round(10) + wins = sum(day['profit_abs'] > 0) + draws = sum(day['profit_abs'] == 0) + loses = sum(day['profit_abs'] < 0) + stats.append( + { + 'date': name.strftime('%d/%m/%Y'), + 'date_ts': int(name.to_pydatetime().timestamp() * 1000), + 'profit_abs': profit_abs, + 'wins': wins, + 'draws': draws, + 'loses': loses + } + ) + return stats + + +def generate_all_periodic_breakdown_stats(trade_list: List) -> Dict[str, List]: + result = {} + for period in BACKTEST_BREAKDOWNS: + result[period] = generate_periodic_breakdown_stats(trade_list, period) + return result + + +def generate_trading_stats(results: DataFrame) -> Dict[str, Any]: + """ Generate overall trade statistics """ + if len(results) == 0: + return { + 'wins': 0, + 'losses': 0, + 'draws': 0, + 'holding_avg': timedelta(), + 'winner_holding_avg': timedelta(), + 'loser_holding_avg': timedelta(), + } + + winning_trades = results.loc[results['profit_ratio'] > 0] + draw_trades = results.loc[results['profit_ratio'] == 0] + losing_trades = results.loc[results['profit_ratio'] < 0] + + holding_avg = (timedelta(minutes=round(results['trade_duration'].mean())) + if not results.empty else timedelta()) + winner_holding_avg = (timedelta(minutes=round(winning_trades['trade_duration'].mean())) + if not winning_trades.empty else timedelta()) + loser_holding_avg = (timedelta(minutes=round(losing_trades['trade_duration'].mean())) + if not losing_trades.empty else timedelta()) + + return { + 'wins': len(winning_trades), + 'losses': len(losing_trades), + 'draws': len(draw_trades), + 'holding_avg': holding_avg, + 'holding_avg_s': holding_avg.total_seconds(), + 'winner_holding_avg': winner_holding_avg, + 'winner_holding_avg_s': winner_holding_avg.total_seconds(), + 'loser_holding_avg': loser_holding_avg, + 'loser_holding_avg_s': loser_holding_avg.total_seconds(), + } + + +def generate_daily_stats(results: DataFrame) -> Dict[str, Any]: + """ Generate daily statistics """ + if len(results) == 0: + return { + 'backtest_best_day': 0, + 'backtest_worst_day': 0, + 'backtest_best_day_abs': 0, + 'backtest_worst_day_abs': 0, + 'winning_days': 0, + 'draw_days': 0, + 'losing_days': 0, + 'daily_profit_list': [], + } + daily_profit_rel = results.resample('1d', on='close_date')['profit_ratio'].sum() + daily_profit = results.resample('1d', on='close_date')['profit_abs'].sum().round(10) + worst_rel = min(daily_profit_rel) + best_rel = max(daily_profit_rel) + worst = min(daily_profit) + best = max(daily_profit) + 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.items()] + + return { + 'backtest_best_day': best_rel, + 'backtest_worst_day': worst_rel, + 'backtest_best_day_abs': best, + 'backtest_worst_day_abs': worst, + 'winning_days': winning_days, + 'draw_days': draw_days, + 'losing_days': losing_days, + 'daily_profit': daily_profit_list, + } + + +def generate_strategy_stats(pairlist: List[str], + strategy: str, + content: Dict[str, Any], + min_date: datetime, max_date: datetime, + market_change: float, + is_hyperopt: bool = False, + ) -> Dict[str, Any]: + """ + :param pairlist: List of pairs to backtest + :param strategy: Strategy name + :param content: Backtest result data in the format: + {'results: results, 'config: config}}. + :param min_date: Backtest start date + :param max_date: Backtest end date + :param market_change: float indicating the market change + :return: Dictionary containing results per strategy and a strategy summary. + """ + results: Dict[str, DataFrame] = content['results'] + if not isinstance(results, DataFrame): + return {} + config = content['config'] + max_open_trades = min(config['max_open_trades'], len(pairlist)) + start_balance = config['dry_run_wallet'] + stake_currency = config['stake_currency'] + + pair_results = generate_pair_metrics(pairlist, stake_currency=stake_currency, + starting_balance=start_balance, + results=results, skip_nan=False) + + enter_tag_results = generate_tag_metrics("enter_tag", starting_balance=start_balance, + results=results, skip_nan=False) + + exit_reason_stats = generate_exit_reason_stats(max_open_trades=max_open_trades, + results=results) + left_open_results = generate_pair_metrics( + pairlist, stake_currency=stake_currency, starting_balance=start_balance, + results=results.loc[results['exit_reason'] == 'force_exit'], skip_nan=True) + + daily_stats = generate_daily_stats(results) + trade_stats = generate_trading_stats(results) + + periodic_breakdown = {} + if not is_hyperopt: + periodic_breakdown = {'periodic_breakdown': generate_all_periodic_breakdown_stats(results)} + + best_pair = max([pair for pair in pair_results if pair['key'] != 'TOTAL'], + key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None + worst_pair = min([pair for pair in pair_results if pair['key'] != 'TOTAL'], + key=lambda x: x['profit_sum']) if len(pair_results) > 1 else None + winning_profit = results.loc[results['profit_abs'] > 0, 'profit_abs'].sum() + losing_profit = results.loc[results['profit_abs'] < 0, 'profit_abs'].sum() + profit_factor = winning_profit / abs(losing_profit) if losing_profit else 0.0 + + backtest_days = (max_date - min_date).days or 1 + strat_stats = { + 'trades': results.to_dict(orient='records'), + 'locks': [lock.to_json() for lock in content['locks']], + 'best_pair': best_pair, + 'worst_pair': worst_pair, + 'results_per_pair': pair_results, + 'results_per_enter_tag': enter_tag_results, + 'exit_reason_summary': exit_reason_stats, + 'left_open_trades': left_open_results, + + 'total_trades': len(results), + 'trade_count_long': len(results.loc[~results['is_short']]), + 'trade_count_short': len(results.loc[results['is_short']]), + 'total_volume': float(results['stake_amount'].sum()), + 'avg_stake_amount': results['stake_amount'].mean() if len(results) > 0 else 0, + 'profit_mean': results['profit_ratio'].mean() if len(results) > 0 else 0, + 'profit_median': results['profit_ratio'].median() if len(results) > 0 else 0, + 'profit_total': results['profit_abs'].sum() / start_balance, + 'profit_total_long': results.loc[~results['is_short'], 'profit_abs'].sum() / start_balance, + 'profit_total_short': results.loc[results['is_short'], 'profit_abs'].sum() / start_balance, + 'profit_total_abs': results['profit_abs'].sum(), + 'profit_total_long_abs': results.loc[~results['is_short'], 'profit_abs'].sum(), + 'profit_total_short_abs': results.loc[results['is_short'], 'profit_abs'].sum(), + 'cagr': calculate_cagr(backtest_days, start_balance, content['final_balance']), + 'expectancy': calculate_expectancy(results), + 'sortino': calculate_sortino(results, min_date, max_date, start_balance), + 'sharpe': calculate_sharpe(results, min_date, max_date, start_balance), + 'calmar': calculate_calmar(results, min_date, max_date, start_balance), + 'profit_factor': profit_factor, + 'backtest_start': min_date.strftime(DATETIME_PRINT_FORMAT), + 'backtest_start_ts': int(min_date.timestamp() * 1000), + 'backtest_end': max_date.strftime(DATETIME_PRINT_FORMAT), + 'backtest_end_ts': int(max_date.timestamp() * 1000), + 'backtest_days': backtest_days, + + 'backtest_run_start_ts': content['backtest_start_time'], + 'backtest_run_end_ts': content['backtest_end_time'], + + 'trades_per_day': round(len(results) / backtest_days, 2), + 'market_change': market_change, + 'pairlist': pairlist, + 'stake_amount': config['stake_amount'], + 'stake_currency': config['stake_currency'], + 'stake_currency_decimals': decimals_per_coin(config['stake_currency']), + 'starting_balance': start_balance, + 'dry_run_wallet': start_balance, + 'final_balance': content['final_balance'], + 'rejected_signals': content['rejected_signals'], + 'timedout_entry_orders': content['timedout_entry_orders'], + 'timedout_exit_orders': content['timedout_exit_orders'], + 'canceled_trade_entries': content['canceled_trade_entries'], + 'canceled_entry_orders': content['canceled_entry_orders'], + 'replaced_entry_orders': content['replaced_entry_orders'], + 'max_open_trades': max_open_trades, + 'max_open_trades_setting': (config['max_open_trades'] + if config['max_open_trades'] != float('inf') else -1), + 'timeframe': config['timeframe'], + 'timeframe_detail': config.get('timeframe_detail', ''), + 'timerange': config.get('timerange', ''), + 'enable_protections': config.get('enable_protections', False), + 'strategy_name': strategy, + # Parameters relevant for backtesting + 'stoploss': config['stoploss'], + 'trailing_stop': config.get('trailing_stop', False), + 'trailing_stop_positive': config.get('trailing_stop_positive'), + 'trailing_stop_positive_offset': config.get('trailing_stop_positive_offset', 0.0), + 'trailing_only_offset_is_reached': config.get('trailing_only_offset_is_reached', False), + 'use_custom_stoploss': config.get('use_custom_stoploss', False), + 'minimal_roi': config['minimal_roi'], + 'use_exit_signal': config['use_exit_signal'], + 'exit_profit_only': config['exit_profit_only'], + 'exit_profit_offset': config['exit_profit_offset'], + 'ignore_roi_if_entry_signal': config['ignore_roi_if_entry_signal'], + **periodic_breakdown, + **daily_stats, + **trade_stats + } + + try: + max_drawdown_legacy, _, _, _, _, _ = calculate_max_drawdown( + results, value_col='profit_ratio') + (drawdown_abs, drawdown_start, drawdown_end, high_val, low_val, + max_drawdown) = calculate_max_drawdown( + results, value_col='profit_abs', starting_balance=start_balance) + # max_relative_drawdown = Underwater + (_, _, _, _, _, max_relative_drawdown) = calculate_max_drawdown( + results, value_col='profit_abs', starting_balance=start_balance, relative=True) + + strat_stats.update({ + 'max_drawdown': max_drawdown_legacy, # Deprecated - do not use + 'max_drawdown_account': max_drawdown, + 'max_relative_drawdown': max_relative_drawdown, + 'max_drawdown_abs': drawdown_abs, + 'drawdown_start': drawdown_start.strftime(DATETIME_PRINT_FORMAT), + 'drawdown_start_ts': drawdown_start.timestamp() * 1000, + 'drawdown_end': drawdown_end.strftime(DATETIME_PRINT_FORMAT), + 'drawdown_end_ts': drawdown_end.timestamp() * 1000, + + 'max_drawdown_low': low_val, + 'max_drawdown_high': high_val, + }) + + csum_min, csum_max = calculate_csum(results, start_balance) + strat_stats.update({ + 'csum_min': csum_min, + 'csum_max': csum_max + }) + + except ValueError: + strat_stats.update({ + 'max_drawdown': 0.0, + 'max_drawdown_account': 0.0, + 'max_relative_drawdown': 0.0, + 'max_drawdown_abs': 0.0, + 'max_drawdown_low': 0.0, + 'max_drawdown_high': 0.0, + 'drawdown_start': datetime(1970, 1, 1, tzinfo=timezone.utc), + 'drawdown_start_ts': 0, + 'drawdown_end': datetime(1970, 1, 1, tzinfo=timezone.utc), + 'drawdown_end_ts': 0, + 'csum_min': 0, + 'csum_max': 0 + }) + + return strat_stats + + +def generate_backtest_stats(btdata: Dict[str, DataFrame], + all_results: Dict[str, Dict[str, Union[DataFrame, Dict]]], + min_date: datetime, max_date: datetime + ) -> Dict[str, Any]: + """ + :param btdata: Backtest data + :param all_results: backtest result - dictionary in the form: + { Strategy: {'results: results, 'config: config}}. + :param min_date: Backtest start date + :param max_date: Backtest end date + :return: Dictionary containing results per strategy and a strategy summary. + """ + result: Dict[str, Any] = { + 'metadata': {}, + 'strategy': {}, + 'strategy_comparison': [], + } + market_change = calculate_market_change(btdata, 'close') + metadata = {} + pairlist = list(btdata.keys()) + for strategy, content in all_results.items(): + strat_stats = generate_strategy_stats(pairlist, strategy, content, + min_date, max_date, market_change=market_change) + metadata[strategy] = { + 'run_id': content['run_id'], + 'backtest_start_time': content['backtest_start_time'], + } + result['strategy'][strategy] = strat_stats + + strategy_results = generate_strategy_comparison(bt_stats=result['strategy']) + + result['metadata'] = metadata + result['strategy_comparison'] = strategy_results + + return result diff --git a/freqtrade/persistence/key_value_store.py b/freqtrade/persistence/key_value_store.py index 2d26acbd3..6da7265d6 100644 --- a/freqtrade/persistence/key_value_store.py +++ b/freqtrade/persistence/key_value_store.py @@ -36,13 +36,13 @@ class _KeyValueStoreModel(ModelBase): value_type: Mapped[ValueTypesEnum] = mapped_column(String(20), nullable=False) - string_value: Mapped[Optional[str]] + string_value: Mapped[Optional[str]] = mapped_column(String(255), nullable=True) datetime_value: Mapped[Optional[datetime]] float_value: Mapped[Optional[float]] int_value: Mapped[Optional[int]] -class KeyValueStore(): +class KeyValueStore: """ Generic bot-wide, persistent key-value store Can be used to store generic values, e.g. very first bot startup time. diff --git a/freqtrade/persistence/pairlock_middleware.py b/freqtrade/persistence/pairlock_middleware.py index 29169a50d..dd6bacf3a 100644 --- a/freqtrade/persistence/pairlock_middleware.py +++ b/freqtrade/persistence/pairlock_middleware.py @@ -11,7 +11,7 @@ from freqtrade.persistence.models import PairLock logger = logging.getLogger(__name__) -class PairLocks(): +class PairLocks: """ Pairlocks middleware class Abstracts the database layer away so it becomes optional - which will be necessary to support diff --git a/freqtrade/persistence/trade_model.py b/freqtrade/persistence/trade_model.py index 0572b45a6..d1b9c5a3b 100644 --- a/freqtrade/persistence/trade_model.py +++ b/freqtrade/persistence/trade_model.py @@ -10,6 +10,7 @@ from typing import Any, ClassVar, Dict, List, Optional, Sequence, cast from sqlalchemy import (Enum, Float, ForeignKey, Integer, ScalarResult, Select, String, UniqueConstraint, desc, func, select) from sqlalchemy.orm import Mapped, lazyload, mapped_column, relationship, validates +from typing_extensions import Self from freqtrade.constants import (CUSTOM_TAG_MAX_LENGTH, DATETIME_PRINT_FORMAT, MATH_CLOSE_PREC, NON_OPEN_EXCHANGE_STATES, BuySell, LongShort) @@ -19,7 +20,7 @@ from freqtrade.exchange import (ROUND_DOWN, ROUND_UP, amount_to_contract_precisi price_to_precision) from freqtrade.leverage import interest from freqtrade.persistence.base import ModelBase, SessionType -from freqtrade.util import FtPrecise +from freqtrade.util import FtPrecise, dt_now logger = logging.getLogger(__name__) @@ -46,7 +47,7 @@ class Order(ModelBase): id: Mapped[int] = mapped_column(Integer, primary_key=True) ft_trade_id: Mapped[int] = mapped_column(Integer, ForeignKey('trades.id'), index=True) - trade: Mapped[List["Trade"]] = relationship("Trade", back_populates="orders") + trade: Mapped["Trade"] = relationship("Trade", back_populates="orders") # order_side can only be 'buy', 'sell' or 'stoploss' ft_order_side: Mapped[str] = mapped_column(String(25), nullable=False) @@ -68,7 +69,7 @@ class Order(ModelBase): remaining: Mapped[Optional[float]] = mapped_column(Float(), nullable=True) cost: Mapped[Optional[float]] = mapped_column(Float(), nullable=True) stop_price: Mapped[Optional[float]] = mapped_column(Float(), nullable=True) - order_date: Mapped[datetime] = mapped_column(nullable=True, default=datetime.utcnow) + order_date: Mapped[datetime] = mapped_column(nullable=True, default=dt_now) order_filled_date: Mapped[Optional[datetime]] = mapped_column(nullable=True) order_update_date: Mapped[Optional[datetime]] = mapped_column(nullable=True) funding_fee: Mapped[Optional[float]] = mapped_column(Float(), nullable=True) @@ -97,7 +98,7 @@ class Order(ModelBase): @property def safe_filled(self) -> float: - return self.filled if self.filled is not None else self.amount or 0.0 + return self.filled if self.filled is not None else 0.0 @property def safe_cost(self) -> float: @@ -120,9 +121,9 @@ class Order(ModelBase): def __repr__(self): - return (f"Order(id={self.id}, order_id={self.order_id}, trade_id={self.ft_trade_id}, " + return (f"Order(id={self.id}, trade={self.ft_trade_id}, order_id={self.order_id}, " f"side={self.side}, filled={self.safe_filled}, price={self.safe_price}, " - f"order_type={self.order_type}, status={self.status})") + f"status={self.status}, date={self.order_date:{DATETIME_PRINT_FORMAT}})") def update_from_ccxt_object(self, order): """ @@ -158,7 +159,7 @@ class Order(ModelBase): self.order_filled_date = datetime.now(timezone.utc) self.order_update_date = datetime.now(timezone.utc) - def to_ccxt_object(self) -> Dict[str, Any]: + def to_ccxt_object(self, stopPriceName: str = 'stopPrice') -> Dict[str, Any]: order: Dict[str, Any] = { 'id': self.order_id, 'symbol': self.ft_pair, @@ -170,7 +171,6 @@ class Order(ModelBase): 'side': self.ft_order_side, 'filled': self.filled, 'remaining': self.remaining, - 'stopPrice': self.stop_price, 'datetime': self.order_date_utc.strftime('%Y-%m-%dT%H:%M:%S.%f'), 'timestamp': int(self.order_date_utc.timestamp() * 1000), 'status': self.status, @@ -178,7 +178,11 @@ class Order(ModelBase): 'info': {}, } if self.ft_order_side == 'stoploss': - order['ft_order_type'] = 'stoploss' + order.update({ + stopPriceName: self.stop_price, + 'ft_order_type': 'stoploss', + }) + return order def to_json(self, entry_side: str, minified: bool = False) -> Dict[str, Any]: @@ -208,6 +212,7 @@ class Order(ModelBase): 'order_type': self.order_type, 'price': self.price, 'remaining': self.remaining, + 'ft_fee_base': self.ft_fee_base, }) return resp @@ -243,15 +248,15 @@ class Order(ModelBase): else: logger.warning(f"Did not find order for {order}.") - @staticmethod + @classmethod def parse_from_ccxt_object( - order: Dict[str, Any], pair: str, side: str, - amount: Optional[float] = None, price: Optional[float] = None) -> 'Order': + cls, order: Dict[str, Any], pair: str, side: str, + amount: Optional[float] = None, price: Optional[float] = None) -> Self: """ Parse an order from a ccxt object and return a new order Object. Optional support for overriding amount and price is only used for test simplification. """ - o = Order( + o = cls( order_id=str(order['id']), ft_order_side=side, ft_pair=pair, @@ -279,7 +284,7 @@ class Order(ModelBase): return Order.session.scalars(select(Order).filter(Order.order_id == order_id)).first() -class LocalTrade(): +class LocalTrade: """ Trade database model. Used in backtesting - must be aligned to Trade model! @@ -422,7 +427,7 @@ class LocalTrade(): @property def close_date_utc(self): - return self.close_date.replace(tzinfo=timezone.utc) + return self.close_date.replace(tzinfo=timezone.utc) if self.close_date else None @property def entry_side(self) -> str: @@ -700,7 +705,7 @@ class LocalTrade(): self.stoploss_order_id = None self.close_rate_requested = self.stop_loss self.exit_reason = ExitType.STOPLOSS_ON_EXCHANGE.value - if self.is_open: + if self.is_open and order.safe_filled > 0: logger.info(f'{order.order_type.upper()} is hit for {self}.') else: raise ValueError(f'Unknown order type: {order.order_type}') @@ -708,7 +713,10 @@ class LocalTrade(): if order.ft_order_side != self.entry_side: amount_tr = amount_to_contract_precision(self.amount, self.amount_precision, self.precision_mode, self.contract_size) - if isclose(order.safe_amount_after_fee, amount_tr, abs_tol=MATH_CLOSE_PREC): + if ( + isclose(order.safe_amount_after_fee, amount_tr, abs_tol=MATH_CLOSE_PREC) + or order.safe_amount_after_fee > amount_tr + ): self.close(order.safe_price) else: self.recalc_trade_from_orders() @@ -1291,9 +1299,12 @@ class Trade(ModelBase, LocalTrade): Float(), nullable=True, default=None) # type: ignore def __init__(self, **kwargs): + from_json = kwargs.pop('__FROM_JSON', None) super().__init__(**kwargs) - self.realized_profit = 0 - self.recalc_open_trade_value() + if not from_json: + # Skip recalculation when loading from json + self.realized_profit = 0 + self.recalc_open_trade_value() @validates('enter_tag', 'exit_reason') def validate_string_len(self, key, value): @@ -1385,7 +1396,10 @@ class Trade(ModelBase, LocalTrade): e.g. `(trade_filter=Trade.id == trade_id)` :return: unsorted query object """ - return Trade.session.scalars(Trade.get_trades_query(trade_filter, include_orders)) + query = Trade.get_trades_query(trade_filter, include_orders) + # this sholud remain split. if use_db is False, session is not available and the above will + # raise an exception. + return Trade.session.scalars(query) @staticmethod def get_open_order_trades() -> List['Trade']: @@ -1632,8 +1646,8 @@ class Trade(ModelBase, LocalTrade): )).scalar_one() return trading_volume - @staticmethod - def from_json(json_str: str) -> 'Trade': + @classmethod + def from_json(cls, json_str: str) -> Self: """ Create a Trade instance from a json string. @@ -1643,7 +1657,8 @@ class Trade(ModelBase, LocalTrade): """ import rapidjson data = rapidjson.loads(json_str) - trade = Trade( + trade = cls( + __FROM_JSON=True, id=data["trade_id"], pair=data["pair"], base_currency=data["base_currency"], @@ -1698,6 +1713,7 @@ class Trade(ModelBase, LocalTrade): order_obj = Order( amount=order["amount"], + ft_amount=order["amount"], ft_order_side=order["ft_order_side"], ft_pair=order["pair"], ft_is_open=order["is_open"], @@ -1712,6 +1728,7 @@ class Trade(ModelBase, LocalTrade): if order["order_filled_timestamp"] else None), order_type=order["order_type"], price=order["price"], + ft_price=order["price"], remaining=order["remaining"], ) trade.orders.append(order_obj) diff --git a/freqtrade/plot/plotting.py b/freqtrade/plot/plotting.py index e415c4911..7fd20f041 100644 --- a/freqtrade/plot/plotting.py +++ b/freqtrade/plot/plotting.py @@ -633,7 +633,7 @@ def load_and_plot_trades(config: Config): """ strategy = StrategyResolver.load_strategy(config) - exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config) + exchange = ExchangeResolver.load_exchange(config) IStrategy.dp = DataProvider(config, exchange) strategy.ft_bot_start() strategy.bot_loop_start(datetime.now(timezone.utc)) @@ -678,7 +678,7 @@ def plot_profit(config: Config) -> None: if 'timeframe' not in config: raise OperationalException('Timeframe must be set in either config or via --timeframe.') - exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config) + exchange = ExchangeResolver.load_exchange(config) plot_elements = init_plotscript(config, list(exchange.markets)) trades = plot_elements['trades'] # Filter trades to relevant pairs diff --git a/freqtrade/plugins/pairlist/AgeFilter.py b/freqtrade/plugins/pairlist/AgeFilter.py index f9c02e250..bce789446 100644 --- a/freqtrade/plugins/pairlist/AgeFilter.py +++ b/freqtrade/plugins/pairlist/AgeFilter.py @@ -3,17 +3,17 @@ Minimum age (days listed) pair list filter """ import logging from copy import deepcopy +from datetime import timedelta from typing import Any, Dict, List, Optional -import arrow from pandas import DataFrame from freqtrade.constants import Config, ListPairsWithTimeframes from freqtrade.exceptions import OperationalException from freqtrade.exchange.types import Tickers from freqtrade.misc import plural -from freqtrade.plugins.pairlist.IPairList import IPairList -from freqtrade.util import PeriodicCache +from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter +from freqtrade.util import PeriodicCache, dt_floor_day, dt_now, dt_ts logger = logging.getLogger(__name__) @@ -68,6 +68,27 @@ class AgeFilter(IPairList): f"{self._max_days_listed} {plural(self._max_days_listed, 'day')}" ) if self._max_days_listed else '') + @staticmethod + def description() -> str: + return "Filter pairs by age (days listed)." + + @staticmethod + def available_parameters() -> Dict[str, PairlistParameter]: + return { + "min_days_listed": { + "type": "number", + "default": 10, + "description": "Minimum Days Listed", + "help": "Minimum number of days a pair must have been listed on the exchange.", + }, + "max_days_listed": { + "type": "number", + "default": None, + "description": "Maximum Days Listed", + "help": "Maximum number of days a pair must have been listed on the exchange.", + }, + } + def filter_pairlist(self, pairlist: List[str], tickers: Tickers) -> List[str]: """ :param pairlist: pairlist to filter or sort @@ -84,10 +105,7 @@ class AgeFilter(IPairList): since_days = -( self._max_days_listed if self._max_days_listed else self._min_days_listed ) - 1 - since_ms = int(arrow.utcnow() - .floor('day') - .shift(days=since_days) - .float_timestamp) * 1000 + since_ms = dt_ts(dt_floor_day(dt_now()) + timedelta(days=since_days)) candles = self._exchange.refresh_latest_ohlcv(needed_pairs, since_ms=since_ms, cache=False) if self._enabled: for p in deepcopy(pairlist): @@ -116,7 +134,7 @@ class AgeFilter(IPairList): ): # We have fetched at least the minimum required number of daily candles # Add to cache, store the time we last checked this symbol - self._symbolsChecked[pair] = arrow.utcnow().int_timestamp * 1000 + self._symbolsChecked[pair] = dt_ts() return True else: self.log_once(( @@ -127,6 +145,6 @@ class AgeFilter(IPairList): " or more than " f"{self._max_days_listed} {plural(self._max_days_listed, 'day')}" ) if self._max_days_listed else ''), logger.info) - self._symbolsCheckFailed[pair] = arrow.utcnow().int_timestamp * 1000 + self._symbolsCheckFailed[pair] = dt_ts() return False return False diff --git a/freqtrade/plugins/pairlist/IPairList.py b/freqtrade/plugins/pairlist/IPairList.py index d0382c778..d09b447d4 100644 --- a/freqtrade/plugins/pairlist/IPairList.py +++ b/freqtrade/plugins/pairlist/IPairList.py @@ -4,7 +4,7 @@ PairList Handler base class import logging from abc import ABC, abstractmethod, abstractproperty from copy import deepcopy -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Literal, Optional, TypedDict, Union from freqtrade.constants import Config from freqtrade.exceptions import OperationalException @@ -16,8 +16,44 @@ from freqtrade.mixins import LoggingMixin logger = logging.getLogger(__name__) +class __PairlistParameterBase(TypedDict): + description: str + help: str + + +class __NumberPairlistParameter(__PairlistParameterBase): + type: Literal["number"] + default: Union[int, float, None] + + +class __StringPairlistParameter(__PairlistParameterBase): + type: Literal["string"] + default: Union[str, None] + + +class __OptionPairlistParameter(__PairlistParameterBase): + type: Literal["option"] + default: Union[str, None] + options: List[str] + + +class __BoolPairlistParameter(__PairlistParameterBase): + type: Literal["boolean"] + default: Union[bool, None] + + +PairlistParameter = Union[ + __NumberPairlistParameter, + __StringPairlistParameter, + __OptionPairlistParameter, + __BoolPairlistParameter + ] + + class IPairList(LoggingMixin, ABC): + is_pairlist_generator = False + def __init__(self, exchange: Exchange, pairlistmanager, config: Config, pairlistconfig: Dict[str, Any], pairlist_pos: int) -> None: @@ -53,6 +89,37 @@ class IPairList(LoggingMixin, ABC): If no Pairlist requires tickers, an empty Dict is passed as tickers argument to filter_pairlist """ + return False + + @staticmethod + @abstractmethod + def description() -> str: + """ + Return description of this Pairlist Handler + -> Please overwrite in subclasses + """ + return "" + + @staticmethod + def available_parameters() -> Dict[str, PairlistParameter]: + """ + Return parameters used by this Pairlist Handler, and their type + contains a dictionary with the parameter name as key, and a dictionary + with the type and default value. + -> Please overwrite in subclasses + """ + return {} + + @staticmethod + def refresh_period_parameter() -> Dict[str, PairlistParameter]: + return { + "refresh_period": { + "type": "number", + "default": 1800, + "description": "Refresh period", + "help": "Refresh period in seconds", + } + } @abstractmethod def short_desc(self) -> str: diff --git a/freqtrade/plugins/pairlist/OffsetFilter.py b/freqtrade/plugins/pairlist/OffsetFilter.py index 8f21cdd85..af152c7bc 100644 --- a/freqtrade/plugins/pairlist/OffsetFilter.py +++ b/freqtrade/plugins/pairlist/OffsetFilter.py @@ -7,7 +7,7 @@ from typing import Any, Dict, List from freqtrade.constants import Config from freqtrade.exceptions import OperationalException from freqtrade.exchange.types import Tickers -from freqtrade.plugins.pairlist.IPairList import IPairList +from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter logger = logging.getLogger(__name__) @@ -43,6 +43,27 @@ class OffsetFilter(IPairList): return f"{self.name} - Taking {self._number_pairs} Pairs, starting from {self._offset}." return f"{self.name} - Offsetting pairs by {self._offset}." + @staticmethod + def description() -> str: + return "Offset pair list filter." + + @staticmethod + def available_parameters() -> Dict[str, PairlistParameter]: + return { + "offset": { + "type": "number", + "default": 0, + "description": "Offset", + "help": "Offset of the pairlist.", + }, + "number_assets": { + "type": "number", + "default": 0, + "description": "Number of assets", + "help": "Number of assets to use from the pairlist, starting from offset.", + }, + } + def filter_pairlist(self, pairlist: List[str], tickers: Tickers) -> List[str]: """ Filters and sorts pairlist and returns the whitelist again. diff --git a/freqtrade/plugins/pairlist/PerformanceFilter.py b/freqtrade/plugins/pairlist/PerformanceFilter.py index e7fcac1e4..b45259605 100644 --- a/freqtrade/plugins/pairlist/PerformanceFilter.py +++ b/freqtrade/plugins/pairlist/PerformanceFilter.py @@ -9,7 +9,7 @@ import pandas as pd from freqtrade.constants import Config from freqtrade.exchange.types import Tickers from freqtrade.persistence import Trade -from freqtrade.plugins.pairlist.IPairList import IPairList +from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter logger = logging.getLogger(__name__) @@ -40,6 +40,27 @@ class PerformanceFilter(IPairList): """ return f"{self.name} - Sorting pairs by performance." + @staticmethod + def description() -> str: + return "Filter pairs by performance." + + @staticmethod + def available_parameters() -> Dict[str, PairlistParameter]: + return { + "minutes": { + "type": "number", + "default": 0, + "description": "Minutes", + "help": "Consider trades from the last X minutes. 0 means all trades.", + }, + "min_profit": { + "type": "number", + "default": None, + "description": "Minimum profit", + "help": "Minimum profit in percent. Pairs with less profit are removed.", + }, + } + def filter_pairlist(self, pairlist: List[str], tickers: Tickers) -> List[str]: """ Filters and sorts pairlist and returns the allowlist again. @@ -68,10 +89,10 @@ class PerformanceFilter(IPairList): # Sort the list using: # - primarily performance (high to low) # - then count (low to high, so as to favor same performance with fewer trades) - # - then pair name alphametically + # - then by prior index, keeping original sorting order sorted_df = list_df.merge(performance, on='pair', how='left')\ - .fillna(0).sort_values(by=['count', 'prior_idx'], ascending=True)\ - .sort_values(by=['profit_ratio'], ascending=False) + .fillna(0).sort_values(by=['profit_ratio', 'count', 'prior_idx'], + ascending=[False, True, True]) if self._min_profit is not None: removed = sorted_df[sorted_df['profit_ratio'] < self._min_profit] for _, row in removed.iterrows(): diff --git a/freqtrade/plugins/pairlist/PrecisionFilter.py b/freqtrade/plugins/pairlist/PrecisionFilter.py index 2e74aa293..d354eaf63 100644 --- a/freqtrade/plugins/pairlist/PrecisionFilter.py +++ b/freqtrade/plugins/pairlist/PrecisionFilter.py @@ -46,6 +46,10 @@ class PrecisionFilter(IPairList): """ return f"{self.name} - Filtering untradable pairs." + @staticmethod + def description() -> str: + return "Filters low-value coins which would not allow setting stoplosses." + def _validate_pair(self, pair: str, ticker: Optional[Ticker]) -> bool: """ Check if pair has enough room to add a stoploss to avoid "unsellable" buys of very diff --git a/freqtrade/plugins/pairlist/PriceFilter.py b/freqtrade/plugins/pairlist/PriceFilter.py index 4d23de792..4c8781184 100644 --- a/freqtrade/plugins/pairlist/PriceFilter.py +++ b/freqtrade/plugins/pairlist/PriceFilter.py @@ -7,7 +7,7 @@ from typing import Any, Dict, Optional from freqtrade.constants import Config from freqtrade.exceptions import OperationalException from freqtrade.exchange.types import Ticker -from freqtrade.plugins.pairlist.IPairList import IPairList +from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter logger = logging.getLogger(__name__) @@ -65,6 +65,40 @@ class PriceFilter(IPairList): return f"{self.name} - No price filters configured." + @staticmethod + def description() -> str: + return "Filter pairs by price." + + @staticmethod + def available_parameters() -> Dict[str, PairlistParameter]: + return { + "low_price_ratio": { + "type": "number", + "default": 0, + "description": "Low price ratio", + "help": ("Remove pairs where a price move of 1 price unit (pip) " + "is above this ratio."), + }, + "min_price": { + "type": "number", + "default": 0, + "description": "Minimum price", + "help": "Remove pairs with a price below this value.", + }, + "max_price": { + "type": "number", + "default": 0, + "description": "Maximum price", + "help": "Remove pairs with a price above this value.", + }, + "max_value": { + "type": "number", + "default": 0, + "description": "Maximum value", + "help": "Remove pairs with a value (price * amount) above this value.", + }, + } + def _validate_pair(self, pair: str, ticker: Optional[Ticker]) -> bool: """ Check if if one price-step (pip) is > than a certain barrier. diff --git a/freqtrade/plugins/pairlist/ProducerPairList.py b/freqtrade/plugins/pairlist/ProducerPairList.py index 882d49b76..826f05913 100644 --- a/freqtrade/plugins/pairlist/ProducerPairList.py +++ b/freqtrade/plugins/pairlist/ProducerPairList.py @@ -8,7 +8,7 @@ from typing import Any, Dict, List, Optional from freqtrade.exceptions import OperationalException from freqtrade.exchange.types import Tickers -from freqtrade.plugins.pairlist.IPairList import IPairList +from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter logger = logging.getLogger(__name__) @@ -28,6 +28,7 @@ class ProducerPairList(IPairList): } ], """ + is_pairlist_generator = True def __init__(self, exchange, pairlistmanager, config: Dict[str, Any], pairlistconfig: Dict[str, Any], @@ -56,6 +57,28 @@ class ProducerPairList(IPairList): """ return f"{self.name} - {self._producer_name}" + @staticmethod + def description() -> str: + return "Get a pairlist from an upstream bot." + + @staticmethod + def available_parameters() -> Dict[str, PairlistParameter]: + return { + "number_assets": { + "type": "number", + "default": 0, + "description": "Number of assets", + "help": "Number of assets to use from the pairlist", + }, + "producer_name": { + "type": "string", + "default": "default", + "description": "Producer name", + "help": ("Name of the producer to use. Requires additional " + "external_message_consumer configuration.") + }, + } + def _filter_pairlist(self, pairlist: Optional[List[str]]): upstream_pairlist = self._pairlistmanager._dataprovider.get_producer_pairs( self._producer_name) diff --git a/freqtrade/plugins/pairlist/RemotePairList.py b/freqtrade/plugins/pairlist/RemotePairList.py index d077330e0..66b7d9496 100644 --- a/freqtrade/plugins/pairlist/RemotePairList.py +++ b/freqtrade/plugins/pairlist/RemotePairList.py @@ -15,7 +15,8 @@ from freqtrade import __version__ from freqtrade.constants import Config from freqtrade.exceptions import OperationalException from freqtrade.exchange.types import Tickers -from freqtrade.plugins.pairlist.IPairList import IPairList +from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter +from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist logger = logging.getLogger(__name__) @@ -23,6 +24,8 @@ logger = logging.getLogger(__name__) class RemotePairList(IPairList): + is_pairlist_generator = True + def __init__(self, exchange, pairlistmanager, config: Config, pairlistconfig: Dict[str, Any], pairlist_pos: int) -> None: @@ -38,6 +41,8 @@ class RemotePairList(IPairList): '`pairlist_url` not specified. Please check your configuration ' 'for "pairlist.config.pairlist_url"') + self._mode = self._pairlistconfig.get('mode', 'whitelist') + self._processing_mode = self._pairlistconfig.get('processing_mode', 'filter') self._number_pairs = self._pairlistconfig['number_assets'] self._refresh_period: int = self._pairlistconfig.get('refresh_period', 1800) self._keep_pairlist_on_failure = self._pairlistconfig.get('keep_pairlist_on_failure', True) @@ -48,6 +53,21 @@ class RemotePairList(IPairList): self._init_done = False self._last_pairlist: List[Any] = list() + if self._mode not in ['whitelist', 'blacklist']: + raise OperationalException( + '`mode` not configured correctly. Supported Modes ' + 'are "whitelist","blacklist"') + + if self._processing_mode not in ['filter', 'append']: + raise OperationalException( + '`processing_mode` not configured correctly. Supported Modes ' + 'are "filter","append"') + + if self._pairlist_pos == 0 and self._mode == 'blacklist': + raise OperationalException( + 'A `blacklist` mode RemotePairList can not be on the first ' + 'position of your pairlist.') + @property def needstickers(self) -> bool: """ @@ -63,6 +83,60 @@ class RemotePairList(IPairList): """ return f"{self.name} - {self._pairlistconfig['number_assets']} pairs from RemotePairlist." + @staticmethod + def description() -> str: + return "Retrieve pairs from a remote API or local file." + + @staticmethod + def available_parameters() -> Dict[str, PairlistParameter]: + return { + "pairlist_url": { + "type": "string", + "default": "", + "description": "URL to fetch pairlist from", + "help": "URL to fetch pairlist from", + }, + "number_assets": { + "type": "number", + "default": 30, + "description": "Number of assets", + "help": "Number of assets to use from the pairlist.", + }, + "mode": { + "type": "option", + "default": "whitelist", + "options": ["whitelist", "blacklist"], + "description": "Pairlist mode", + "help": "Should this pairlist operate as a whitelist or blacklist?", + }, + "processing_mode": { + "type": "option", + "default": "filter", + "options": ["filter", "append"], + "description": "Processing mode", + "help": "Append pairs to incomming pairlist or filter them?", + }, + **IPairList.refresh_period_parameter(), + "keep_pairlist_on_failure": { + "type": "boolean", + "default": True, + "description": "Keep last pairlist on failure", + "help": "Keep last pairlist on failure", + }, + "read_timeout": { + "type": "number", + "default": 60, + "description": "Read timeout", + "help": "Request timeout for remote pairlist", + }, + "bearer_token": { + "type": "string", + "default": "", + "description": "Bearer token", + "help": "Bearer token - used for auth against the upstream service.", + }, + } + def process_json(self, jsonparse) -> List[str]: pairlist = jsonparse.get('pairs', []) @@ -181,6 +255,7 @@ class RemotePairList(IPairList): self.log_once(f"Fetched pairs: {pairlist}", logger.debug) + pairlist = expand_pairlist(pairlist, list(self._exchange.get_markets().keys())) pairlist = self._whitelist_for_active_markets(pairlist) pairlist = pairlist[:self._number_pairs] @@ -208,6 +283,23 @@ class RemotePairList(IPairList): :return: new whitelist """ rpl_pairlist = self.gen_pairlist(tickers) - merged_list = pairlist + rpl_pairlist - merged_list = sorted(set(merged_list), key=merged_list.index) + merged_list = [] + filtered = [] + + if self._mode == "whitelist": + if self._processing_mode == "filter": + merged_list = [pair for pair in pairlist if pair in rpl_pairlist] + elif self._processing_mode == "append": + merged_list = pairlist + rpl_pairlist + merged_list = sorted(set(merged_list), key=merged_list.index) + else: + for pair in pairlist: + if pair not in rpl_pairlist: + merged_list.append(pair) + else: + filtered.append(pair) + if filtered: + self.log_once(f"Blacklist - Filtered out pairs: {filtered}", logger.info) + + merged_list = merged_list[:self._number_pairs] return merged_list diff --git a/freqtrade/plugins/pairlist/ShuffleFilter.py b/freqtrade/plugins/pairlist/ShuffleFilter.py index 76d7600d2..ce37dd8b5 100644 --- a/freqtrade/plugins/pairlist/ShuffleFilter.py +++ b/freqtrade/plugins/pairlist/ShuffleFilter.py @@ -9,7 +9,7 @@ from freqtrade.constants import Config from freqtrade.enums import RunMode from freqtrade.exchange import timeframe_to_seconds from freqtrade.exchange.types import Tickers -from freqtrade.plugins.pairlist.IPairList import IPairList +from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter from freqtrade.util.periodic_cache import PeriodicCache @@ -55,6 +55,28 @@ class ShuffleFilter(IPairList): return (f"{self.name} - Shuffling pairs every {self._shuffle_freq}" + (f", seed = {self._seed}." if self._seed is not None else ".")) + @staticmethod + def description() -> str: + return "Randomize pairlist order." + + @staticmethod + def available_parameters() -> Dict[str, PairlistParameter]: + return { + "shuffle_frequency": { + "type": "option", + "default": "candle", + "options": ["candle", "iteration"], + "description": "Shuffle frequency", + "help": "Shuffle frequency. Can be either 'candle' or 'iteration'.", + }, + "seed": { + "type": "number", + "default": None, + "description": "Random Seed", + "help": "Seed for random number generator. Not used in live mode.", + }, + } + def filter_pairlist(self, pairlist: List[str], tickers: Tickers) -> List[str]: """ Filters and sorts pairlist and returns the whitelist again. diff --git a/freqtrade/plugins/pairlist/SpreadFilter.py b/freqtrade/plugins/pairlist/SpreadFilter.py index d47b68568..ee41cbe66 100644 --- a/freqtrade/plugins/pairlist/SpreadFilter.py +++ b/freqtrade/plugins/pairlist/SpreadFilter.py @@ -7,7 +7,7 @@ from typing import Any, Dict, Optional from freqtrade.constants import Config from freqtrade.exceptions import OperationalException from freqtrade.exchange.types import Ticker -from freqtrade.plugins.pairlist.IPairList import IPairList +from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter logger = logging.getLogger(__name__) @@ -45,6 +45,21 @@ class SpreadFilter(IPairList): return (f"{self.name} - Filtering pairs with ask/bid diff above " f"{self._max_spread_ratio:.2%}.") + @staticmethod + def description() -> str: + return "Filter by bid/ask difference." + + @staticmethod + def available_parameters() -> Dict[str, PairlistParameter]: + return { + "max_spread_ratio": { + "type": "number", + "default": 0.005, + "description": "Max spread ratio", + "help": "Max spread ratio for a pair to be considered.", + }, + } + def _validate_pair(self, pair: str, ticker: Optional[Ticker]) -> bool: """ Validate spread for the ticker diff --git a/freqtrade/plugins/pairlist/StaticPairList.py b/freqtrade/plugins/pairlist/StaticPairList.py index 4b1961a53..16fb97adb 100644 --- a/freqtrade/plugins/pairlist/StaticPairList.py +++ b/freqtrade/plugins/pairlist/StaticPairList.py @@ -9,7 +9,7 @@ from typing import Any, Dict, List from freqtrade.constants import Config from freqtrade.exchange.types import Tickers -from freqtrade.plugins.pairlist.IPairList import IPairList +from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter logger = logging.getLogger(__name__) @@ -17,6 +17,8 @@ logger = logging.getLogger(__name__) class StaticPairList(IPairList): + is_pairlist_generator = True + def __init__(self, exchange, pairlistmanager, config: Config, pairlistconfig: Dict[str, Any], pairlist_pos: int) -> None: @@ -40,6 +42,21 @@ class StaticPairList(IPairList): """ return f"{self.name}" + @staticmethod + def description() -> str: + return "Use pairlist as configured in config." + + @staticmethod + def available_parameters() -> Dict[str, PairlistParameter]: + return { + "allow_inactive": { + "type": "boolean", + "default": False, + "description": "Allow inactive pairs", + "help": "Allow inactive pairs to be in the whitelist.", + }, + } + def gen_pairlist(self, tickers: Tickers) -> List[str]: """ Generate the pairlist diff --git a/freqtrade/plugins/pairlist/VolatilityFilter.py b/freqtrade/plugins/pairlist/VolatilityFilter.py index 401a2e86c..800bf3664 100644 --- a/freqtrade/plugins/pairlist/VolatilityFilter.py +++ b/freqtrade/plugins/pairlist/VolatilityFilter.py @@ -4,9 +4,9 @@ Volatility pairlist filter import logging import sys from copy import deepcopy +from datetime import timedelta from typing import Any, Dict, List, Optional -import arrow import numpy as np from cachetools import TTLCache from pandas import DataFrame @@ -15,7 +15,8 @@ from freqtrade.constants import Config, ListPairsWithTimeframes from freqtrade.exceptions import OperationalException from freqtrade.exchange.types import Tickers from freqtrade.misc import plural -from freqtrade.plugins.pairlist.IPairList import IPairList +from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter +from freqtrade.util import dt_floor_day, dt_now, dt_ts logger = logging.getLogger(__name__) @@ -63,6 +64,34 @@ class VolatilityFilter(IPairList): f"{self._min_volatility}-{self._max_volatility} " f" the last {self._days} {plural(self._days, 'day')}.") + @staticmethod + def description() -> str: + return "Filter pairs by their recent volatility." + + @staticmethod + def available_parameters() -> Dict[str, PairlistParameter]: + return { + "lookback_days": { + "type": "number", + "default": 10, + "description": "Lookback Days", + "help": "Number of days to look back at.", + }, + "min_volatility": { + "type": "number", + "default": 0, + "description": "Minimum Volatility", + "help": "Minimum volatility a pair must have to be considered.", + }, + "max_volatility": { + "type": "number", + "default": None, + "description": "Maximum Volatility", + "help": "Maximum volatility a pair must have to be considered.", + }, + **IPairList.refresh_period_parameter() + } + def filter_pairlist(self, pairlist: List[str], tickers: Tickers) -> List[str]: """ Validate trading range @@ -73,10 +102,7 @@ class VolatilityFilter(IPairList): needed_pairs: ListPairsWithTimeframes = [ (p, '1d', self._def_candletype) for p in pairlist if p not in self._pair_cache] - since_ms = (arrow.utcnow() - .floor('day') - .shift(days=-self._days - 1) - .int_timestamp) * 1000 + since_ms = dt_ts(dt_floor_day(dt_now()) - timedelta(days=self._days)) # Get all candles candles = {} if needed_pairs: @@ -105,7 +131,7 @@ class VolatilityFilter(IPairList): result = False if daily_candles is not None and not daily_candles.empty: - returns = (np.log(daily_candles.close / daily_candles.close.shift(-1))) + returns = (np.log(daily_candles["close"].shift(1) / daily_candles["close"])) returns.fillna(0, inplace=True) volatility_series = returns.rolling(window=self._days).std() * np.sqrt(self._days) diff --git a/freqtrade/plugins/pairlist/VolumePairList.py b/freqtrade/plugins/pairlist/VolumePairList.py index 2649a8425..9e4a4fca9 100644 --- a/freqtrade/plugins/pairlist/VolumePairList.py +++ b/freqtrade/plugins/pairlist/VolumePairList.py @@ -4,7 +4,7 @@ Volume PairList provider Provides dynamic pair list based on trade volumes """ import logging -from datetime import datetime, timedelta, timezone +from datetime import timedelta from typing import Any, Dict, List, Literal from cachetools import TTLCache @@ -13,8 +13,8 @@ from freqtrade.constants import Config, ListPairsWithTimeframes from freqtrade.exceptions import OperationalException from freqtrade.exchange import timeframe_to_minutes, timeframe_to_prev_date from freqtrade.exchange.types import Tickers -from freqtrade.misc import format_ms_time -from freqtrade.plugins.pairlist.IPairList import IPairList +from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter +from freqtrade.util import dt_now, format_ms_time logger = logging.getLogger(__name__) @@ -25,6 +25,8 @@ SORT_VALUES = ['quoteVolume'] class VolumePairList(IPairList): + is_pairlist_generator = True + def __init__(self, exchange, pairlistmanager, config: Config, pairlistconfig: Dict[str, Any], pairlist_pos: int) -> None: @@ -111,6 +113,53 @@ class VolumePairList(IPairList): """ return f"{self.name} - top {self._pairlistconfig['number_assets']} volume pairs." + @staticmethod + def description() -> str: + return "Provides dynamic pair list based on trade volumes." + + @staticmethod + def available_parameters() -> Dict[str, PairlistParameter]: + return { + "number_assets": { + "type": "number", + "default": 30, + "description": "Number of assets", + "help": "Number of assets to use from the pairlist", + }, + "sort_key": { + "type": "option", + "default": "quoteVolume", + "options": SORT_VALUES, + "description": "Sort key", + "help": "Sort key to use for sorting the pairlist.", + }, + "min_value": { + "type": "number", + "default": 0, + "description": "Minimum value", + "help": "Minimum value to use for filtering the pairlist.", + }, + **IPairList.refresh_period_parameter(), + "lookback_days": { + "type": "number", + "default": 0, + "description": "Lookback Days", + "help": "Number of days to look back at.", + }, + "lookback_timeframe": { + "type": "string", + "default": "", + "description": "Lookback Timeframe", + "help": "Timeframe to use for lookback.", + }, + "lookback_period": { + "type": "number", + "default": 0, + "description": "Lookback Period", + "help": "Number of periods to look back at.", + }, + } + def gen_pairlist(self, tickers: Tickers) -> List[str]: """ Generate the pairlist @@ -161,13 +210,13 @@ class VolumePairList(IPairList): # get lookback period in ms, for exchange ohlcv fetch since_ms = int(timeframe_to_prev_date( self._lookback_timeframe, - datetime.now(timezone.utc) + timedelta( + dt_now() + timedelta( minutes=-(self._lookback_period * self._tf_in_min) - self._tf_in_min) ).timestamp()) * 1000 to_ms = int(timeframe_to_prev_date( self._lookback_timeframe, - datetime.now(timezone.utc) - timedelta(minutes=self._tf_in_min) + dt_now() - timedelta(minutes=self._tf_in_min) ).timestamp()) * 1000 # todo: utc date output for starting date diff --git a/freqtrade/plugins/pairlist/rangestabilityfilter.py b/freqtrade/plugins/pairlist/rangestabilityfilter.py index 546b026cb..f294b882b 100644 --- a/freqtrade/plugins/pairlist/rangestabilityfilter.py +++ b/freqtrade/plugins/pairlist/rangestabilityfilter.py @@ -3,9 +3,9 @@ Rate of change pairlist filter """ import logging from copy import deepcopy +from datetime import timedelta from typing import Any, Dict, List, Optional -import arrow from cachetools import TTLCache from pandas import DataFrame @@ -13,7 +13,8 @@ from freqtrade.constants import Config, ListPairsWithTimeframes from freqtrade.exceptions import OperationalException from freqtrade.exchange.types import Tickers from freqtrade.misc import plural -from freqtrade.plugins.pairlist.IPairList import IPairList +from freqtrade.plugins.pairlist.IPairList import IPairList, PairlistParameter +from freqtrade.util import dt_floor_day, dt_now, dt_ts logger = logging.getLogger(__name__) @@ -61,6 +62,34 @@ class RangeStabilityFilter(IPairList): f"{self._min_rate_of_change}{max_rate_desc} over the " f"last {plural(self._days, 'day')}.") + @staticmethod + def description() -> str: + return "Filters pairs by their rate of change." + + @staticmethod + def available_parameters() -> Dict[str, PairlistParameter]: + return { + "lookback_days": { + "type": "number", + "default": 10, + "description": "Lookback Days", + "help": "Number of days to look back at.", + }, + "min_rate_of_change": { + "type": "number", + "default": 0.01, + "description": "Minimum Rate of Change", + "help": "Minimum rate of change to filter pairs.", + }, + "max_rate_of_change": { + "type": "number", + "default": None, + "description": "Maximum Rate of Change", + "help": "Maximum rate of change to filter pairs.", + }, + **IPairList.refresh_period_parameter() + } + def filter_pairlist(self, pairlist: List[str], tickers: Tickers) -> List[str]: """ Validate trading range @@ -71,10 +100,7 @@ class RangeStabilityFilter(IPairList): needed_pairs: ListPairsWithTimeframes = [ (p, '1d', self._def_candletype) for p in pairlist if p not in self._pair_cache] - since_ms = (arrow.utcnow() - .floor('day') - .shift(days=-self._days - 1) - .int_timestamp) * 1000 + since_ms = dt_ts(dt_floor_day(dt_now()) - timedelta(days=self._days - 1)) # Get all candles candles = {} if needed_pairs: diff --git a/freqtrade/plugins/protectionmanager.py b/freqtrade/plugins/protectionmanager.py index 54432e677..6e55ade11 100644 --- a/freqtrade/plugins/protectionmanager.py +++ b/freqtrade/plugins/protectionmanager.py @@ -15,7 +15,7 @@ from freqtrade.resolvers import ProtectionResolver logger = logging.getLogger(__name__) -class ProtectionManager(): +class ProtectionManager: def __init__(self, config: Config, protections: List) -> None: self._config = config diff --git a/freqtrade/resolvers/exchange_resolver.py b/freqtrade/resolvers/exchange_resolver.py index 54a488e8d..2f912c4ab 100644 --- a/freqtrade/resolvers/exchange_resolver.py +++ b/freqtrade/resolvers/exchange_resolver.py @@ -2,9 +2,11 @@ This module loads custom exchanges """ import logging +from inspect import isclass +from typing import Any, Dict, List, Optional import freqtrade.exchange as exchanges -from freqtrade.constants import Config +from freqtrade.constants import Config, ExchangeConfig from freqtrade.exchange import MAP_EXCHANGE_CHILDCLASS, Exchange from freqtrade.resolvers import IResolver @@ -19,13 +21,14 @@ class ExchangeResolver(IResolver): object_type = Exchange @staticmethod - def load_exchange(exchange_name: str, config: Config, validate: bool = True, - load_leverage_tiers: bool = False) -> Exchange: + def load_exchange(config: Config, *, exchange_config: Optional[ExchangeConfig] = None, + validate: bool = True, load_leverage_tiers: bool = False) -> Exchange: """ Load the custom class from config parameter :param exchange_name: name of the Exchange to load :param config: configuration dictionary """ + exchange_name: str = config['exchange']['name'] # Map exchange name to avoid duplicate classes for identical exchanges exchange_name = MAP_EXCHANGE_CHILDCLASS.get(exchange_name, exchange_name) exchange_name = exchange_name.title() @@ -36,13 +39,14 @@ class ExchangeResolver(IResolver): kwargs={ 'config': config, 'validate': validate, + 'exchange_config': exchange_config, 'load_leverage_tiers': load_leverage_tiers} ) except ImportError: logger.info( f"No {exchange_name} specific subclass found. Using the generic class instead.") if not exchange: - exchange = Exchange(config, validate=validate) + exchange = Exchange(config, validate=validate, exchange_config=exchange_config,) return exchange @staticmethod @@ -69,3 +73,26 @@ class ExchangeResolver(IResolver): f"Impossible to load Exchange '{exchange_name}'. This class does not exist " "or contains Python code errors." ) + + @classmethod + def search_all_objects(cls, config: Config, enum_failed: bool, + recursive: bool = False) -> List[Dict[str, Any]]: + """ + Searches for valid objects + :param config: Config object + :param enum_failed: If True, will return None for modules which fail. + Otherwise, failing modules are skipped. + :param recursive: Recursively walk directory tree searching for strategies + :return: List of dicts containing 'name', 'class' and 'location' entries + """ + result = [] + for exchange_name in dir(exchanges): + exchange = getattr(exchanges, exchange_name) + if isclass(exchange) and issubclass(exchange, Exchange): + result.append({ + 'name': exchange_name, + 'class': exchange, + 'location': exchange.__module__, + 'location_rel: ': exchange.__module__.replace('freqtrade.', ''), + }) + return result diff --git a/freqtrade/resolvers/freqaimodel_resolver.py b/freqtrade/resolvers/freqaimodel_resolver.py index 48c3facac..3696b9e56 100644 --- a/freqtrade/resolvers/freqaimodel_resolver.py +++ b/freqtrade/resolvers/freqaimodel_resolver.py @@ -34,7 +34,7 @@ class FreqaiModelResolver(IResolver): Load the custom class from config parameter :param config: configuration dictionary """ - disallowed_models = ["BaseRegressionModel", "BaseTensorFlowModel"] + disallowed_models = ["BaseRegressionModel"] freqaimodel_name = config.get("freqaimodel") if not freqaimodel_name: diff --git a/freqtrade/resolvers/iresolver.py b/freqtrade/resolvers/iresolver.py index 2b20560e2..1557f0f35 100644 --- a/freqtrade/resolvers/iresolver.py +++ b/freqtrade/resolvers/iresolver.py @@ -41,7 +41,7 @@ class IResolver: object_type: Type[Any] object_type_str: str user_subdir: Optional[str] = None - initial_search_path: Optional[Path] + initial_search_path: Optional[Path] = None # Optional config setting containing a path (strategy_path, freqaimodel_path) extra_path: Optional[str] = None diff --git a/freqtrade/rpc/api_server/api_background_tasks.py b/freqtrade/rpc/api_server/api_background_tasks.py new file mode 100644 index 000000000..c13fa31e4 --- /dev/null +++ b/freqtrade/rpc/api_server/api_background_tasks.py @@ -0,0 +1,145 @@ +import logging +from copy import deepcopy + +from fastapi import APIRouter, BackgroundTasks, Depends +from fastapi.exceptions import HTTPException + +from freqtrade.constants import Config +from freqtrade.enums import CandleType +from freqtrade.exceptions import OperationalException +from freqtrade.rpc.api_server.api_schemas import (BackgroundTaskStatus, BgJobStarted, + ExchangeModePayloadMixin, PairListsPayload, + PairListsResponse, WhitelistEvaluateResponse) +from freqtrade.rpc.api_server.deps import get_config, get_exchange +from freqtrade.rpc.api_server.webserver_bgwork import ApiBG + + +logger = logging.getLogger(__name__) + +# Private API, protected by authentication and webserver_mode dependency +router = APIRouter() + + +@router.get('/background/{jobid}', response_model=BackgroundTaskStatus, tags=['webserver']) +def background_job(jobid: str): + if not (job := ApiBG.jobs.get(jobid)): + raise HTTPException(status_code=404, detail='Job not found.') + + return { + 'job_id': jobid, + 'job_category': job['category'], + 'status': job['status'], + 'running': job['is_running'], + 'progress': job.get('progress'), + # 'job_error': job['error'], + } + + +@router.get('/pairlists/available', + response_model=PairListsResponse, tags=['pairlists', 'webserver']) +def list_pairlists(config=Depends(get_config)): + from freqtrade.resolvers import PairListResolver + pairlists = PairListResolver.search_all_objects( + config, False) + pairlists = sorted(pairlists, key=lambda x: x['name']) + + return {'pairlists': [{ + "name": x['name'], + "is_pairlist_generator": x['class'].is_pairlist_generator, + "params": x['class'].available_parameters(), + "description": x['class'].description(), + } for x in pairlists + ]} + + +def __run_pairlist(job_id: str, config_loc: Config): + try: + + ApiBG.jobs[job_id]['is_running'] = True + from freqtrade.plugins.pairlistmanager import PairListManager + + exchange = get_exchange(config_loc) + pairlists = PairListManager(exchange, config_loc) + pairlists.refresh_pairlist() + ApiBG.jobs[job_id]['result'] = { + 'method': pairlists.name_list, + 'length': len(pairlists.whitelist), + 'whitelist': pairlists.whitelist + } + ApiBG.jobs[job_id]['status'] = 'success' + except (OperationalException, Exception) as e: + logger.exception(e) + ApiBG.jobs[job_id]['error'] = str(e) + ApiBG.jobs[job_id]['status'] = 'failed' + finally: + ApiBG.jobs[job_id]['is_running'] = False + ApiBG.pairlist_running = False + + +@router.post('/pairlists/evaluate', response_model=BgJobStarted, tags=['pairlists', 'webserver']) +def pairlists_evaluate(payload: PairListsPayload, background_tasks: BackgroundTasks, + config=Depends(get_config)): + if ApiBG.pairlist_running: + raise HTTPException(status_code=400, detail='Pairlist evaluation is already running.') + + config_loc = deepcopy(config) + config_loc['stake_currency'] = payload.stake_currency + config_loc['pairlists'] = payload.pairlists + handleExchangePayload(payload, config_loc) + # TODO: overwrite blacklist? make it optional and fall back to the one in config? + # Outcome depends on the UI approach. + config_loc['exchange']['pair_blacklist'] = payload.blacklist + # Random job id + job_id = ApiBG.get_job_id() + + ApiBG.jobs[job_id] = { + 'category': 'pairlist', + 'status': 'pending', + 'progress': None, + 'is_running': False, + 'result': {}, + 'error': None, + } + background_tasks.add_task(__run_pairlist, job_id, config_loc) + ApiBG.pairlist_running = True + + return { + 'status': 'Pairlist evaluation started in background.', + 'job_id': job_id, + } + + +def handleExchangePayload(payload: ExchangeModePayloadMixin, config_loc: Config): + """ + Handle exchange and trading mode payload. + Updates the configuration with the payload values. + """ + if payload.exchange: + config_loc['exchange']['name'] = payload.exchange + if payload.trading_mode: + config_loc['trading_mode'] = payload.trading_mode + config_loc['candle_type_def'] = CandleType.get_default( + config_loc.get('trading_mode', 'spot') or 'spot') + if payload.margin_mode: + config_loc['margin_mode'] = payload.margin_mode + + +@router.get('/pairlists/evaluate/{jobid}', response_model=WhitelistEvaluateResponse, + tags=['pairlists', 'webserver']) +def pairlists_evaluate_get(jobid: str): + if not (job := ApiBG.jobs.get(jobid)): + raise HTTPException(status_code=404, detail='Job not found.') + + if job['is_running']: + raise HTTPException(status_code=400, detail='Job not finished yet.') + + if error := job['error']: + return { + 'status': 'failed', + 'error': error, + } + + return { + 'status': 'success', + 'result': job['result'], + } diff --git a/freqtrade/rpc/api_server/api_backtest.py b/freqtrade/rpc/api_server/api_backtest.py index d9d7a27f1..411ba4978 100644 --- a/freqtrade/rpc/api_server/api_backtest.py +++ b/freqtrade/rpc/api_server/api_backtest.py @@ -8,36 +8,111 @@ from fastapi import APIRouter, BackgroundTasks, Depends from fastapi.exceptions import HTTPException from freqtrade.configuration.config_validation import validate_config_consistency +from freqtrade.constants import Config from freqtrade.data.btanalysis import get_backtest_resultlist, load_and_merge_backtest_result from freqtrade.enums import BacktestState from freqtrade.exceptions import DependencyException, OperationalException +from freqtrade.exchange.common import remove_exchange_credentials from freqtrade.misc import deep_merge_dicts from freqtrade.rpc.api_server.api_schemas import (BacktestHistoryEntry, BacktestRequest, BacktestResponse) -from freqtrade.rpc.api_server.deps import get_config, is_webserver_mode -from freqtrade.rpc.api_server.webserver import ApiServer +from freqtrade.rpc.api_server.deps import get_config +from freqtrade.rpc.api_server.webserver_bgwork import ApiBG from freqtrade.rpc.rpc import RPCException logger = logging.getLogger(__name__) -# Private API, protected by authentication +# Private API, protected by authentication and webserver_mode dependency router = APIRouter() +def __run_backtest_bg(btconfig: Config): + from freqtrade.optimize.optimize_reports import generate_backtest_stats, store_backtest_stats + from freqtrade.resolvers import StrategyResolver + asyncio.set_event_loop(asyncio.new_event_loop()) + try: + # Reload strategy + lastconfig = ApiBG.bt['last_config'] + strat = StrategyResolver.load_strategy(btconfig) + validate_config_consistency(btconfig) + + if ( + not ApiBG.bt['bt'] + or lastconfig.get('timeframe') != strat.timeframe + or lastconfig.get('timeframe_detail') != btconfig.get('timeframe_detail') + or lastconfig.get('timerange') != btconfig['timerange'] + ): + from freqtrade.optimize.backtesting import Backtesting + ApiBG.bt['bt'] = Backtesting(btconfig) + ApiBG.bt['bt'].load_bt_data_detail() + else: + ApiBG.bt['bt'].config = btconfig + ApiBG.bt['bt'].init_backtest() + # Only reload data if timeframe changed. + if ( + not ApiBG.bt['data'] + or not ApiBG.bt['timerange'] + or lastconfig.get('timeframe') != strat.timeframe + or lastconfig.get('timerange') != btconfig['timerange'] + ): + ApiBG.bt['data'], ApiBG.bt['timerange'] = ApiBG.bt[ + 'bt'].load_bt_data() + + lastconfig['timerange'] = btconfig['timerange'] + lastconfig['timeframe'] = strat.timeframe + lastconfig['protections'] = btconfig.get('protections', []) + lastconfig['enable_protections'] = btconfig.get('enable_protections') + lastconfig['dry_run_wallet'] = btconfig.get('dry_run_wallet') + + ApiBG.bt['bt'].enable_protections = btconfig.get('enable_protections', False) + ApiBG.bt['bt'].strategylist = [strat] + ApiBG.bt['bt'].results = {} + ApiBG.bt['bt'].load_prior_backtest() + + ApiBG.bt['bt'].abort = False + if (ApiBG.bt['bt'].results and + strat.get_strategy_name() in ApiBG.bt['bt'].results['strategy']): + # When previous result hash matches - reuse that result and skip backtesting. + logger.info(f'Reusing result of previous backtest for {strat.get_strategy_name()}') + else: + min_date, max_date = ApiBG.bt['bt'].backtest_one_strategy( + strat, ApiBG.bt['data'], ApiBG.bt['timerange']) + + ApiBG.bt['bt'].results = generate_backtest_stats( + ApiBG.bt['data'], ApiBG.bt['bt'].all_results, + min_date=min_date, max_date=max_date) + + if btconfig.get('export', 'none') == 'trades': + store_backtest_stats( + btconfig['exportfilename'], ApiBG.bt['bt'].results, + datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + ) + + logger.info("Backtest finished.") + + except (Exception, OperationalException, DependencyException) as e: + logger.exception(f"Backtesting caused an error: {e}") + ApiBG.bt['bt_error'] = str(e) + pass + finally: + ApiBG.bgtask_running = False + + @router.post('/backtest', response_model=BacktestResponse, tags=['webserver', 'backtest']) -async def api_start_backtest( # noqa: C901 +async def api_start_backtest( bt_settings: BacktestRequest, background_tasks: BackgroundTasks, - config=Depends(get_config), ws_mode=Depends(is_webserver_mode)): - ApiServer._bt['bt_error'] = None + config=Depends(get_config)): + ApiBG.bt['bt_error'] = None """Start backtesting if not done so already""" - if ApiServer._bgtask_running: + if ApiBG.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) + remove_exchange_credentials(btconfig['exchange'], True) settings = dict(bt_settings) if settings.get('freqai', None) is not None: settings['freqai'] = dict(settings['freqai']) @@ -54,80 +129,9 @@ async def api_start_backtest( # noqa: C901 # Start backtesting # Initialize backtesting object - def run_backtest(): - from freqtrade.optimize.optimize_reports import (generate_backtest_stats, - store_backtest_stats) - from freqtrade.resolvers import StrategyResolver - asyncio.set_event_loop(asyncio.new_event_loop()) - try: - # Reload strategy - lastconfig = ApiServer._bt['last_config'] - strat = StrategyResolver.load_strategy(btconfig) - validate_config_consistency(btconfig) - if ( - not ApiServer._bt['bt'] - or lastconfig.get('timeframe') != strat.timeframe - or lastconfig.get('timeframe_detail') != btconfig.get('timeframe_detail') - or lastconfig.get('timerange') != btconfig['timerange'] - ): - from freqtrade.optimize.backtesting import Backtesting - ApiServer._bt['bt'] = Backtesting(btconfig) - ApiServer._bt['bt'].load_bt_data_detail() - else: - ApiServer._bt['bt'].config = btconfig - ApiServer._bt['bt'].init_backtest() - # Only reload data if timeframe changed. - if ( - not ApiServer._bt['data'] - or not ApiServer._bt['timerange'] - or lastconfig.get('timeframe') != strat.timeframe - or lastconfig.get('timerange') != btconfig['timerange'] - ): - ApiServer._bt['data'], ApiServer._bt['timerange'] = ApiServer._bt[ - 'bt'].load_bt_data() - - lastconfig['timerange'] = btconfig['timerange'] - lastconfig['timeframe'] = strat.timeframe - lastconfig['protections'] = btconfig.get('protections', []) - lastconfig['enable_protections'] = btconfig.get('enable_protections') - lastconfig['dry_run_wallet'] = btconfig.get('dry_run_wallet') - - ApiServer._bt['bt'].enable_protections = btconfig.get('enable_protections', False) - ApiServer._bt['bt'].strategylist = [strat] - ApiServer._bt['bt'].results = {} - ApiServer._bt['bt'].load_prior_backtest() - - ApiServer._bt['bt'].abort = False - if (ApiServer._bt['bt'].results and - strat.get_strategy_name() in ApiServer._bt['bt'].results['strategy']): - # When previous result hash matches - reuse that result and skip backtesting. - logger.info(f'Reusing result of previous backtest for {strat.get_strategy_name()}') - else: - min_date, max_date = ApiServer._bt['bt'].backtest_one_strategy( - strat, ApiServer._bt['data'], ApiServer._bt['timerange']) - - ApiServer._bt['bt'].results = generate_backtest_stats( - ApiServer._bt['data'], ApiServer._bt['bt'].all_results, - min_date=min_date, max_date=max_date) - - if btconfig.get('export', 'none') == 'trades': - store_backtest_stats( - btconfig['exportfilename'], ApiServer._bt['bt'].results, - datetime.now().strftime("%Y-%m-%d_%H-%M-%S") - ) - - logger.info("Backtest finished.") - - except (Exception, OperationalException, DependencyException) as e: - logger.exception(f"Backtesting caused an error: {e}") - ApiServer._bt['bt_error'] = str(e) - pass - finally: - ApiServer._bgtask_running = False - - background_tasks.add_task(run_backtest) - ApiServer._bgtask_running = True + background_tasks.add_task(__run_backtest_bg, btconfig=btconfig) + ApiBG.bgtask_running = True return { "status": "running", @@ -139,24 +143,24 @@ async def api_start_backtest( # noqa: C901 @router.get('/backtest', response_model=BacktestResponse, tags=['webserver', 'backtest']) -def api_get_backtest(ws_mode=Depends(is_webserver_mode)): +def api_get_backtest(): """ Get backtesting result. Returns Result after backtesting has been ran. """ from freqtrade.persistence import LocalTrade - if ApiServer._bgtask_running: + if ApiBG.bgtask_running: return { "status": "running", "running": True, - "step": (ApiServer._bt['bt'].progress.action if ApiServer._bt['bt'] + "step": (ApiBG.bt['bt'].progress.action if ApiBG.bt['bt'] else str(BacktestState.STARTUP)), - "progress": ApiServer._bt['bt'].progress.progress if ApiServer._bt['bt'] else 0, + "progress": ApiBG.bt['bt'].progress.progress if ApiBG.bt['bt'] else 0, "trade_count": len(LocalTrade.trades), "status_msg": "Backtest running", } - if not ApiServer._bt['bt']: + if not ApiBG.bt['bt']: return { "status": "not_started", "running": False, @@ -164,13 +168,13 @@ def api_get_backtest(ws_mode=Depends(is_webserver_mode)): "progress": 0, "status_msg": "Backtest not yet executed" } - if ApiServer._bt['bt_error']: + if ApiBG.bt['bt_error']: return { "status": "error", "running": False, "step": "", "progress": 0, - "status_msg": f"Backtest failed with {ApiServer._bt['bt_error']}" + "status_msg": f"Backtest failed with {ApiBG.bt['bt_error']}" } return { @@ -179,14 +183,14 @@ def api_get_backtest(ws_mode=Depends(is_webserver_mode)): "status_msg": "Backtest ended", "step": "finished", "progress": 1, - "backtest_result": ApiServer._bt['bt'].results, + "backtest_result": ApiBG.bt['bt'].results, } @router.delete('/backtest', response_model=BacktestResponse, tags=['webserver', 'backtest']) -def api_delete_backtest(ws_mode=Depends(is_webserver_mode)): +def api_delete_backtest(): """Reset backtesting""" - if ApiServer._bgtask_running: + if ApiBG.bgtask_running: return { "status": "running", "running": True, @@ -194,12 +198,12 @@ def api_delete_backtest(ws_mode=Depends(is_webserver_mode)): "progress": 0, "status_msg": "Backtest running", } - if ApiServer._bt['bt']: - ApiServer._bt['bt'].cleanup() - del ApiServer._bt['bt'] - ApiServer._bt['bt'] = None - del ApiServer._bt['data'] - ApiServer._bt['data'] = None + if ApiBG.bt['bt']: + ApiBG.bt['bt'].cleanup() + del ApiBG.bt['bt'] + ApiBG.bt['bt'] = None + del ApiBG.bt['data'] + ApiBG.bt['data'] = None logger.info("Backtesting reset") return { "status": "reset", @@ -211,8 +215,8 @@ def api_delete_backtest(ws_mode=Depends(is_webserver_mode)): @router.get('/backtest/abort', response_model=BacktestResponse, tags=['webserver', 'backtest']) -def api_backtest_abort(ws_mode=Depends(is_webserver_mode)): - if not ApiServer._bgtask_running: +def api_backtest_abort(): + if not ApiBG.bgtask_running: return { "status": "not_running", "running": False, @@ -220,7 +224,7 @@ def api_backtest_abort(ws_mode=Depends(is_webserver_mode)): "progress": 0, "status_msg": "Backtest ended", } - ApiServer._bt['bt'].abort = True + ApiBG.bt['bt'].abort = True return { "status": "stopping", "running": False, @@ -232,15 +236,14 @@ def api_backtest_abort(ws_mode=Depends(is_webserver_mode)): @router.get('/backtest/history', response_model=List[BacktestHistoryEntry], tags=['webserver', 'backtest']) -def api_backtest_history(config=Depends(get_config), ws_mode=Depends(is_webserver_mode)): +def api_backtest_history(config=Depends(get_config)): # Get backtest result history, read from metadata files return get_backtest_resultlist(config['user_data_dir'] / 'backtest_results') @router.get('/backtest/history/result', response_model=BacktestResponse, tags=['webserver', 'backtest']) -def api_backtest_history_result(filename: str, strategy: str, config=Depends(get_config), - ws_mode=Depends(is_webserver_mode)): +def api_backtest_history_result(filename: str, strategy: str, config=Depends(get_config)): # Get backtest result history, read from metadata files fn = config['user_data_dir'] / 'backtest_results' / filename results: Dict[str, Any] = { diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index 53bf7558f..2c2d40a3d 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -4,7 +4,14 @@ from typing import Any, Dict, List, Optional, Union from pydantic import BaseModel from freqtrade.constants import DATETIME_PRINT_FORMAT, IntOrInf -from freqtrade.enums import OrderTypeValues, SignalDirection, TradingMode +from freqtrade.enums import MarginMode, OrderTypeValues, SignalDirection, TradingMode +from freqtrade.types import ValidExchangesType + + +class ExchangeModePayloadMixin(BaseModel): + trading_mode: Optional[TradingMode] + margin_mode: Optional[MarginMode] + exchange: Optional[str] class Ping(BaseModel): @@ -27,6 +34,23 @@ class StatusMsg(BaseModel): status: str +class BgJobStarted(StatusMsg): + job_id: str + + +class BackgroundTaskStatus(BaseModel): + job_id: str + job_category: str + status: str + running: bool + progress: Optional[float] + + +class BackgroundTaskResult(BaseModel): + error: Optional[str] + status: str + + class ResultMsg(BaseModel): result: str @@ -36,20 +60,25 @@ class Balance(BaseModel): free: float balance: float used: float + bot_owned: Optional[float] est_stake: float + est_stake_bot: Optional[float] stake: str # Starting with 2.x side: str leverage: float is_position: bool position: float + is_bot_managed: bool class Balances(BaseModel): currencies: List[Balance] total: float + total_bot: float symbol: str value: float + value_bot: float stake: str note: str starting_capital: float @@ -95,8 +124,10 @@ class Profit(BaseModel): trade_count: int closed_trade_count: int first_trade_date: str + first_trade_humanized: str first_trade_timestamp: int latest_trade_date: str + latest_trade_humanized: str latest_trade_timestamp: int avg_duration: str best_pair: str @@ -207,6 +238,7 @@ class OrderSchema(BaseModel): is_open: bool order_timestamp: Optional[int] order_filled_timestamp: Optional[int] + ft_fee_base: Optional[float] class TradeSchema(BaseModel): @@ -369,6 +401,10 @@ class WhitelistResponse(BaseModel): method: List[str] +class WhitelistEvaluateResponse(BackgroundTaskResult): + result: Optional[WhitelistResponse] + + class DeleteTrade(BaseModel): cancel_order_count: int result: str @@ -389,6 +425,27 @@ class StrategyListResponse(BaseModel): strategies: List[str] +class ExchangeListResponse(BaseModel): + exchanges: List[ValidExchangesType] + + +class PairListResponse(BaseModel): + name: str + description: str + is_pairlist_generator: bool + params: Dict[str, Any] + + +class PairListsResponse(BaseModel): + pairlists: List[PairListResponse] + + +class PairListsPayload(ExchangeModePayloadMixin, BaseModel): + pairlists: List[Dict[str, Any]] + blacklist: List[str] + stake_currency: str + + class FreqAIModelListResponse(BaseModel): freqaimodels: List[str] diff --git a/freqtrade/rpc/api_server/api_v1.py b/freqtrade/rpc/api_server/api_v1.py index 8ea70bb69..143f110f0 100644 --- a/freqtrade/rpc/api_server/api_v1.py +++ b/freqtrade/rpc/api_server/api_v1.py @@ -12,7 +12,8 @@ from freqtrade.exceptions import OperationalException from freqtrade.rpc import RPC from freqtrade.rpc.api_server.api_schemas import (AvailablePairs, Balances, BlacklistPayload, BlacklistResponse, Count, Daily, - DeleteLockRequest, DeleteTrade, ForceEnterPayload, + DeleteLockRequest, DeleteTrade, + ExchangeListResponse, ForceEnterPayload, ForceEnterResponse, ForceExitPayload, FreqAIModelListResponse, Health, Locks, Logs, OpenTradeSchema, PairHistory, PerformanceEntry, @@ -43,7 +44,12 @@ logger = logging.getLogger(__name__) # 2.23: Allow plot config request in webserver mode # 2.24: Add cancel_open_order endpoint # 2.25: Add several profit values to /status endpoint -API_VERSION = 2.25 +# 2.26: increase /balance output +# 2.27: Add /trades//reload endpoint +# 2.28: Switch reload endpoint to Post +# 2.29: Add /exchanges endpoint +# 2.30: new /pairlists endpoint +API_VERSION = 2.30 # Public API, requires no auth. router_public = APIRouter() @@ -126,11 +132,17 @@ def trades_delete(tradeid: int, rpc: RPC = Depends(get_rpc)): @router.delete('/trades/{tradeid}/open-order', response_model=OpenTradeSchema, tags=['trading']) -def cancel_open_order(tradeid: int, rpc: RPC = Depends(get_rpc)): +def trade_cancel_open_order(tradeid: int, rpc: RPC = Depends(get_rpc)): rpc._rpc_cancel_open_order(tradeid) return rpc._rpc_trade_status([tradeid])[0] +@router.post('/trades/{tradeid}/reload', response_model=OpenTradeSchema, tags=['trading']) +def trade_reload(tradeid: int, rpc: RPC = Depends(get_rpc)): + rpc._rpc_reload_trade_from_exchange(tradeid) + return rpc._rpc_trade_status([tradeid])[0] + + # TODO: Missing response model @router.get('/edge', tags=['info']) def edge(rpc: RPC = Depends(get_rpc)): @@ -246,14 +258,17 @@ def pair_candles( @router.get('/pair_history', response_model=PairHistory, tags=['candle data']) def pair_history(pair: str, timeframe: str, timerange: str, strategy: str, + freqaimodel: Optional[str] = None, config=Depends(get_config), exchange=Depends(get_exchange)): # The initial call to this endpoint can be slow, as it may need to initialize # the exchange class. config = deepcopy(config) config.update({ 'strategy': strategy, + 'timerange': timerange, + 'freqaimodel': freqaimodel if freqaimodel else config.get('freqaimodel'), }) - return RPC._rpc_analysed_history_full(config, pair, timeframe, timerange, exchange) + return RPC._rpc_analysed_history_full(config, pair, timeframe, exchange) @router.get('/plot_config', response_model=PlotConfig, tags=['candle data']) @@ -300,14 +315,23 @@ def get_strategy(strategy: str, config=Depends(get_config)): } +@router.get('/exchanges', response_model=ExchangeListResponse, tags=[]) +def list_exchanges(config=Depends(get_config)): + from freqtrade.exchange import list_available_exchanges + exchanges = list_available_exchanges(config) + return { + 'exchanges': exchanges, + } + + @router.get('/freqaimodels', response_model=FreqAIModelListResponse, tags=['freqai']) def list_freqaimodels(config=Depends(get_config)): from freqtrade.resolvers.freqaimodel_resolver import FreqaiModelResolver - strategies = FreqaiModelResolver.search_all_objects( + models = FreqaiModelResolver.search_all_objects( config, False) - strategies = sorted(strategies, key=lambda x: x['name']) + models = sorted(models, key=lambda x: x['name']) - return {'freqaimodels': [x['name'] for x in strategies]} + return {'freqaimodels': [x['name'] for x in models]} @router.get('/available_pairs', response_model=AvailablePairs, tags=['candle data']) diff --git a/freqtrade/rpc/api_server/api_ws.py b/freqtrade/rpc/api_server/api_ws.py index b253d66c2..40a5a75fd 100644 --- a/freqtrade/rpc/api_server/api_ws.py +++ b/freqtrade/rpc/api_server/api_ws.py @@ -7,12 +7,14 @@ from fastapi.websockets import WebSocket from pydantic import ValidationError from freqtrade.enums import RPCMessageType, RPCRequestType +from freqtrade.exceptions import FreqtradeException from freqtrade.rpc.api_server.api_auth import validate_ws_token from freqtrade.rpc.api_server.deps import get_message_stream, get_rpc from freqtrade.rpc.api_server.ws.channel import WebSocketChannel, create_channel from freqtrade.rpc.api_server.ws.message_stream import MessageStream -from freqtrade.rpc.api_server.ws_schemas import (WSAnalyzedDFMessage, WSMessageSchema, - WSRequestSchema, WSWhitelistMessage) +from freqtrade.rpc.api_server.ws_schemas import (WSAnalyzedDFMessage, WSErrorMessage, + WSMessageSchema, WSRequestSchema, + WSWhitelistMessage) from freqtrade.rpc.rpc import RPC @@ -27,7 +29,13 @@ async def channel_reader(channel: WebSocketChannel, rpc: RPC): Iterate over the messages from the channel and process the request """ async for message in channel: - await _process_consumer_request(message, channel, rpc) + try: + await _process_consumer_request(message, channel, rpc) + except FreqtradeException: + logger.exception(f"Error processing request from {channel}") + response = WSErrorMessage(data='Error processing request') + + await channel.send(response.dict(exclude_none=True)) async def channel_broadcaster(channel: WebSocketChannel, message_stream: MessageStream): @@ -62,13 +70,13 @@ async def _process_consumer_request( logger.error(f"Invalid request from {channel}: {e}") return - type, data = websocket_request.type, websocket_request.data + type_, data = websocket_request.type, websocket_request.data response: WSMessageSchema - logger.debug(f"Request of type {type} from {channel}") + 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 type_ == RPCRequestType.SUBSCRIBE: # If the request is empty, do nothing if not data: return @@ -80,7 +88,7 @@ async def _process_consumer_request( # We don't send a response for subscriptions return - elif type == RPCRequestType.WHITELIST: + elif type_ == RPCRequestType.WHITELIST: # Get whitelist whitelist = rpc._ws_request_whitelist() @@ -88,7 +96,7 @@ async def _process_consumer_request( response = WSWhitelistMessage(data=whitelist) await channel.send(response.dict(exclude_none=True)) - elif type == RPCRequestType.ANALYZED_DF: + elif type_ == RPCRequestType.ANALYZED_DF: # Limit the amount of candles per dataframe to 'limit' or 1500 limit = int(min(data.get('limit', 1500), 1500)) if data else None pair = data.get('pair', None) if data else None diff --git a/freqtrade/rpc/api_server/deps.py b/freqtrade/rpc/api_server/deps.py index f5b1bcd74..bface89bd 100644 --- a/freqtrade/rpc/api_server/deps.py +++ b/freqtrade/rpc/api_server/deps.py @@ -1,11 +1,13 @@ from typing import Any, AsyncIterator, Dict, Optional from uuid import uuid4 -from fastapi import Depends +from fastapi import Depends, HTTPException +from freqtrade.constants import Config from freqtrade.enums import RunMode from freqtrade.persistence import Trade from freqtrade.persistence.models import _request_id_ctx_var +from freqtrade.rpc.api_server.webserver_bgwork import ApiBG from freqtrade.rpc.rpc import RPC, RPCException from .webserver import ApiServer @@ -42,12 +44,21 @@ def get_api_config() -> Dict[str, Any]: return ApiServer._config['api_server'] +def _generate_exchange_key(config: Config) -> str: + """ + Exchange key - used for caching the exchange object. + """ + return f"{config['exchange']['name']}_{config.get('trading_mode', 'spot')}" + + def get_exchange(config=Depends(get_config)): - if not ApiServer._exchange: + exchange_key = _generate_exchange_key(config) + if not (exchange := ApiBG.exchanges.get(exchange_key)): from freqtrade.resolvers import ExchangeResolver - ApiServer._exchange = ExchangeResolver.load_exchange( - config['exchange']['name'], config, load_leverage_tiers=False) - return ApiServer._exchange + exchange = ExchangeResolver.load_exchange( + config, load_leverage_tiers=False) + ApiBG.exchanges[exchange_key] = exchange + return exchange def get_message_stream(): @@ -56,5 +67,6 @@ def get_message_stream(): def is_webserver_mode(config=Depends(get_config)): if config['runmode'] != RunMode.WEBSERVER: - raise RPCException('Bot is not in the correct state') + raise HTTPException(status_code=503, + detail='Bot is not in the correct state.') return None diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py index 8030e303b..4d934eee3 100644 --- a/freqtrade/rpc/api_server/webserver.py +++ b/freqtrade/rpc/api_server/webserver.py @@ -1,6 +1,6 @@ import logging from ipaddress import IPv4Address -from typing import Any, Dict, Optional +from typing import Any, Optional import orjson import uvicorn @@ -36,19 +36,8 @@ class ApiServer(RPCHandler): __initialized = False _rpc: RPC - # Backtesting type: Backtesting - _bt: Dict[str, Any] = { - 'bt': None, - 'data': None, - 'timerange': None, - 'last_config': {}, - 'bt_error': None, - } _has_rpc: bool = False - _bgtask_running: bool = False _config: Config = {} - # Exchange - only available in webserver mode. - _exchange = None # websocket message stuff _message_stream: Optional[MessageStream] = None @@ -85,7 +74,7 @@ class ApiServer(RPCHandler): """ Attach rpc handler """ - if not self._has_rpc: + if not ApiServer._has_rpc: ApiServer._rpc = rpc ApiServer._has_rpc = True else: @@ -125,10 +114,12 @@ class ApiServer(RPCHandler): def configure_app(self, app: FastAPI, config): from freqtrade.rpc.api_server.api_auth import http_basic_or_jwt_token, router_login + from freqtrade.rpc.api_server.api_background_tasks import router as api_bg_tasks 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.deps import is_webserver_mode from freqtrade.rpc.api_server.web_ui import router_ui app.include_router(api_v1_public, prefix="/api/v1") @@ -137,7 +128,12 @@ class ApiServer(RPCHandler): dependencies=[Depends(http_basic_or_jwt_token)], ) app.include_router(api_backtest, prefix="/api/v1", - dependencies=[Depends(http_basic_or_jwt_token)], + dependencies=[Depends(http_basic_or_jwt_token), + Depends(is_webserver_mode)], + ) + app.include_router(api_bg_tasks, prefix="/api/v1", + dependencies=[Depends(http_basic_or_jwt_token), + Depends(is_webserver_mode)], ) app.include_router(ws_router, prefix="/api/v1") app.include_router(router_login, prefix="/api/v1", tags=["auth"]) diff --git a/freqtrade/rpc/api_server/webserver_bgwork.py b/freqtrade/rpc/api_server/webserver_bgwork.py new file mode 100644 index 000000000..13f45227e --- /dev/null +++ b/freqtrade/rpc/api_server/webserver_bgwork.py @@ -0,0 +1,39 @@ + +from typing import Any, Dict, Literal, Optional, TypedDict +from uuid import uuid4 + +from freqtrade.exchange.exchange import Exchange + + +class JobsContainer(TypedDict): + category: Literal['pairlist'] + is_running: bool + status: str + progress: Optional[float] + result: Any + error: Optional[str] + + +class ApiBG: + # Backtesting type: Backtesting + bt: Dict[str, Any] = { + 'bt': None, + 'data': None, + 'timerange': None, + 'last_config': {}, + 'bt_error': None, + } + bgtask_running: bool = False + # Exchange - only available in webserver mode. + exchanges: Dict[str, Exchange] = {} + + # Generic background jobs + + # TODO: Change this to TTLCache + jobs: Dict[str, JobsContainer] = {} + # Pairlist evaluate things + pairlist_running: bool = False + + @staticmethod + def get_job_id() -> str: + return str(uuid4()) diff --git a/freqtrade/rpc/api_server/ws_schemas.py b/freqtrade/rpc/api_server/ws_schemas.py index 292672b60..af98bd532 100644 --- a/freqtrade/rpc/api_server/ws_schemas.py +++ b/freqtrade/rpc/api_server/ws_schemas.py @@ -66,4 +66,9 @@ class WSAnalyzedDFMessage(WSMessageSchema): type: RPCMessageType = RPCMessageType.ANALYZED_DF data: AnalyzedDFData + +class WSErrorMessage(WSMessageSchema): + type: RPCMessageType = RPCMessageType.EXCEPTION + data: str + # -------------------------------------------------------------------------- diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 1312199bc..fe676a2ba 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -7,7 +7,6 @@ from datetime import date, datetime, timedelta, timezone from math import isnan from typing import Any, Dict, Generator, List, Optional, Sequence, Tuple, Union -import arrow import psutil from dateutil.relativedelta import relativedelta from dateutil.tz import tzlocal @@ -24,13 +23,15 @@ from freqtrade.enums import (CandleType, ExitCheckTuple, ExitType, MarketDirecti State, TradingMode) from freqtrade.exceptions import ExchangeError, PricingError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs +from freqtrade.exchange.types import Tickers from freqtrade.loggers import bufferHandler -from freqtrade.misc import decimals_per_coin, shorten_date +from freqtrade.misc import decimals_per_coin from freqtrade.persistence import KeyStoreKeys, KeyValueStore, 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 from freqtrade.rpc.rpc_types import RPCSendMsg +from freqtrade.util import dt_humanize, dt_now, shorten_date from freqtrade.wallets import PositionWallet, Wallet @@ -291,7 +292,7 @@ class RPC: and open_order.ft_order_side == trade.entry_side) else '') + ('**' if (open_order and open_order.ft_order_side == trade.exit_side is not None) else ''), - shorten_date(arrow.get(trade.open_date).humanize(only_distance=True)), + shorten_date(dt_humanize(trade.open_date, only_distance=True)), profit_str ] if self._config.get('position_adjustment_enable', False): @@ -419,16 +420,15 @@ class RPC: else: return 'draws' trades = Trade.get_trades([Trade.is_open.is_(False)], include_orders=False) - # Sell reason + # Duration + dur: Dict[str, List[float]] = {'wins': [], 'draws': [], 'losses': []} + # Exit reason exit_reasons = {} for trade in trades: if trade.exit_reason not in exit_reasons: exit_reasons[trade.exit_reason] = {'wins': 0, 'losses': 0, 'draws': 0} exit_reasons[trade.exit_reason][trade_win_loss(trade)] += 1 - # Duration - dur: Dict[str, List[float]] = {'wins': [], 'draws': [], 'losses': []} - for trade in trades: if trade.close_date is not None and trade.open_date is not None: trade_dur = (trade.close_date - trade.open_date).total_seconds() dur[trade_win_loss(trade)].append(trade_dur) @@ -560,8 +560,8 @@ class RPC: fiat_display_currency ) if self._fiat_converter else 0 - first_date = trades[0].open_date if trades else None - last_date = trades[-1].open_date if trades else None + first_date = trades[0].open_date_utc if trades else None + last_date = trades[-1].open_date_utc if trades else None num = float(len(durations) or 1) bot_start = KeyValueStore.get_datetime_value(KeyStoreKeys.BOT_START_TIME) return { @@ -583,9 +583,11 @@ class RPC: 'profit_all_fiat': profit_all_fiat, 'trade_count': len(trades), 'closed_trade_count': closed_trade_count, - 'first_trade_date': arrow.get(first_date).humanize() if first_date else '', + 'first_trade_date': first_date.strftime(DATETIME_PRINT_FORMAT) if first_date else '', + 'first_trade_humanized': dt_humanize(first_date) if first_date else '', 'first_trade_timestamp': int(first_date.timestamp() * 1000) if first_date else 0, - 'latest_trade_date': arrow.get(last_date).humanize() if last_date else '', + 'latest_trade_date': last_date.strftime(DATETIME_PRINT_FORMAT) if last_date else '', + 'latest_trade_humanized': dt_humanize(last_date) if last_date else '', 'latest_trade_timestamp': int(last_date.timestamp() * 1000) if last_date else 0, 'avg_duration': str(timedelta(seconds=sum(durations) / num)).split('.')[0], 'best_pair': best_pair[0] if best_pair else '', @@ -604,15 +606,44 @@ class RPC: 'bot_start_date': bot_start.strftime(DATETIME_PRINT_FORMAT) if bot_start else '', } + def __balance_get_est_stake( + self, coin: str, stake_currency: str, amount: float, + balance: Wallet, tickers) -> Tuple[float, float]: + est_stake = 0.0 + est_bot_stake = 0.0 + if coin == stake_currency: + est_stake = balance.total + if self._config.get('trading_mode', TradingMode.SPOT) != TradingMode.SPOT: + # in Futures, "total" includes the locked stake, and therefore all positions + est_stake = balance.free + est_bot_stake = amount + else: + try: + pair = self._freqtrade.exchange.get_valid_pair_combination(coin, stake_currency) + rate: Optional[float] = tickers.get(pair, {}).get('last', None) + if rate: + if pair.startswith(stake_currency) and not pair.endswith(stake_currency): + rate = 1.0 / rate + est_stake = rate * balance.total + est_bot_stake = rate * amount + except (ExchangeError): + logger.warning(f"Could not get rate for pair {coin}.") + raise ValueError() + + return est_stake, est_bot_stake + def _rpc_balance(self, stake_currency: str, fiat_display_currency: str) -> Dict: """ Returns current account balance per crypto """ currencies: List[Dict] = [] total = 0.0 + total_bot = 0.0 try: - tickers = self._freqtrade.exchange.get_tickers(cached=True) + tickers: Tickers = self._freqtrade.exchange.get_tickers(cached=True) except (ExchangeError): raise RPCException('Error getting current tickers.') + open_trades: List[Trade] = Trade.get_open_trades() + open_assets: Dict[str, Trade] = {t.safe_base_currency: t for t in open_trades} self._freqtrade.wallets.update(require_update=False) starting_capital = self._freqtrade.wallets.get_starting_balance() starting_cap_fiat = self._fiat_converter.convert_amount( @@ -623,41 +654,42 @@ class RPC: if not balance.total: continue - est_stake: float = 0 + trade = open_assets.get(coin, None) + is_bot_managed = coin == stake_currency or trade is not None + trade_amount = trade.amount if trade else 0 if coin == stake_currency: - rate = 1.0 - est_stake = balance.total - if self._config.get('trading_mode', TradingMode.SPOT) != TradingMode.SPOT: - # in Futures, "total" includes the locked stake, and therefore all positions - est_stake = balance.free - else: - try: - pair = self._freqtrade.exchange.get_valid_pair_combination(coin, stake_currency) - rate = tickers.get(pair, {}).get('last') - if rate: - if pair.startswith(stake_currency) and not pair.endswith(stake_currency): - rate = 1.0 / rate - est_stake = rate * balance.total - except (ExchangeError): - logger.warning(f" Could not get rate for pair {coin}.") - continue - total = total + est_stake + trade_amount = self._freqtrade.wallets.get_available_stake_amount() + + try: + est_stake, est_stake_bot = self.__balance_get_est_stake( + coin, stake_currency, trade_amount, balance, tickers) + except ValueError: + continue + + total += est_stake + + if is_bot_managed: + total_bot += est_stake_bot currencies.append({ 'currency': coin, 'free': balance.free, 'balance': balance.total, 'used': balance.used, + 'bot_owned': trade_amount, 'est_stake': est_stake or 0, + 'est_stake_bot': est_stake_bot if is_bot_managed else 0, 'stake': stake_currency, 'side': 'long', 'leverage': 1, 'position': 0, + 'is_bot_managed': is_bot_managed, 'is_position': False, }) symbol: str position: PositionWallet for symbol, position in self._freqtrade.wallets.get_all_positions().items(): total += position.collateral + total_bot += position.collateral currencies.append({ 'currency': symbol, @@ -666,24 +698,30 @@ class RPC: 'used': 0, 'position': position.position, 'est_stake': position.collateral, + 'est_stake_bot': position.collateral, 'stake': stake_currency, 'leverage': position.leverage, 'side': position.side, + 'is_bot_managed': True, 'is_position': True }) value = self._fiat_converter.convert_amount( total, stake_currency, fiat_display_currency) if self._fiat_converter else 0 + value_bot = self._fiat_converter.convert_amount( + total_bot, stake_currency, fiat_display_currency) if self._fiat_converter else 0 trade_count = len(Trade.get_trades_proxy()) - starting_capital_ratio = (total / starting_capital) - 1 if starting_capital else 0.0 - starting_cap_fiat_ratio = (value / starting_cap_fiat) - 1 if starting_cap_fiat else 0.0 + starting_capital_ratio = (total_bot / starting_capital) - 1 if starting_capital else 0.0 + starting_cap_fiat_ratio = (value_bot / starting_cap_fiat) - 1 if starting_cap_fiat else 0.0 return { 'currencies': currencies, 'total': total, + 'total_bot': total_bot, 'symbol': fiat_display_currency, 'value': value, + 'value_bot': value_bot, 'stake': stake_currency, 'starting_capital': starting_capital, 'starting_capital_ratio': starting_capital_ratio, @@ -727,8 +765,20 @@ class RPC: return {'status': 'No more entries will occur from now. Run /reload_config to reset.'} + def _rpc_reload_trade_from_exchange(self, trade_id: int) -> Dict[str, str]: + """ + Handler for reload_trade_from_exchange. + Reloads a trade from it's orders, should manual interaction have happened. + """ + trade = Trade.get_trades(trade_filter=[Trade.id == trade_id]).first() + if not trade: + raise RPCException(f"Could not find trade with id {trade_id}.") + + self._freqtrade.handle_onexchange_order(trade) + return {'status': 'Reloaded from orders from exchange'} + def __exec_force_exit(self, trade: Trade, ordertype: Optional[str], - amount: Optional[float] = None) -> None: + amount: Optional[float] = None) -> bool: # Check if there is there is an open order fully_canceled = False if trade.open_order_id: @@ -743,6 +793,9 @@ class RPC: self._freqtrade.handle_cancel_exit(trade, order, CANCEL_REASON['FORCE_EXIT']) if not fully_canceled: + if trade.open_order_id is not None: + # Order cancellation failed, so we can't exit. + return False # Get current rate and execute sell current_rate = self._freqtrade.exchange.get_rate( trade.pair, side='exit', is_short=trade.is_short, refresh=True) @@ -763,6 +816,9 @@ class RPC: trade, current_rate, exit_check, ordertype=order_type, sub_trade_amt=sub_amount) + return True + return False + def _rpc_force_exit(self, trade_id: str, ordertype: Optional[str] = None, *, amount: Optional[float] = None) -> Dict[str, str]: """ @@ -775,12 +831,12 @@ class RPC: with self._freqtrade._exit_lock: if trade_id == 'all': - # Execute sell for all open orders + # Execute exit for all open orders for trade in Trade.get_open_trades(): self.__exec_force_exit(trade, ordertype) Trade.commit() self._freqtrade.wallets.update() - return {'result': 'Created sell orders for all open trades.'} + return {'result': 'Created exit orders for all open trades.'} # Query for trade trade = Trade.get_trades( @@ -790,10 +846,12 @@ class RPC: logger.warning('force_exit: Invalid argument received') raise RPCException('invalid argument') - self.__exec_force_exit(trade, ordertype, amount) + result = self.__exec_force_exit(trade, ordertype, amount) Trade.commit() self._freqtrade.wallets.update() - return {'result': f'Created sell order for trade {trade_id}.'} + if not result: + raise RPCException('Failed to exit trade.') + return {'result': f'Created exit order for trade {trade_id}.'} def _force_entry_validations(self, pair: str, order_side: SignalDirection): if not self._freqtrade.config.get('force_entry_enable', False): @@ -1202,8 +1260,8 @@ class RPC: @staticmethod def _rpc_analysed_history_full(config: Config, pair: str, timeframe: str, - timerange: str, exchange) -> Dict[str, Any]: - timerange_parsed = TimeRange.parse_timerange(timerange) + exchange) -> Dict[str, Any]: + timerange_parsed = TimeRange.parse_timerange(config.get('timerange')) _data = load_data( datadir=config["datadir"], @@ -1214,7 +1272,8 @@ class RPC: candle_type=config.get('candle_type_def', CandleType.SPOT) ) if pair not in _data: - raise RPCException(f"No data for {pair}, {timeframe} in {timerange} found.") + raise RPCException( + f"No data for {pair}, {timeframe} in {config.get('timerange')} found.") from freqtrade.data.dataprovider import DataProvider from freqtrade.resolvers.strategy_resolver import StrategyResolver strategy = StrategyResolver.load_strategy(config) @@ -1224,7 +1283,7 @@ class RPC: df_analyzed = strategy.analyze_ticker(_data[pair], {'pair': pair}) return RPC._convert_dataframe_to_dict(strategy.get_strategy_name(), pair, timeframe, - df_analyzed, arrow.Arrow.utcnow().datetime) + df_analyzed, dt_now()) def _rpc_plot_config(self) -> Dict[str, Any]: if (self._freqtrade.strategy.plot_config and diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 92b500642..33db5a76d 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -3,6 +3,7 @@ """ This module manage Telegram communication """ +import asyncio import json import logging import re @@ -13,15 +14,16 @@ from functools import partial from html import escape from itertools import chain from math import isnan -from typing import Any, Callable, Dict, List, Optional, Union +from threading import Thread +from typing import Any, Callable, Coroutine, Dict, List, Optional, Union -import arrow from tabulate import tabulate -from telegram import (MAX_MESSAGE_LENGTH, CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, - KeyboardButton, ParseMode, ReplyKeyboardMarkup, Update) +from telegram import (CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, KeyboardButton, + ReplyKeyboardMarkup, Update) +from telegram.constants import MessageLimit, ParseMode from telegram.error import BadRequest, NetworkError, TelegramError -from telegram.ext import CallbackContext, CallbackQueryHandler, CommandHandler, Updater -from telegram.utils.helpers import escape_markdown +from telegram.ext import Application, CallbackContext, CallbackQueryHandler, CommandHandler +from telegram.helpers import escape_markdown from freqtrade.__init__ import __version__ from freqtrade.constants import DUST_PER_COIN, Config @@ -31,6 +33,10 @@ from freqtrade.misc import chunks, plural, round_coin_value from freqtrade.persistence import Trade from freqtrade.rpc import RPC, RPCException, RPCHandler from freqtrade.rpc.rpc_types import RPCSendMsg +from freqtrade.util import dt_humanize + + +MAX_MESSAGE_LENGTH = MessageLimit.MAX_TEXT_LENGTH logger = logging.getLogger(__name__) @@ -47,14 +53,14 @@ class TimeunitMappings: default: int -def authorized_only(command_handler: Callable[..., None]) -> Callable[..., Any]: +def authorized_only(command_handler: Callable[..., Coroutine[Any, Any, None]]): """ Decorator to check if the message comes from the correct chat_id :param command_handler: Telegram CommandHandler :return: decorated function """ - def wrapper(self, *args, **kwargs): + async def wrapper(self, *args, **kwargs): """ Decorator logic """ update = kwargs.get('update') or args[0] @@ -76,9 +82,9 @@ def authorized_only(command_handler: Callable[..., None]) -> Callable[..., Any]: chat_id ) try: - return command_handler(self, *args, **kwargs) + return await command_handler(self, *args, **kwargs) except RPCException as e: - self._send_msg(str(e)) + await self._send_msg(str(e)) except BaseException: logger.exception('Exception occurred within Telegram module') finally: @@ -99,9 +105,17 @@ class Telegram(RPCHandler): """ super().__init__(rpc, config) - self._updater: Updater + self._app: Application + self._loop: asyncio.AbstractEventLoop self._init_keyboard() - self._init() + self._start_thread() + + def _start_thread(self): + """ + Creates and starts the polling thread + """ + self._thread = Thread(target=self._init, name='FTTelegram') + self._thread.start() def _init_keyboard(self) -> None: """ @@ -152,14 +166,23 @@ class Telegram(RPCHandler): logger.info('using custom keyboard from ' f'config.json: {self._keyboard}') + def _init_telegram_app(self): + return Application.builder().token(self._config['telegram']['token']).build() + def _init(self) -> None: """ Initializes this module with the given config, registers all known command handlers and starts polling for message updates + Runs in a separate thread. """ - self._updater = Updater(token=self._config['telegram']['token'], workers=0, - use_context=True) + try: + self._loop = asyncio.get_running_loop() + except RuntimeError: + self._loop = asyncio.new_event_loop() + asyncio.set_event_loop(self._loop) + + self._app = self._init_telegram_app() # Register command handler and start telegram message polling handles = [ @@ -173,6 +196,7 @@ class Telegram(RPCHandler): self._force_enter, order_side=SignalDirection.LONG)), CommandHandler('forceshort', partial( self._force_enter, order_side=SignalDirection.SHORT)), + CommandHandler('reload_trade', self._reload_trade_from_exchange), CommandHandler('trades', self._trades), CommandHandler('delete', self._delete_trade), CommandHandler(['coo', 'cancel_open_order'], self._cancel_open_order), @@ -218,21 +242,38 @@ class Telegram(RPCHandler): CallbackQueryHandler(self._force_enter_inline, pattern=r"\S+\/\S+"), ] for handle in handles: - self._updater.dispatcher.add_handler(handle) + self._app.add_handler(handle) for callback in callbacks: - self._updater.dispatcher.add_handler(callback) + self._app.add_handler(callback) - self._updater.start_polling( - bootstrap_retries=-1, - timeout=20, - read_latency=60, # Assumed transmission latency - drop_pending_updates=True, - ) logger.info( 'rpc.telegram is listening for following commands: %s', - [h.command for h in handles] + [[x for x in sorted(h.commands)] for h in handles] ) + self._loop.run_until_complete(self._startup_telegram()) + + async def _startup_telegram(self) -> None: + await self._app.initialize() + await self._app.start() + if self._app.updater: + await self._app.updater.start_polling( + bootstrap_retries=-1, + timeout=20, + # read_latency=60, # Assumed transmission latency + drop_pending_updates=True, + # stop_signals=[], # Necessary as we don't run on the main thread + ) + while True: + await asyncio.sleep(10) + if not self._app.updater.running: + break + + async def _cleanup_telegram(self) -> None: + if self._app.updater: + await self._app.updater.stop() + await self._app.stop() + await self._app.shutdown() def cleanup(self) -> None: """ @@ -240,7 +281,8 @@ class Telegram(RPCHandler): :return: None """ # This can take up to `timeout` from the call to `start_polling`. - self._updater.stop() + asyncio.run_coroutine_threadsafe(self._cleanup_telegram(), self._loop) + self._thread.join() def _exchange_from_msg(self, msg: Dict[str, Any]) -> str: """ @@ -453,7 +495,9 @@ class Telegram(RPCHandler): message = self.compose_message(deepcopy(msg), msg_type) # type: ignore if message: - self._send_msg(message, disable_notification=(noti == 'silent')) + asyncio.run_coroutine_threadsafe( + self._send_msg(message, disable_notification=(noti == 'silent')), + self._loop) def _get_sell_emoji(self, msg): """ @@ -484,17 +528,16 @@ class Telegram(RPCHandler): order_nr += 1 wording = 'Entry' if order['ft_is_entry'] else 'Exit' - cur_entry_datetime = arrow.get(order["order_filled_date"]) cur_entry_amount = order["filled"] or order["amount"] cur_entry_average = order["safe_price"] lines.append(" ") if order_nr == 1: lines.append(f"*{wording} #{order_nr}:*") lines.append( - f"*Amount:* {cur_entry_amount} " + f"*Amount:* {cur_entry_amount:.8g} " f"({round_coin_value(order['cost'], quote_currency)})" ) - lines.append(f"*Average Price:* {cur_entry_average}") + lines.append(f"*Average Price:* {cur_entry_average:.8g}") else: sum_stake = 0 sum_amount = 0 @@ -515,28 +558,20 @@ class Telegram(RPCHandler): lines.append(f"*{wording} #{order_nr}:* at {minus_on_entry:.2%} avg Profit") if is_open: - lines.append("({})".format(cur_entry_datetime - .humanize(granularity=["day", "hour", "minute"]))) - lines.append(f"*Amount:* {cur_entry_amount} " + lines.append("({})".format(dt_humanize(order["order_filled_date"], + granularity=["day", "hour", "minute"]))) + lines.append(f"*Amount:* {cur_entry_amount:.8g} " f"({round_coin_value(order['cost'], quote_currency)})") - lines.append(f"*Average {wording} Price:* {cur_entry_average} " + lines.append(f"*Average {wording} Price:* {cur_entry_average:.8g} " f"({price_to_1st_entry:.2%} from 1st entry Rate)") lines.append(f"*Order filled:* {order['order_filled_date']}") - # TODO: is this really useful? - # dur_entry = cur_entry_datetime - arrow.get( - # filled_orders[x - 1]["order_filled_date"]) - # days = dur_entry.days - # hours, remainder = divmod(dur_entry.seconds, 3600) - # minutes, seconds = divmod(remainder, 60) - # lines.append( - # f"({days}d {hours}h {minutes}m {seconds}s from previous {wording.lower()})") lines_detail.append("\n".join(lines)) return lines_detail @authorized_only - def _status(self, update: Update, context: CallbackContext) -> None: + async def _status(self, update: Update, context: CallbackContext) -> None: """ Handler for /status. Returns the current TradeThread status @@ -546,12 +581,12 @@ class Telegram(RPCHandler): """ if context.args and 'table' in context.args: - self._status_table(update, context) + await self._status_table(update, context) return else: - self._status_msg(update, context) + await self._status_msg(update, context) - def _status_msg(self, update: Update, context: CallbackContext) -> None: + async def _status_msg(self, update: Update, context: CallbackContext) -> None: """ handler for `/status` and `/status `. @@ -566,7 +601,7 @@ class Telegram(RPCHandler): position_adjust = self._config.get('position_adjustment_enable', False) max_entries = self._config.get('max_entry_position_adjustment', -1) for r in results: - r['open_date_hum'] = arrow.get(r['open_date']).humanize() + r['open_date_hum'] = dt_humanize(r['open_date']) r['num_entries'] = len([o for o in r['orders'] if o['ft_is_entry']]) r['num_exits'] = len([o for o in r['orders'] if not o['ft_is_entry'] and not o['ft_order_side'] == 'stoploss']) @@ -598,11 +633,11 @@ class Telegram(RPCHandler): ]) lines.extend([ - "*Open Rate:* `{open_rate:.8f}`", - "*Close Rate:* `{close_rate:.8f}`" if r['close_rate'] else "", + "*Open Rate:* `{open_rate:.8g}`", + "*Close Rate:* `{close_rate:.8g}`" if r['close_rate'] else "", "*Open Date:* `{open_date}`", "*Close Date:* `{close_date}`" if r['close_date'] else "", - " \n*Current Rate:* `{current_rate:.8f}`" if r['is_open'] else "", + " \n*Current Rate:* `{current_rate:.8g}`" if r['is_open'] else "", ("*Unrealized Profit:* " if r['is_open'] else "*Close Profit: *") + "`{profit_ratio:.2%}` `({profit_abs_r})`", ]) @@ -623,9 +658,9 @@ class Telegram(RPCHandler): "`({initial_stop_loss_ratio:.2%})`") # Adding stoploss and stoploss percentage only if it is not None - lines.append("*Stoploss:* `{stop_loss_abs:.8f}` " + + lines.append("*Stoploss:* `{stop_loss_abs:.8g}` " + ("`({stop_loss_ratio:.2%})`" if r['stop_loss_ratio'] else "")) - lines.append("*Stoploss distance:* `{stoploss_current_dist:.8f}` " + lines.append("*Stoploss distance:* `{stoploss_current_dist:.8g}` " "`({stoploss_current_dist_ratio:.2%})`") if r['open_order']: lines.append( @@ -635,9 +670,9 @@ class Telegram(RPCHandler): lines_detail = self._prepare_order_details( r['orders'], r['quote_currency'], r['is_open']) lines.extend(lines_detail if lines_detail else "") - self.__send_status_msg(lines, r) + await self.__send_status_msg(lines, r) - def __send_status_msg(self, lines: List[str], r: Dict[str, Any]) -> None: + async def __send_status_msg(self, lines: List[str], r: Dict[str, Any]) -> None: """ Send status message. """ @@ -648,13 +683,13 @@ class Telegram(RPCHandler): if (len(msg) + len(line) + 1) < MAX_MESSAGE_LENGTH: msg += line + '\n' else: - self._send_msg(msg.format(**r)) + await self._send_msg(msg.format(**r)) msg = "*Trade ID:* `{trade_id}` - continued\n" + line + '\n' - self._send_msg(msg.format(**r)) + await self._send_msg(msg.format(**r)) @authorized_only - def _status_table(self, update: Update, context: CallbackContext) -> None: + async def _status_table(self, update: Update, context: CallbackContext) -> None: """ Handler for /status table. Returns the current TradeThread status in table format @@ -687,12 +722,11 @@ class Telegram(RPCHandler): # insert separators line between Total lines = message.split("\n") message = "\n".join(lines[:-1] + [lines[1]] + [lines[-1]]) - self._send_msg(f"
{message}
", parse_mode=ParseMode.HTML, - reload_able=True, callback_path="update_status_table", - query=update.callback_query) + await self._send_msg(f"
{message}
", parse_mode=ParseMode.HTML, + reload_able=True, callback_path="update_status_table", + query=update.callback_query) - @authorized_only - def _timeunit_stats(self, update: Update, context: CallbackContext, unit: str) -> None: + async def _timeunit_stats(self, update: Update, context: CallbackContext, unit: str) -> None: """ Handler for /daily Returns a daily profit (in BTC) over the last n days. @@ -739,11 +773,11 @@ class Telegram(RPCHandler): f'{val.message} Profit over the last {timescale} {val.message2}:\n' f'
{stats_tab}
' ) - self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True, - callback_path=val.callback, query=update.callback_query) + await self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True, + callback_path=val.callback, query=update.callback_query) @authorized_only - def _daily(self, update: Update, context: CallbackContext) -> None: + async def _daily(self, update: Update, context: CallbackContext) -> None: """ Handler for /daily Returns a daily profit (in BTC) over the last n days. @@ -751,10 +785,10 @@ class Telegram(RPCHandler): :param update: message update :return: None """ - self._timeunit_stats(update, context, 'days') + await self._timeunit_stats(update, context, 'days') @authorized_only - def _weekly(self, update: Update, context: CallbackContext) -> None: + async def _weekly(self, update: Update, context: CallbackContext) -> None: """ Handler for /weekly Returns a weekly profit (in BTC) over the last n weeks. @@ -762,10 +796,10 @@ class Telegram(RPCHandler): :param update: message update :return: None """ - self._timeunit_stats(update, context, 'weeks') + await self._timeunit_stats(update, context, 'weeks') @authorized_only - def _monthly(self, update: Update, context: CallbackContext) -> None: + async def _monthly(self, update: Update, context: CallbackContext) -> None: """ Handler for /monthly Returns a monthly profit (in BTC) over the last n months. @@ -773,10 +807,10 @@ class Telegram(RPCHandler): :param update: message update :return: None """ - self._timeunit_stats(update, context, 'months') + await self._timeunit_stats(update, context, 'months') @authorized_only - def _profit(self, update: Update, context: CallbackContext) -> None: + async def _profit(self, update: Update, context: CallbackContext) -> None: """ Handler for /profit. Returns a cumulative profit statistics. @@ -810,8 +844,8 @@ class Telegram(RPCHandler): profit_all_percent = stats['profit_all_percent'] profit_all_fiat = stats['profit_all_fiat'] trade_count = stats['trade_count'] - first_trade_date = stats['first_trade_date'] - latest_trade_date = stats['latest_trade_date'] + first_trade_date = f"{stats['first_trade_humanized']} ({stats['first_trade_date']})" + latest_trade_date = f"{stats['latest_trade_humanized']} ({stats['latest_trade_date']})" avg_duration = stats['avg_duration'] best_pair = stats['best_pair'] best_pair_profit_ratio = stats['best_pair_profit_ratio'] @@ -855,11 +889,11 @@ class Telegram(RPCHandler): f"*Max Drawdown:* `{stats['max_drawdown']:.2%} " f"({round_coin_value(stats['max_drawdown_abs'], stake_cur)})`" ) - self._send_msg(markdown_msg, reload_able=True, callback_path="update_profit", - query=update.callback_query) + await self._send_msg(markdown_msg, reload_able=True, callback_path="update_profit", + query=update.callback_query) @authorized_only - def _stats(self, update: Update, context: CallbackContext) -> None: + async def _stats(self, update: Update, context: CallbackContext) -> None: """ Handler for /stats Show stats of recent trades @@ -890,7 +924,7 @@ class Telegram(RPCHandler): headers=['Exit Reason', 'Exits', 'Wins', 'Losses'] ) if len(exit_reasons_tabulate) > 25: - self._send_msg(f"```\n{exit_reasons_msg}```", ParseMode.MARKDOWN) + await self._send_msg(f"```\n{exit_reasons_msg}```", ParseMode.MARKDOWN) exit_reasons_msg = '' durations = stats['durations'] @@ -905,11 +939,12 @@ class Telegram(RPCHandler): ) msg = (f"""```\n{exit_reasons_msg}```\n```\n{duration_msg}```""") - self._send_msg(msg, ParseMode.MARKDOWN) + await self._send_msg(msg, ParseMode.MARKDOWN) @authorized_only - def _balance(self, update: Update, context: CallbackContext) -> None: + async def _balance(self, update: Update, context: CallbackContext) -> None: """ Handler for /balance """ + full_result = context.args and 'full' in context.args result = self._rpc._rpc_balance(self._config['stake_currency'], self._config.get('fiat_display_currency', '')) @@ -920,8 +955,7 @@ class Telegram(RPCHandler): output = '' if self._config['dry_run']: output += "*Warning:* Simulated balances in Dry Mode.\n" - starting_cap = round_coin_value( - result['starting_capital'], self._config['stake_currency']) + starting_cap = round_coin_value(result['starting_capital'], self._config['stake_currency']) output += f"Starting capital: `{starting_cap}`" starting_cap_fiat = round_coin_value( result['starting_capital_fiat'], self._config['fiat_display_currency'] @@ -933,7 +967,10 @@ class Telegram(RPCHandler): total_dust_currencies = 0 for curr in result['currencies']: curr_output = '' - if curr['est_stake'] > balance_dust_level: + if ( + (curr['is_position'] or curr['est_stake'] > balance_dust_level) + and (full_result or curr['is_bot_managed']) + ): if curr['is_position']: curr_output = ( f"*{curr['currency']}:*\n" @@ -942,20 +979,24 @@ class Telegram(RPCHandler): f"\t`Est. {curr['stake']}: " f"{round_coin_value(curr['est_stake'], curr['stake'], False)}`\n") else: + est_stake = round_coin_value( + curr['est_stake' if full_result else 'est_stake_bot'], curr['stake'], False) + curr_output = ( f"*{curr['currency']}:*\n" f"\t`Available: {curr['free']:.8f}`\n" f"\t`Balance: {curr['balance']:.8f}`\n" f"\t`Pending: {curr['used']:.8f}`\n" - f"\t`Est. {curr['stake']}: " - f"{round_coin_value(curr['est_stake'], curr['stake'], False)}`\n") + f"\t`Bot Owned: {curr['bot_owned']:.8f}`\n" + f"\t`Est. {curr['stake']}: {est_stake}`\n") + elif curr['est_stake'] <= balance_dust_level: total_dust_balance += curr['est_stake'] total_dust_currencies += 1 # Handle overflowing message length if len(output + curr_output) >= MAX_MESSAGE_LENGTH: - self._send_msg(output) + await self._send_msg(output) output = curr_output else: output += curr_output @@ -970,19 +1011,20 @@ class Telegram(RPCHandler): tc = result['trade_count'] > 0 stake_improve = f" `({result['starting_capital_ratio']:.2%})`" if tc else '' fiat_val = f" `({result['starting_capital_fiat_ratio']:.2%})`" if tc else '' - - output += ("\n*Estimated Value*:\n" - f"\t`{result['stake']}: " - f"{round_coin_value(result['total'], result['stake'], False)}`" - f"{stake_improve}\n" - f"\t`{result['symbol']}: " - f"{round_coin_value(result['value'], result['symbol'], False)}`" - f"{fiat_val}\n") - self._send_msg(output, reload_able=True, callback_path="update_balance", - query=update.callback_query) + value = round_coin_value( + result['value' if full_result else 'value_bot'], result['symbol'], False) + total_stake = round_coin_value( + result['total' if full_result else 'total_bot'], result['stake'], False) + output += ( + f"\n*Estimated Value{' (Bot managed assets only)' if not full_result else ''}*:\n" + f"\t`{result['stake']}: {total_stake}`{stake_improve}\n" + f"\t`{result['symbol']}: {value}`{fiat_val}\n" + ) + await self._send_msg(output, reload_able=True, callback_path="update_balance", + query=update.callback_query) @authorized_only - def _start(self, update: Update, context: CallbackContext) -> None: + async def _start(self, update: Update, context: CallbackContext) -> None: """ Handler for /start. Starts TradeThread @@ -991,10 +1033,10 @@ class Telegram(RPCHandler): :return: None """ msg = self._rpc._rpc_start() - self._send_msg(f"Status: `{msg['status']}`") + await self._send_msg(f"Status: `{msg['status']}`") @authorized_only - def _stop(self, update: Update, context: CallbackContext) -> None: + async def _stop(self, update: Update, context: CallbackContext) -> None: """ Handler for /stop. Stops TradeThread @@ -1003,10 +1045,10 @@ class Telegram(RPCHandler): :return: None """ msg = self._rpc._rpc_stop() - self._send_msg(f"Status: `{msg['status']}`") + await self._send_msg(f"Status: `{msg['status']}`") @authorized_only - def _reload_config(self, update: Update, context: CallbackContext) -> None: + async def _reload_config(self, update: Update, context: CallbackContext) -> None: """ Handler for /reload_config. Triggers a config file reload @@ -1015,10 +1057,10 @@ class Telegram(RPCHandler): :return: None """ msg = self._rpc._rpc_reload_config() - self._send_msg(f"Status: `{msg['status']}`") + await self._send_msg(f"Status: `{msg['status']}`") @authorized_only - def _stopentry(self, update: Update, context: CallbackContext) -> None: + async def _stopentry(self, update: Update, context: CallbackContext) -> None: """ Handler for /stop_buy. Sets max_open_trades to 0 and gracefully sells all open trades @@ -1027,10 +1069,21 @@ class Telegram(RPCHandler): :return: None """ msg = self._rpc._rpc_stopentry() - self._send_msg(f"Status: `{msg['status']}`") + await self._send_msg(f"Status: `{msg['status']}`") @authorized_only - def _force_exit(self, update: Update, context: CallbackContext) -> None: + async def _reload_trade_from_exchange(self, update: Update, context: CallbackContext) -> None: + """ + Handler for /reload_trade . + """ + if not context.args or len(context.args) == 0: + raise RPCException("Trade-id not set.") + trade_id = int(context.args[0]) + msg = self._rpc._rpc_reload_trade_from_exchange(trade_id) + await self._send_msg(f"Status: `{msg['status']}`") + + @authorized_only + async def _force_exit(self, update: Update, context: CallbackContext) -> None: """ Handler for /forceexit . Sells the given trade at current price @@ -1041,14 +1094,14 @@ class Telegram(RPCHandler): if context.args: trade_id = context.args[0] - self._force_exit_action(trade_id) + await self._force_exit_action(trade_id) else: fiat_currency = self._config.get('fiat_display_currency', '') try: statlist, _, _ = self._rpc._rpc_status_table( self._config['stake_currency'], fiat_currency) except RPCException: - self._send_msg(msg='No open trade found.') + await self._send_msg(msg='No open trade found.') return trades = [] for trade in statlist: @@ -1061,51 +1114,57 @@ class Telegram(RPCHandler): buttons_aligned.append([InlineKeyboardButton( text='Cancel', callback_data='force_exit__cancel')]) - self._send_msg(msg="Which trade?", keyboard=buttons_aligned) + await self._send_msg(msg="Which trade?", keyboard=buttons_aligned) - def _force_exit_action(self, trade_id): + async def _force_exit_action(self, trade_id): if trade_id != 'cancel': try: - self._rpc._rpc_force_exit(trade_id) + loop = asyncio.get_running_loop() + # Workaround to avoid nested loops + await loop.run_in_executor(None, self._rpc._rpc_force_exit, trade_id) except RPCException as e: - self._send_msg(str(e)) + await self._send_msg(str(e)) - def _force_exit_inline(self, update: Update, _: CallbackContext) -> None: + async def _force_exit_inline(self, update: Update, _: CallbackContext) -> None: if update.callback_query: query = update.callback_query if query.data and '__' in query.data: # Input data is "force_exit__" trade_id = query.data.split("__")[1].split(' ')[0] if trade_id == 'cancel': - query.answer() - query.edit_message_text(text="Force exit canceled.") + await query.answer() + await query.edit_message_text(text="Force exit canceled.") return trade: Optional[Trade] = Trade.get_trades(trade_filter=Trade.id == trade_id).first() - query.answer() + await query.answer() if trade: - query.edit_message_text( + await query.edit_message_text( text=f"Manually exiting Trade #{trade_id}, {trade.pair}") - self._force_exit_action(trade_id) + await self._force_exit_action(trade_id) else: - query.edit_message_text(text=f"Trade {trade_id} not found.") + await query.edit_message_text(text=f"Trade {trade_id} not found.") - def _force_enter_action(self, pair, price: Optional[float], order_side: SignalDirection): + async def _force_enter_action(self, pair, price: Optional[float], order_side: SignalDirection): if pair != 'cancel': try: - self._rpc._rpc_force_entry(pair, price, order_side=order_side) + def _force_enter(): + self._rpc._rpc_force_entry(pair, price, order_side=order_side) + loop = asyncio.get_running_loop() + # Workaround to avoid nested loops + await loop.run_in_executor(None, _force_enter) except RPCException as e: logger.exception("Forcebuy error!") - self._send_msg(str(e), ParseMode.HTML) + await self._send_msg(str(e), ParseMode.HTML) - def _force_enter_inline(self, update: Update, _: CallbackContext) -> None: + async def _force_enter_inline(self, update: Update, _: CallbackContext) -> None: if update.callback_query: query = update.callback_query if query.data and '_||_' in query.data: pair, side = query.data.split('_||_') order_side = SignalDirection(side) - query.answer() - query.edit_message_text(text=f"Manually entering {order_side} for {pair}") - self._force_enter_action(pair, None, order_side) + await query.answer() + await query.edit_message_text(text=f"Manually entering {order_side} for {pair}") + await self._force_enter_action(pair, None, order_side) @staticmethod def _layout_inline_keyboard( @@ -1118,7 +1177,7 @@ class Telegram(RPCHandler): return [buttons[i:i + cols] for i in range(0, len(buttons), cols)] @authorized_only - def _force_enter( + async def _force_enter( self, update: Update, context: CallbackContext, order_side: SignalDirection) -> None: """ Handler for /forcelong and `/forceshort @@ -1130,7 +1189,7 @@ class Telegram(RPCHandler): if context.args: pair = context.args[0] price = float(context.args[1]) if len(context.args) > 1 else None - self._force_enter_action(pair, price, order_side) + await self._force_enter_action(pair, price, order_side) else: whitelist = self._rpc._rpc_whitelist()['whitelist'] pair_buttons = [ @@ -1140,12 +1199,12 @@ class Telegram(RPCHandler): buttons_aligned = self._layout_inline_keyboard(pair_buttons) buttons_aligned.append([InlineKeyboardButton(text='Cancel', callback_data='cancel')]) - self._send_msg(msg="Which pair?", - keyboard=buttons_aligned, - query=update.callback_query) + await self._send_msg(msg="Which pair?", + keyboard=buttons_aligned, + query=update.callback_query) @authorized_only - def _trades(self, update: Update, context: CallbackContext) -> None: + async def _trades(self, update: Update, context: CallbackContext) -> None: """ Handler for /trades Returns last n recent trades. @@ -1162,7 +1221,7 @@ class Telegram(RPCHandler): nrecent ) trades_tab = tabulate( - [[arrow.get(trade['close_date']).humanize(), + [[dt_humanize(trade['close_date']), trade['pair'] + " (#" + str(trade['trade_id']) + ")", f"{(trade['close_profit']):.2%} ({trade['close_profit_abs']})"] for trade in trades['trades']], @@ -1174,10 +1233,10 @@ class Telegram(RPCHandler): tablefmt='simple') message = (f"{min(trades['trades_count'], nrecent)} recent trades:\n" + (f"
{trades_tab}
" if trades['trades_count'] > 0 else '')) - self._send_msg(message, parse_mode=ParseMode.HTML) + await self._send_msg(message, parse_mode=ParseMode.HTML) @authorized_only - def _delete_trade(self, update: Update, context: CallbackContext) -> None: + async def _delete_trade(self, update: Update, context: CallbackContext) -> None: """ Handler for /delete . Delete the given trade @@ -1189,13 +1248,13 @@ class Telegram(RPCHandler): raise RPCException("Trade-id not set.") trade_id = int(context.args[0]) msg = self._rpc._rpc_delete(trade_id) - self._send_msg( + await self._send_msg( f"`{msg['result_msg']}`\n" 'Please make sure to take care of this asset on the exchange manually.' ) @authorized_only - def _cancel_open_order(self, update: Update, context: CallbackContext) -> None: + async def _cancel_open_order(self, update: Update, context: CallbackContext) -> None: """ Handler for /cancel_open_order . Cancel open order for tradeid @@ -1207,10 +1266,10 @@ class Telegram(RPCHandler): raise RPCException("Trade-id not set.") trade_id = int(context.args[0]) self._rpc._rpc_cancel_open_order(trade_id) - self._send_msg('Open order canceled.') + await self._send_msg('Open order canceled.') @authorized_only - def _performance(self, update: Update, context: CallbackContext) -> None: + async def _performance(self, update: Update, context: CallbackContext) -> None: """ Handler for /performance. Shows a performance statistic from finished trades @@ -1228,17 +1287,17 @@ class Telegram(RPCHandler): f"({trade['count']})\n") if len(output + stat_line) >= MAX_MESSAGE_LENGTH: - self._send_msg(output, parse_mode=ParseMode.HTML) + await self._send_msg(output, parse_mode=ParseMode.HTML) output = stat_line else: output += stat_line - self._send_msg(output, parse_mode=ParseMode.HTML, - reload_able=True, callback_path="update_performance", - query=update.callback_query) + await self._send_msg(output, parse_mode=ParseMode.HTML, + reload_able=True, callback_path="update_performance", + query=update.callback_query) @authorized_only - def _enter_tag_performance(self, update: Update, context: CallbackContext) -> None: + async def _enter_tag_performance(self, update: Update, context: CallbackContext) -> None: """ Handler for /buys PAIR . Shows a performance statistic from finished trades @@ -1260,17 +1319,17 @@ class Telegram(RPCHandler): f"({trade['count']})\n") if len(output + stat_line) >= MAX_MESSAGE_LENGTH: - self._send_msg(output, parse_mode=ParseMode.HTML) + await self._send_msg(output, parse_mode=ParseMode.HTML) output = stat_line else: output += stat_line - self._send_msg(output, parse_mode=ParseMode.HTML, - reload_able=True, callback_path="update_enter_tag_performance", - query=update.callback_query) + await self._send_msg(output, parse_mode=ParseMode.HTML, + reload_able=True, callback_path="update_enter_tag_performance", + query=update.callback_query) @authorized_only - def _exit_reason_performance(self, update: Update, context: CallbackContext) -> None: + async def _exit_reason_performance(self, update: Update, context: CallbackContext) -> None: """ Handler for /sells. Shows a performance statistic from finished trades @@ -1292,17 +1351,17 @@ class Telegram(RPCHandler): f"({trade['count']})\n") if len(output + stat_line) >= MAX_MESSAGE_LENGTH: - self._send_msg(output, parse_mode=ParseMode.HTML) + await self._send_msg(output, parse_mode=ParseMode.HTML) output = stat_line else: output += stat_line - self._send_msg(output, parse_mode=ParseMode.HTML, - reload_able=True, callback_path="update_exit_reason_performance", - query=update.callback_query) + await self._send_msg(output, parse_mode=ParseMode.HTML, + reload_able=True, callback_path="update_exit_reason_performance", + query=update.callback_query) @authorized_only - def _mix_tag_performance(self, update: Update, context: CallbackContext) -> None: + async def _mix_tag_performance(self, update: Update, context: CallbackContext) -> None: """ Handler for /mix_tags. Shows a performance statistic from finished trades @@ -1324,17 +1383,17 @@ class Telegram(RPCHandler): f"({trade['count']})\n") if len(output + stat_line) >= MAX_MESSAGE_LENGTH: - self._send_msg(output, parse_mode=ParseMode.HTML) + await self._send_msg(output, parse_mode=ParseMode.HTML) output = stat_line else: output += stat_line - self._send_msg(output, parse_mode=ParseMode.HTML, - reload_able=True, callback_path="update_mix_tag_performance", - query=update.callback_query) + await self._send_msg(output, parse_mode=ParseMode.HTML, + reload_able=True, callback_path="update_mix_tag_performance", + query=update.callback_query) @authorized_only - def _count(self, update: Update, context: CallbackContext) -> None: + async def _count(self, update: Update, context: CallbackContext) -> None: """ Handler for /count. Returns the number of trades running @@ -1348,19 +1407,19 @@ class Telegram(RPCHandler): tablefmt='simple') message = f"
{message}
" logger.debug(message) - self._send_msg(message, parse_mode=ParseMode.HTML, - reload_able=True, callback_path="update_count", - query=update.callback_query) + await self._send_msg(message, parse_mode=ParseMode.HTML, + reload_able=True, callback_path="update_count", + query=update.callback_query) @authorized_only - def _locks(self, update: Update, context: CallbackContext) -> None: + async def _locks(self, update: Update, context: CallbackContext) -> None: """ Handler for /locks. Returns the currently active locks """ rpc_locks = self._rpc._rpc_locks() if not rpc_locks['locks']: - self._send_msg('No active locks.', parse_mode=ParseMode.HTML) + await self._send_msg('No active locks.', parse_mode=ParseMode.HTML) for locks in chunks(rpc_locks['locks'], 25): message = tabulate([[ @@ -1372,10 +1431,10 @@ class Telegram(RPCHandler): tablefmt='simple') message = f"
{escape(message)}
" logger.debug(message) - self._send_msg(message, parse_mode=ParseMode.HTML) + await self._send_msg(message, parse_mode=ParseMode.HTML) @authorized_only - def _delete_locks(self, update: Update, context: CallbackContext) -> None: + async def _delete_locks(self, update: Update, context: CallbackContext) -> None: """ Handler for /delete_locks. Returns the currently active locks @@ -1390,10 +1449,10 @@ class Telegram(RPCHandler): pair = arg self._rpc._rpc_delete_lock(lockid=lockid, pair=pair) - self._locks(update, context) + await self._locks(update, context) @authorized_only - def _whitelist(self, update: Update, context: CallbackContext) -> None: + async def _whitelist(self, update: Update, context: CallbackContext) -> None: """ Handler for /whitelist Shows the currently active whitelist @@ -1410,39 +1469,39 @@ class Telegram(RPCHandler): message += f"`{', '.join(whitelist['whitelist'])}`" logger.debug(message) - self._send_msg(message) + await self._send_msg(message) @authorized_only - def _blacklist(self, update: Update, context: CallbackContext) -> None: + async def _blacklist(self, update: Update, context: CallbackContext) -> None: """ Handler for /blacklist Shows the currently active blacklist """ - self.send_blacklist_msg(self._rpc._rpc_blacklist(context.args)) + await self.send_blacklist_msg(self._rpc._rpc_blacklist(context.args)) - def send_blacklist_msg(self, blacklist: Dict): + async def send_blacklist_msg(self, blacklist: Dict): errmsgs = [] for pair, error in blacklist['errors'].items(): - errmsgs.append(f"Error adding `{pair}` to blacklist: `{error['error_msg']}`") + errmsgs.append(f"Error: {error['error_msg']}") if errmsgs: - self._send_msg('\n'.join(errmsgs)) + await self._send_msg('\n'.join(errmsgs)) message = f"Blacklist contains {blacklist['length']} pairs\n" message += f"`{', '.join(blacklist['blacklist'])}`" logger.debug(message) - self._send_msg(message) + await self._send_msg(message) @authorized_only - def _blacklist_delete(self, update: Update, context: CallbackContext) -> None: + async def _blacklist_delete(self, update: Update, context: CallbackContext) -> None: """ Handler for /bl_delete Deletes pair(s) from current blacklist """ - self.send_blacklist_msg(self._rpc._rpc_blacklist_delete(context.args or [])) + await self.send_blacklist_msg(self._rpc._rpc_blacklist_delete(context.args or [])) @authorized_only - def _logs(self, update: Update, context: CallbackContext) -> None: + async def _logs(self, update: Update, context: CallbackContext) -> None: """ Handler for /logs Shows the latest logs @@ -1461,17 +1520,17 @@ class Telegram(RPCHandler): escape_markdown(logrec[4], version=2)) if len(msgs + msg) + 10 >= MAX_MESSAGE_LENGTH: # Send message immediately if it would become too long - self._send_msg(msgs, parse_mode=ParseMode.MARKDOWN_V2) + await self._send_msg(msgs, parse_mode=ParseMode.MARKDOWN_V2) msgs = msg + '\n' else: # Append message to messages to send msgs += msg + '\n' if msgs: - self._send_msg(msgs, parse_mode=ParseMode.MARKDOWN_V2) + await self._send_msg(msgs, parse_mode=ParseMode.MARKDOWN_V2) @authorized_only - def _edge(self, update: Update, context: CallbackContext) -> None: + async def _edge(self, update: Update, context: CallbackContext) -> None: """ Handler for /edge Shows information related to Edge @@ -1479,17 +1538,17 @@ class Telegram(RPCHandler): edge_pairs = self._rpc._rpc_edge() if not edge_pairs: message = 'Edge only validated following pairs:' - self._send_msg(message, parse_mode=ParseMode.HTML) + await self._send_msg(message, parse_mode=ParseMode.HTML) for chunk in chunks(edge_pairs, 25): edge_pairs_tab = tabulate(chunk, headers='keys', tablefmt='simple') message = (f'Edge only validated following pairs:\n' f'
{edge_pairs_tab}
') - self._send_msg(message, parse_mode=ParseMode.HTML) + await self._send_msg(message, parse_mode=ParseMode.HTML) @authorized_only - def _help(self, update: Update, context: CallbackContext) -> None: + async def _help(self, update: Update, context: CallbackContext) -> None: """ Handler for /help. Show commands of the bot @@ -1516,6 +1575,7 @@ class Telegram(RPCHandler): "*/fx |all:* `Alias to /forceexit`\n" f"{force_enter_text if self._config.get('force_entry_enable', False) else ''}" "*/delete :* `Instantly delete the given trade in the database`\n" + "*/reload_trade :* `Relade trade from exchange Orders`\n" "*/cancel_open_order :* `Cancels open orders for trade. " "Only valid when the trade has open orders.`\n" "*/coo |all:* `Alias to /cancel_open_order`\n" @@ -1533,7 +1593,8 @@ class Telegram(RPCHandler): "------------\n" "*/show_config:* `Show running configuration` \n" "*/locks:* `Show currently locked pairs`\n" - "*/balance:* `Show account balance per currency`\n" + "*/balance:* `Show bot managed balance per currency`\n" + "*/balance total:* `Show account balance per currency`\n" "*/logs [limit]:* `Show latest logs - defaults to 10` \n" "*/count:* `Show number of active trades compared to allowed number of trades`\n" "*/edge:* `Shows validated pairs by Edge if it is enabled` \n" @@ -1566,20 +1627,20 @@ class Telegram(RPCHandler): "*/version:* `Show version`" ) - self._send_msg(message, parse_mode=ParseMode.MARKDOWN) + await self._send_msg(message, parse_mode=ParseMode.MARKDOWN) @authorized_only - def _health(self, update: Update, context: CallbackContext) -> None: + async def _health(self, update: Update, context: CallbackContext) -> None: """ Handler for /health Shows the last process timestamp """ health = self._rpc.health() message = f"Last process: `{health['last_process_loc']}`" - self._send_msg(message) + await self._send_msg(message) @authorized_only - def _version(self, update: Update, context: CallbackContext) -> None: + async def _version(self, update: Update, context: CallbackContext) -> None: """ Handler for /version. Show version information @@ -1590,12 +1651,12 @@ class Telegram(RPCHandler): strategy_version = self._rpc._freqtrade.strategy.version() version_string = f'*Version:* `{__version__}`' if strategy_version is not None: - version_string += f', *Strategy version: * `{strategy_version}`' + version_string += f'\n*Strategy version: * `{strategy_version}`' - self._send_msg(version_string) + await self._send_msg(version_string) @authorized_only - def _show_config(self, update: Update, context: CallbackContext) -> None: + async def _show_config(self, update: Update, context: CallbackContext) -> None: """ Handler for /show_config. Show config information information @@ -1624,7 +1685,7 @@ class Telegram(RPCHandler): else: pa_info = "*Position adjustment:* Off\n" - self._send_msg( + await self._send_msg( f"*Mode:* `{'Dry-run' if val['dry_run'] else 'Live'}`\n" f"*Exchange:* `{val['exchange']}`\n" f"*Market: * `{val['trading_mode']}`\n" @@ -1640,8 +1701,8 @@ class Telegram(RPCHandler): f"*Current state:* `{val['state']}`" ) - def _update_msg(self, query: CallbackQuery, msg: str, callback_path: str = "", - reload_able: bool = False, parse_mode: str = ParseMode.MARKDOWN) -> None: + async def _update_msg(self, query: CallbackQuery, msg: str, callback_path: str = "", + reload_able: bool = False, parse_mode: str = ParseMode.MARKDOWN) -> None: if reload_able: reply_markup = InlineKeyboardMarkup([ [InlineKeyboardButton("Refresh", callback_data=callback_path)], @@ -1655,7 +1716,7 @@ class Telegram(RPCHandler): message_id = query.message.message_id try: - self._updater.bot.edit_message_text( + await self._app.bot.edit_message_text( chat_id=chat_id, message_id=message_id, text=msg, @@ -1670,12 +1731,12 @@ class Telegram(RPCHandler): except TelegramError as telegram_err: logger.warning('TelegramError: %s! Giving up on that message.', telegram_err.message) - def _send_msg(self, msg: str, parse_mode: str = ParseMode.MARKDOWN, - disable_notification: bool = False, - keyboard: Optional[List[List[InlineKeyboardButton]]] = None, - callback_path: str = "", - reload_able: bool = False, - query: Optional[CallbackQuery] = None) -> None: + async def _send_msg(self, msg: str, parse_mode: str = ParseMode.MARKDOWN, + disable_notification: bool = False, + keyboard: Optional[List[List[InlineKeyboardButton]]] = None, + callback_path: str = "", + reload_able: bool = False, + query: Optional[CallbackQuery] = None) -> None: """ Send given markdown message :param msg: message @@ -1685,20 +1746,20 @@ class Telegram(RPCHandler): """ reply_markup: Union[InlineKeyboardMarkup, ReplyKeyboardMarkup] if query: - self._update_msg(query=query, msg=msg, parse_mode=parse_mode, - callback_path=callback_path, reload_able=reload_able) + await self._update_msg(query=query, msg=msg, parse_mode=parse_mode, + callback_path=callback_path, reload_able=reload_able) return if reload_able and self._config['telegram'].get('reload', True): reply_markup = InlineKeyboardMarkup([ [InlineKeyboardButton("Refresh", callback_data=callback_path)]]) else: if keyboard is not None: - reply_markup = InlineKeyboardMarkup(keyboard, resize_keyboard=True) + reply_markup = InlineKeyboardMarkup(keyboard) else: reply_markup = ReplyKeyboardMarkup(self._keyboard, resize_keyboard=True) try: try: - self._updater.bot.send_message( + await self._app.bot.send_message( self._config['telegram']['chat_id'], text=msg, parse_mode=parse_mode, @@ -1712,7 +1773,7 @@ class Telegram(RPCHandler): 'Telegram NetworkError: %s! Trying one more time.', network_err.message ) - self._updater.bot.send_message( + await self._app.bot.send_message( self._config['telegram']['chat_id'], text=msg, parse_mode=parse_mode, @@ -1726,7 +1787,7 @@ class Telegram(RPCHandler): ) @authorized_only - def _changemarketdir(self, update: Update, context: CallbackContext) -> None: + async def _changemarketdir(self, update: Update, context: CallbackContext) -> None: """ Handler for /marketdir. Updates the bot's market_direction @@ -1749,14 +1810,14 @@ class Telegram(RPCHandler): if new_market_dir is not None: self._rpc._update_market_direction(new_market_dir) - self._send_msg("Successfully updated market direction" - f" from *{old_market_dir}* to *{new_market_dir}*.") + await self._send_msg("Successfully updated market direction" + f" from *{old_market_dir}* to *{new_market_dir}*.") else: raise RPCException("Invalid market direction provided. \n" "Valid market directions: *long, short, even, none*") elif context.args is not None and len(context.args) == 0: old_market_dir = self._rpc._get_market_direction() - self._send_msg(f"Currently set market direction: *{old_market_dir}*") + await self._send_msg(f"Currently set market direction: *{old_market_dir}*") else: raise RPCException("Invalid usage of command /marketdir. \n" "Usage: */marketdir [short | long | even | none]*") diff --git a/freqtrade/rpc/webhook.py b/freqtrade/rpc/webhook.py index 14b881126..80690ec0c 100644 --- a/freqtrade/rpc/webhook.py +++ b/freqtrade/rpc/webhook.py @@ -44,8 +44,11 @@ class Webhook(RPCHandler): def _get_value_dict(self, msg: RPCSendMsg) -> Optional[Dict[str, Any]]: whconfig = self._config['webhook'] + if msg['type'].value in whconfig: + # Explicit types should have priority + valuedict = whconfig.get(msg['type'].value) # Deprecated 2022.10 - only keep generic method. - if msg['type'] in [RPCMessageType.ENTRY]: + elif msg['type'] in [RPCMessageType.ENTRY]: valuedict = whconfig.get('webhookentry') elif msg['type'] in [RPCMessageType.ENTRY_CANCEL]: valuedict = whconfig.get('webhookentrycancel') @@ -62,9 +65,6 @@ class Webhook(RPCHandler): RPCMessageType.EXCEPTION, RPCMessageType.WARNING): valuedict = whconfig.get('webhookstatus') - elif msg['type'].value in whconfig: - # Allow all types ... - valuedict = whconfig.get(msg['type'].value) elif msg['type'] in ( RPCMessageType.PROTECTION_TRIGGER, RPCMessageType.PROTECTION_TRIGGER_GLOBAL, diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 3bc766d91..1e9ebe1ae 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -7,7 +7,6 @@ from abc import ABC, abstractmethod from datetime import datetime, timedelta, timezone from typing import Dict, List, Optional, Tuple, Union -import arrow from pandas import DataFrame from freqtrade.constants import CUSTOM_TAG_MAX_LENGTH, Config, IntOrInf, ListPairsWithTimeframes @@ -23,6 +22,7 @@ from freqtrade.strategy.informative_decorator import (InformativeData, PopulateI _create_and_merge_informative_pair, _format_pair_name) from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper +from freqtrade.util import dt_now from freqtrade.wallets import Wallets @@ -48,7 +48,7 @@ class IStrategy(ABC, HyperStrategyMixin): _ft_params_from_file: Dict # associated minimal roi - minimal_roi: Dict = {"0": 10.0} + minimal_roi: Dict = {} # associated stoploss stoploss: float @@ -168,7 +168,7 @@ class IStrategy(ABC, HyperStrategyMixin): download_all_data_for_training(self.dp, self.config) else: # Gracious failures if freqAI is disabled but "start" is called. - class DummyClass(): + class DummyClass: def start(self, *args, **kwargs): raise OperationalException( 'freqAI is not enabled. ' @@ -618,7 +618,7 @@ class IStrategy(ABC, HyperStrategyMixin): return df def feature_engineering_expand_all(self, dataframe: DataFrame, period: int, - metadata: Dict, **kwargs): + metadata: Dict, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* This function will automatically expand the defined features on the config defined @@ -644,7 +644,8 @@ class IStrategy(ABC, HyperStrategyMixin): """ return dataframe - def feature_engineering_expand_basic(self, dataframe: DataFrame, metadata: Dict, **kwargs): + def feature_engineering_expand_basic( + self, dataframe: DataFrame, metadata: Dict, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* This function will automatically expand the defined features on the config defined @@ -673,7 +674,8 @@ class IStrategy(ABC, HyperStrategyMixin): """ return dataframe - def feature_engineering_standard(self, dataframe: DataFrame, metadata: Dict, **kwargs): + def feature_engineering_standard( + self, dataframe: DataFrame, metadata: Dict, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* This optional function will be called once with the dataframe of the base timeframe. @@ -697,7 +699,7 @@ class IStrategy(ABC, HyperStrategyMixin): """ return dataframe - def set_freqai_targets(self, dataframe: DataFrame, metadata: Dict, **kwargs): + def set_freqai_targets(self, dataframe: DataFrame, metadata: Dict, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* Required function to set the targets for the model. @@ -936,7 +938,7 @@ class IStrategy(ABC, HyperStrategyMixin): pair: str, timeframe: str, dataframe: DataFrame, - ) -> Tuple[Optional[DataFrame], Optional[arrow.Arrow]]: + ) -> Tuple[Optional[DataFrame], Optional[datetime]]: """ Calculates current signal based based on the entry order or exit order columns of the dataframe. @@ -952,16 +954,16 @@ class IStrategy(ABC, HyperStrategyMixin): latest_date = dataframe['date'].max() latest = dataframe.loc[dataframe['date'] == latest_date].iloc[-1] - # Explicitly convert to arrow object to ensure the below comparison does not fail - latest_date = arrow.get(latest_date) + # Explicitly convert to datetime object to ensure the below comparison does not fail + latest_date = latest_date.to_pydatetime() # Check if dataframe is out of date timeframe_minutes = timeframe_to_minutes(timeframe) offset = self.config.get('exchange', {}).get('outdated_offset', 5) - if latest_date < (arrow.utcnow().shift(minutes=-(timeframe_minutes * 2 + offset))): + if latest_date < (dt_now() - timedelta(minutes=timeframe_minutes * 2 + offset)): logger.warning( 'Outdated history for pair %s. Last tick is %s minutes old', - pair, int((arrow.utcnow() - latest_date).total_seconds() // 60) + pair, int((dt_now() - latest_date).total_seconds() // 60) ) return None, None return latest, latest_date @@ -1044,8 +1046,8 @@ class IStrategy(ABC, HyperStrategyMixin): timeframe_seconds = timeframe_to_seconds(timeframe) if self.ignore_expired_candle( - latest_date=latest_date.datetime, - current_time=datetime.now(timezone.utc), + latest_date=latest_date, + current_time=dt_now(), timeframe_seconds=timeframe_seconds, enter=bool(enter_signal) ): @@ -1083,6 +1085,11 @@ class IStrategy(ABC, HyperStrategyMixin): exits: List[ExitCheckTuple] = [] current_rate = rate current_profit = trade.calc_profit_ratio(current_rate) + current_profit_best = current_profit + if low is not None or high is not None: + # Set current rate to high for backtesting ROI exits + current_rate_best = (low if trade.is_short else high) or rate + current_profit_best = trade.calc_profit_ratio(current_rate_best) trade.adjust_min_max_rates(high or current_rate, low or current_rate) @@ -1091,20 +1098,13 @@ class IStrategy(ABC, HyperStrategyMixin): current_profit=current_profit, force_stoploss=force_stoploss, low=low, high=high) - # Set current rate to high for backtesting exits - current_rate = (low if trade.is_short else high) or rate - current_profit = trade.calc_profit_ratio(current_rate) - # if enter signal and ignore_roi is set, we don't need to evaluate min_roi. roi_reached = (not (enter and self.ignore_roi_if_entry_signal) - and self.min_roi_reached(trade=trade, current_profit=current_profit, + and self.min_roi_reached(trade=trade, current_profit=current_profit_best, current_time=current_time)) exit_signal = ExitType.NONE custom_reason = '' - # use provided rate in backtesting, not high/low. - current_rate = rate - current_profit = trade.calc_profit_ratio(current_rate) if self.use_exit_signal: if exit_ and not enter: @@ -1263,7 +1263,7 @@ class IStrategy(ABC, HyperStrategyMixin): :return: minimal ROI entry value or None if none proper ROI entry was found. """ # Get highest entry in ROI dict where key <= trade-duration - roi_list = list(filter(lambda x: x <= trade_dur, self.minimal_roi.keys())) + roi_list = [x for x in self.minimal_roi.keys() if x <= trade_dur] if not roi_list: return None, None roi_entry = max(roi_list) @@ -1300,7 +1300,7 @@ class IStrategy(ABC, HyperStrategyMixin): timedout = (order.status == 'open' and order.order_date_utc < timeout_threshold) if timedout: return True - time_method = (self.check_exit_timeout if order.side == trade.exit_side + time_method = (self.check_exit_timeout if order.ft_order_side == trade.exit_side else self.check_entry_timeout) return strategy_safe_wrapper(time_method, diff --git a/freqtrade/templates/FreqaiExampleHybridStrategy.py b/freqtrade/templates/FreqaiExampleHybridStrategy.py index 3f27ee4a1..03446d76e 100644 --- a/freqtrade/templates/FreqaiExampleHybridStrategy.py +++ b/freqtrade/templates/FreqaiExampleHybridStrategy.py @@ -97,7 +97,7 @@ class FreqaiExampleHybridStrategy(IStrategy): exit_short_rsi = IntParameter(low=1, high=50, default=30, space='buy', optimize=True, load=True) def feature_engineering_expand_all(self, dataframe: DataFrame, period: int, - metadata: Dict, **kwargs): + metadata: Dict, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* This function will automatically expand the defined features on the config defined @@ -151,7 +151,8 @@ class FreqaiExampleHybridStrategy(IStrategy): return dataframe - def feature_engineering_expand_basic(self, dataframe: DataFrame, metadata: Dict, **kwargs): + def feature_engineering_expand_basic( + self, dataframe: DataFrame, metadata: Dict, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* This function will automatically expand the defined features on the config defined @@ -183,7 +184,8 @@ class FreqaiExampleHybridStrategy(IStrategy): dataframe["%-raw_price"] = dataframe["close"] return dataframe - def feature_engineering_standard(self, dataframe: DataFrame, metadata: Dict, **kwargs): + def feature_engineering_standard( + self, dataframe: DataFrame, metadata: Dict, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* This optional function will be called once with the dataframe of the base timeframe. @@ -209,7 +211,7 @@ class FreqaiExampleHybridStrategy(IStrategy): dataframe["%-hour_of_day"] = dataframe["date"].dt.hour return dataframe - def set_freqai_targets(self, dataframe: DataFrame, metadata: Dict, **kwargs): + def set_freqai_targets(self, dataframe: DataFrame, metadata: Dict, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* Required function to set the targets for the model. diff --git a/freqtrade/templates/FreqaiExampleStrategy.py b/freqtrade/templates/FreqaiExampleStrategy.py index 0093c7f7a..084cf2e89 100644 --- a/freqtrade/templates/FreqaiExampleStrategy.py +++ b/freqtrade/templates/FreqaiExampleStrategy.py @@ -15,12 +15,15 @@ logger = logging.getLogger(__name__) class FreqaiExampleStrategy(IStrategy): """ Example strategy showing how the user connects their own - IFreqaiModel to the strategy. Namely, the user uses: - self.freqai.start(dataframe, metadata) + IFreqaiModel to the strategy. - to make predictions on their data. feature_engineering_*() automatically - generate the variety of features indicated by the user in the - canonical freqtrade configuration file under config['freqai']. + Warning! This is a showcase of functionality, + which means that it is designed to show various functions of FreqAI + and it runs on all computers. We use this showcase to help users + understand how to build a strategy, and we use it as a benchmark + to help debug possible problems. + + This means this is *not* meant to be run live in production. """ minimal_roi = {"0": 0.1, "240": -1} @@ -48,7 +51,7 @@ class FreqaiExampleStrategy(IStrategy): [0.75, 1, 1.25, 1.5, 1.75], space="sell", default=1.25, optimize=True) def feature_engineering_expand_all(self, dataframe: DataFrame, period: int, - metadata: Dict, **kwargs): + metadata: Dict, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* This function will automatically expand the defined features on the config defined @@ -106,7 +109,8 @@ class FreqaiExampleStrategy(IStrategy): return dataframe - def feature_engineering_expand_basic(self, dataframe: DataFrame, metadata: Dict, **kwargs): + def feature_engineering_expand_basic( + self, dataframe: DataFrame, metadata: Dict, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* This function will automatically expand the defined features on the config defined @@ -142,7 +146,8 @@ class FreqaiExampleStrategy(IStrategy): dataframe["%-raw_price"] = dataframe["close"] return dataframe - def feature_engineering_standard(self, dataframe: DataFrame, metadata: Dict, **kwargs): + def feature_engineering_standard( + self, dataframe: DataFrame, metadata: Dict, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* This optional function will be called once with the dataframe of the base timeframe. @@ -172,7 +177,7 @@ class FreqaiExampleStrategy(IStrategy): dataframe["%-hour_of_day"] = dataframe["date"].dt.hour return dataframe - def set_freqai_targets(self, dataframe: DataFrame, metadata: Dict, **kwargs): + def set_freqai_targets(self, dataframe: DataFrame, metadata: Dict, **kwargs) -> DataFrame: """ *Only functional with FreqAI enabled strategies* Required function to set the targets for the model. @@ -227,7 +232,7 @@ class FreqaiExampleStrategy(IStrategy): # All indicators must be populated by feature_engineering_*() functions - # the model will return all labels created by user in `feature_engineering_*` + # the model will return all labels created by user in `set_freqai_targets()` # (& 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 # `set_freqai_targets()` for each training period. diff --git a/freqtrade/types/__init__.py b/freqtrade/types/__init__.py new file mode 100644 index 000000000..11fe6354b --- /dev/null +++ b/freqtrade/types/__init__.py @@ -0,0 +1 @@ +from freqtrade.types.valid_exchanges_type import ValidExchangesType # noqa: F401 diff --git a/freqtrade/types/valid_exchanges_type.py b/freqtrade/types/valid_exchanges_type.py new file mode 100644 index 000000000..c01149455 --- /dev/null +++ b/freqtrade/types/valid_exchanges_type.py @@ -0,0 +1,17 @@ +# Used for list-exchanges +from typing import List + +from typing_extensions import TypedDict + + +class TradeModeType(TypedDict): + trading_mode: str + margin_mode: str + + +class ValidExchangesType(TypedDict): + name: str + valid: bool + supported: bool + comment: str + trade_modes: List[TradeModeType] diff --git a/freqtrade/util/__init__.py b/freqtrade/util/__init__.py index 3c3c034c1..92c79b899 100644 --- a/freqtrade/util/__init__.py +++ b/freqtrade/util/__init__.py @@ -1,2 +1,18 @@ -from freqtrade.util.ft_precise import FtPrecise # noqa: F401 -from freqtrade.util.periodic_cache import PeriodicCache # noqa: F401 +from freqtrade.util.datetime_helpers import (dt_floor_day, dt_from_ts, dt_humanize, dt_now, dt_ts, + dt_utc, format_ms_time, shorten_date) +from freqtrade.util.ft_precise import FtPrecise +from freqtrade.util.periodic_cache import PeriodicCache + + +__all__ = [ + 'dt_floor_day', + 'dt_from_ts', + 'dt_humanize', + 'dt_now', + 'dt_ts', + 'dt_utc', + 'format_ms_time', + 'FtPrecise', + 'PeriodicCache', + 'shorten_date', +] diff --git a/freqtrade/util/binance_mig.py b/freqtrade/util/binance_mig.py index 37a2d2ef1..9b0f8521f 100644 --- a/freqtrade/util/binance_mig.py +++ b/freqtrade/util/binance_mig.py @@ -3,7 +3,7 @@ import logging from packaging import version from sqlalchemy import select -from freqtrade.constants import Config +from freqtrade.constants import DOCS_LINK, Config from freqtrade.enums.tradingmode import TradingMode from freqtrade.exceptions import OperationalException from freqtrade.persistence.pairlock import PairLock @@ -25,7 +25,7 @@ def migrate_binance_futures_names(config: Config): if version.parse("2.6.26") > version.parse(ccxt.__version__): raise OperationalException( "Please follow the update instructions in the docs " - "(https://www.freqtrade.io/en/latest/updating/) to install a compatible ccxt version.") + f"({DOCS_LINK}/updating/) to install a compatible ccxt version.") _migrate_binance_futures_db(config) migrate_binance_futures_data(config) diff --git a/freqtrade/util/datetime_helpers.py b/freqtrade/util/datetime_helpers.py new file mode 100644 index 000000000..7f44cbdb0 --- /dev/null +++ b/freqtrade/util/datetime_helpers.py @@ -0,0 +1,71 @@ +import re +from datetime import datetime, timezone +from typing import Optional + +import arrow + + +def dt_now() -> datetime: + """Return the current datetime in UTC.""" + return datetime.now(timezone.utc) + + +def dt_utc(year: int, month: int, day: int, hour: int = 0, minute: int = 0, second: int = 0, + microsecond: int = 0) -> datetime: + """Return a datetime in UTC.""" + return datetime(year, month, day, hour, minute, second, microsecond, tzinfo=timezone.utc) + + +def dt_ts(dt: Optional[datetime] = None) -> int: + """ + Return dt in ms as a timestamp in UTC. + If dt is None, return the current datetime in UTC. + """ + if dt: + return int(dt.timestamp() * 1000) + return int(dt_now().timestamp() * 1000) + + +def dt_floor_day(dt: datetime) -> datetime: + """Return the floor of the day for the given datetime.""" + return dt.replace(hour=0, minute=0, second=0, microsecond=0) + + +def dt_from_ts(timestamp: float) -> datetime: + """ + Return a datetime from a timestamp. + :param timestamp: timestamp in seconds or milliseconds + """ + if timestamp > 1e10: + # Timezone in ms - convert to seconds + timestamp /= 1000 + return datetime.fromtimestamp(timestamp, tz=timezone.utc) + + +def shorten_date(_date: str) -> str: + """ + Trim the date so it fits on small screens + """ + new_date = re.sub('seconds?', 'sec', _date) + new_date = re.sub('minutes?', 'min', new_date) + new_date = re.sub('hours?', 'h', new_date) + new_date = re.sub('days?', 'd', new_date) + new_date = re.sub('^an?', '1', new_date) + return new_date + + +def dt_humanize(dt: datetime, **kwargs) -> str: + """ + Return a humanized string for the given datetime. + :param dt: datetime to humanize + :param kwargs: kwargs to pass to arrow's humanize() + """ + return arrow.get(dt).humanize(**kwargs) + + +def format_ms_time(date: int) -> str: + """ + convert MS date to readable format. + : epoch-string in ms + """ + return datetime.fromtimestamp(date / 1000.0).strftime('%Y-%m-%dT%H:%M:%S') diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index 8dcc92af4..da64515a4 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -3,15 +3,16 @@ import logging from copy import deepcopy +from datetime import datetime, timedelta from typing import Dict, NamedTuple, Optional -import arrow - from freqtrade.constants import UNLIMITED_STAKE_AMOUNT, Config from freqtrade.enums import RunMode, TradingMode from freqtrade.exceptions import DependencyException from freqtrade.exchange import Exchange +from freqtrade.misc import safe_value_fallback from freqtrade.persistence import LocalTrade, Trade +from freqtrade.util.datetime_helpers import dt_now logger = logging.getLogger(__name__) @@ -42,7 +43,7 @@ class Wallets: self._wallets: Dict[str, Wallet] = {} self._positions: Dict[str, PositionWallet] = {} self.start_cap = config['dry_run_wallet'] - self._last_wallet_refresh = 0 + self._last_wallet_refresh: Optional[datetime] = None self.update() def get_free(self, currency: str) -> float: @@ -148,7 +149,7 @@ class Wallets: # Position is not open ... continue size = self._exchange._contracts_to_amount(symbol, position['contracts']) - collateral = position['collateral'] or 0.0 + collateral = safe_value_fallback(position, 'collateral', 'initialMargin', 0.0) leverage = position['leverage'] self._positions[symbol] = PositionWallet( symbol, position=size, @@ -165,14 +166,19 @@ class Wallets: for trading operations, the latest balance is needed. :param require_update: Allow skipping an update if balances were recently refreshed """ - if (require_update or (self._last_wallet_refresh + 3600 < arrow.utcnow().int_timestamp)): + now = dt_now() + if ( + require_update + or self._last_wallet_refresh is None + or (self._last_wallet_refresh + timedelta(seconds=3600) < now) + ): if (not self._config['dry_run'] or self._config.get('runmode') == RunMode.LIVE): self._update_live() else: self._update_dry() if self._log: logger.info('Wallets synced.') - self._last_wallet_refresh = arrow.utcnow().int_timestamp + self._last_wallet_refresh = dt_now() def get_all_balances(self) -> Dict[str, Wallet]: return self._wallets @@ -180,6 +186,35 @@ class Wallets: def get_all_positions(self) -> Dict[str, PositionWallet]: return self._positions + def _check_exit_amount(self, trade: Trade) -> bool: + if trade.trading_mode != TradingMode.FUTURES: + # Slightly higher offset than in safe_exit_amount. + wallet_amount: float = self.get_total(trade.safe_base_currency) * (2 - 0.981) + else: + # wallet_amount: float = self.wallets.get_free(trade.safe_base_currency) + position = self._positions.get(trade.pair) + if position is None: + # We don't own anything :O + return False + wallet_amount = position.position + + if wallet_amount >= trade.amount: + return True + return False + + def check_exit_amount(self, trade: Trade) -> bool: + """ + Checks if the exit amount is available in the wallet. + :param trade: Trade to check + :return: True if the exit amount is available, False otherwise + """ + if not self._check_exit_amount(trade): + # Update wallets just to make sure + self.update() + return self._check_exit_amount(trade) + + return True + def get_starting_balance(self) -> float: """ Retrieves starting balance - based on either available capital, diff --git a/mkdocs.yml b/mkdocs.yml index 3f9e8a880..815a10419 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -22,6 +22,7 @@ nav: - Web Hook: webhook-config.md - Data Downloading: data-download.md - Backtesting: backtesting.md + - Lookahead analysis: lookahead-analysis.md - Hyperopt: hyperopt.md - FreqAI: - Introduction: freqai.md diff --git a/pyproject.toml b/pyproject.toml index baf707c68..17f91c7b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools >= 46.4.0", "wheel"] +requires = ["setuptools >= 64.0.0", "wheel"] build-backend = "setuptools.build_meta" [tool.black] @@ -68,6 +68,9 @@ target-version = "py38" extend-select = [ "C90", # mccabe # "N", # pep8-naming + "F", # pyflakes + "E", # pycodestyle + "W", # pycodestyle "UP", # pyupgrade "TID", # flake8-tidy-imports # "EXE", # flake8-executable diff --git a/requirements-dev.txt b/requirements-dev.txt index fc0efcfe7..1a1914ead 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,26 +7,24 @@ -r docs/requirements-docs.txt coveralls==3.3.1 -ruff==0.0.261 -mypy==1.2.0 -pre-commit==3.2.2 -pytest==7.3.1 +ruff==0.0.277 +mypy==1.4.1 +pre-commit==3.3.3 +pytest==7.4.0 pytest-asyncio==0.21.0 -pytest-cov==4.0.0 -pytest-mock==3.10.0 +pytest-cov==4.1.0 +pytest-mock==3.11.1 pytest-random-order==1.1.0 isort==5.12.0 # For datetime mocking -time-machine==2.9.0 -# fastapi testing -httpx==0.24.0 +time-machine==2.11.0 # Convert jupyter notebooks to markdown documents -nbconvert==7.3.1 +nbconvert==7.6.0 # mypy types types-cachetools==5.3.0.5 types-filelock==3.2.7 -types-requests==2.28.11.17 +types-requests==2.31.0.1 types-tabulate==0.9.0.2 -types-python-dateutil==2.8.19.12 +types-python-dateutil==2.8.19.13 diff --git a/requirements-freqai-rl.txt b/requirements-freqai-rl.txt index f4e1e557b..74c6d4ebe 100644 --- a/requirements-freqai-rl.txt +++ b/requirements-freqai-rl.txt @@ -2,11 +2,10 @@ -r requirements-freqai.txt # Required for freqai-rl -torch==1.13.1; python_version < '3.11' -stable-baselines3==1.7.0; python_version < '3.11' -sb3-contrib==1.7.0; python_version < '3.11' -# Gym is forced to this version by stable-baselines3. -setuptools==65.5.1 # Should be removed when gym is fixed. -gym==0.21; python_version < '3.11' +torch==2.0.1 +#until these branches will be released we can use this +gymnasium==0.28.1 +stable_baselines3==2.0.0 +sb3_contrib>=2.0.0a9 # Progress bar for stable-baselines3 and sb3-contrib -tqdm==4.65.0; python_version < '3.11' +tqdm==4.65.0 diff --git a/requirements-freqai.txt b/requirements-freqai.txt index 51396ab91..ceb5488a6 100644 --- a/requirements-freqai.txt +++ b/requirements-freqai.txt @@ -4,8 +4,9 @@ # Required for freqai scikit-learn==1.1.3 -joblib==1.2.0 -catboost==1.1.1; platform_machine != 'aarch64' and 'arm' not in platform_machine and python_version < '3.11' +joblib==1.3.1 +catboost==1.2; 'arm' not in platform_machine lightgbm==3.3.5 -xgboost==1.7.5 -tensorboard==2.12.2 +xgboost==1.7.6 +tensorboard==2.13.0 +datasieve==0.1.7 diff --git a/requirements-hyperopt.txt b/requirements-hyperopt.txt index c81c17f63..aafbad608 100644 --- a/requirements-hyperopt.txt +++ b/requirements-hyperopt.txt @@ -2,7 +2,8 @@ -r requirements.txt # Required for hyperopt -scipy==1.10.1 +scipy==1.11.1; python_version >= '3.9' +scipy==1.10.1; python_version < '3.9' scikit-learn==1.1.3 scikit-optimize==0.9.0 -filelock==3.11.0 +filelock==3.12.2 diff --git a/requirements-plot.txt b/requirements-plot.txt index 8b9ad5bc4..72303efcb 100644 --- a/requirements-plot.txt +++ b/requirements-plot.txt @@ -1,4 +1,4 @@ # Include all requirements to run the bot. -r requirements.txt -plotly==5.14.1 +plotly==5.15.0 diff --git a/requirements.txt b/requirements.txt index 07ddde148..0f877c680 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +1,21 @@ -numpy==1.24.2 -pandas==1.5.3 +numpy==1.25.1; python_version > '3.8' +numpy==1.24.3; python_version <= '3.8' +pandas==2.0.3 pandas-ta==0.3.14b -ccxt==3.0.59 -cryptography==40.0.2 +ccxt==4.0.17 +cryptography==41.0.2; platform_machine != 'armv7l' +cryptography==40.0.1; platform_machine == 'armv7l' aiohttp==3.8.4 -SQLAlchemy==2.0.9 -python-telegram-bot==13.15 +SQLAlchemy==2.0.18 +python-telegram-bot==20.4 +# can't be hard-pinned due to telegram-bot pinning httpx with ~ +httpx>=0.24.1 arrow==1.2.3 -cachetools==4.2.2 -requests==2.28.2 -urllib3==1.26.15 -jsonschema==4.17.3 +cachetools==5.3.1 +requests==2.31.0 +urllib3==2.0.3 +jsonschema==4.18.0 TA-Lib==0.4.26 technical==1.4.0 tabulate==0.9.0 @@ -19,9 +23,9 @@ pycoingecko==3.1.0 jinja2==3.1.2 tables==3.8.0 blosc==1.11.1 -joblib==1.2.0 -rich==13.3.4 -pyarrow==11.0.0; platform_machine != 'armv7l' +joblib==1.3.1 +rich==13.4.2 +pyarrow==12.0.1; platform_machine != 'armv7l' # find first, C search in arrays py_find_1st==1.1.5 @@ -29,24 +33,24 @@ py_find_1st==1.1.5 # Load ticker files 30% faster python-rapidjson==1.10 # Properly format api responses -orjson==3.8.10 +orjson==3.9.2 # Notify systemd sdnotify==0.3.2 # API Server -fastapi==0.95.1 -pydantic==1.10.7 -uvicorn==0.21.1 -pyjwt==2.6.0 +fastapi==0.100.0 +pydantic==1.10.9 +uvicorn==0.22.0 +pyjwt==2.7.0 aiofiles==23.1.0 -psutil==5.9.4 +psutil==5.9.5 # Support for colorized terminal output colorama==0.4.6 # Building config files interactively questionary==1.10.0 -prompt-toolkit==3.0.38 +prompt-toolkit==3.0.39 # Extensions to datetime library python-dateutil==2.8.2 @@ -54,7 +58,8 @@ python-dateutil==2.8.2 schedule==1.2.0 #WS Messages -websockets==11.0.1 +websockets==11.0.3 janus==1.0.0 -ast-comments==1.0.1 +ast-comments==1.1.0 +packaging==23.1 diff --git a/scripts/rest_client.py b/scripts/rest_client.py index 196542780..2b4690287 100755 --- a/scripts/rest_client.py +++ b/scripts/rest_client.py @@ -29,7 +29,7 @@ logging.basicConfig( logger = logging.getLogger("ft_rest_client") -class FtRestClient(): +class FtRestClient: def __init__(self, serverurl, username=None, password=None): @@ -279,8 +279,9 @@ class FtRestClient(): """ data = {"pair": pair, "side": side, - "price": price, } + if price: + data['price'] = price return self._post("forceenter", data=data) def forceexit(self, tradeid, ordertype=None, amount=None): @@ -313,6 +314,13 @@ class FtRestClient(): """ return self._get(f"strategy/{strategy}") + def pairlists_available(self): + """Lists available pairlist providers + + :return: json object + """ + return self._get("pairlists/available") + def plot_config(self): """Return plot configuration if the strategy defines one. @@ -348,12 +356,13 @@ class FtRestClient(): params['limit'] = limit return self._get("pair_candles", params=params) - def pair_history(self, pair, timeframe, strategy, timerange=None): + def pair_history(self, pair, timeframe, strategy, timerange=None, freqaimodel=None): """Return historic, analyzed dataframe :param pair: Pair to get data for :param timeframe: Only pairs with this timeframe available. :param strategy: Strategy to analyze and get values for + :param freqaimodel: FreqAI model to use for analysis :param timerange: Timerange to get data for (same format than --timerange endpoints) :return: json object """ @@ -361,6 +370,7 @@ class FtRestClient(): "pair": pair, "timeframe": timeframe, "strategy": strategy, + "freqaimodel": freqaimodel, "timerange": timerange if timerange else '', }) diff --git a/setup.py b/setup.py index 048dc066d..1d35957e0 100644 --- a/setup.py +++ b/setup.py @@ -5,23 +5,27 @@ from setuptools import setup plot = ['plotly>=4.0'] hyperopt = [ 'scipy', - 'scikit-learn', + 'scikit-learn<=1.1.3', 'scikit-optimize>=0.7.0', 'filelock', ] freqai = [ 'scikit-learn', + 'joblib', 'catboost; platform_machine != "aarch64"', 'lightgbm', - 'xgboost' + 'xgboost', + 'tensorboard', + 'datasieve>=0.1.5' ] freqai_rl = [ 'torch', + 'gymnasium', 'stable-baselines3', - 'gym==0.21', - 'sb3-contrib' + 'sb3-contrib', + 'tqdm' ] hdf5 = [ @@ -32,11 +36,20 @@ hdf5 = [ develop = [ 'coveralls', 'mypy', + 'ruff', + 'pre-commit', 'pytest', 'pytest-asyncio', 'pytest-cov', 'pytest-mock', 'pytest-random-order', + 'isort', + 'time-machine', + 'types-cachetools', + 'types-filelock', + 'types-requests', + 'types-tabulate', + 'types-python-dateutil' ] jupyter = [ @@ -57,10 +70,10 @@ setup( ], install_requires=[ # from requirements.txt - 'ccxt>=2.6.26', + 'ccxt>=4.0.0', 'SQLAlchemy>=2.0.6', - 'python-telegram-bot>=13.4', - 'arrow>=0.17.0', + 'python-telegram-bot>=20.1', + 'arrow>=1.0.0', 'cachetools', 'requests', 'urllib3', @@ -91,7 +104,13 @@ setup( 'aiofiles', 'schedule', 'websockets', - 'janus' + 'janus', + 'ast-comments', + 'aiohttp', + 'cryptography', + 'httpx>=0.24.1', + 'python-dateutil', + 'packaging', ], extras_require={ 'dev': all_extra, diff --git a/setup.sh b/setup.sh index 805e13237..84f804021 100755 --- a/setup.sh +++ b/setup.sh @@ -25,7 +25,7 @@ function check_installed_python() { exit 2 fi - for v in 10 9 8 + for v in 11 10 9 8 do PYTHON="python3.${v}" which $PYTHON @@ -49,8 +49,7 @@ function updateenv() { source .env/bin/activate SYS_ARCH=$(uname -m) echo "pip install in-progress. Please wait..." - # Setuptools 65.5.0 is the last version that can install gym==0.21.0 - ${PYTHON} -m pip install --upgrade pip==23.0.1 wheel setuptools==65.5.1 + ${PYTHON} -m pip install --upgrade pip wheel setuptools REQUIREMENTS_HYPEROPT="" REQUIREMENTS_PLOT="" REQUIREMENTS_FREQAI="" @@ -259,7 +258,7 @@ function install() { install_redhat else echo "This script does not support your OS." - echo "If you have Python version 3.8 - 3.10, pip, virtualenv, ta-lib you can continue." + echo "If you have Python version 3.8 - 3.11, pip, virtualenv, ta-lib you can continue." echo "Wait 10 seconds to continue the next install steps or use ctrl+c to interrupt this shell." sleep 10 fi diff --git a/tests/commands/test_commands.py b/tests/commands/test_commands.py index 318590b32..fccfd8ebb 100644 --- a/tests/commands/test_commands.py +++ b/tests/commands/test_commands.py @@ -1,12 +1,11 @@ import json import re -from datetime import datetime +from datetime import datetime, timedelta from io import BytesIO from pathlib import Path from unittest.mock import MagicMock, PropertyMock from zipfile import ZipFile -import arrow import pytest from freqtrade.commands import (start_backtesting_show, start_convert_data, start_convert_trades, @@ -25,6 +24,7 @@ from freqtrade.enums import RunMode from freqtrade.exceptions import OperationalException from freqtrade.persistence.models import init_db from freqtrade.persistence.pairlock_middleware import PairLocks +from freqtrade.util import dt_floor_day, dt_now, dt_utc from tests.conftest import (CURRENT_TEST_STRATEGY, EXMS, create_mock_trades, get_args, log_has, log_has_re, patch_exchange, patched_configuration_load_config_file) from tests.conftest_trades import MOCK_TRADE_COUNT @@ -641,7 +641,7 @@ def test_get_ui_download_url_direct(mocker): def test_download_data_keyboardInterrupt(mocker, markets): - dl_mock = mocker.patch('freqtrade.commands.data_commands.refresh_backtest_ohlcv_data', + dl_mock = mocker.patch('freqtrade.commands.data_commands.download_data_main', MagicMock(side_effect=KeyboardInterrupt)) patch_exchange(mocker) mocker.patch(f'{EXMS}.markets', PropertyMock(return_value=markets)) @@ -660,7 +660,7 @@ def test_download_data_keyboardInterrupt(mocker, markets): def test_download_data_timerange(mocker, markets): - dl_mock = mocker.patch('freqtrade.commands.data_commands.refresh_backtest_ohlcv_data', + dl_mock = mocker.patch('freqtrade.data.history.history_utils.refresh_backtest_ohlcv_data', MagicMock(return_value=["ETH/BTC", "XRP/BTC"])) patch_exchange(mocker) mocker.patch(f'{EXMS}.markets', PropertyMock(return_value=markets)) @@ -689,7 +689,7 @@ def test_download_data_timerange(mocker, markets): start_download_data(pargs) assert dl_mock.call_count == 1 # 20days ago - days_ago = arrow.get(arrow.now().shift(days=-20).date()).int_timestamp + days_ago = dt_floor_day(dt_now() - timedelta(days=20)).timestamp() assert dl_mock.call_args_list[0][1]['timerange'].startts == days_ago dl_mock.reset_mock() @@ -704,15 +704,14 @@ def test_download_data_timerange(mocker, markets): start_download_data(pargs) assert dl_mock.call_count == 1 - assert dl_mock.call_args_list[0][1]['timerange'].startts == arrow.Arrow( - 2020, 1, 1).int_timestamp + assert dl_mock.call_args_list[0][1]['timerange'].startts == int(dt_utc(2020, 1, 1).timestamp()) def test_download_data_no_markets(mocker, caplog): - dl_mock = mocker.patch('freqtrade.commands.data_commands.refresh_backtest_ohlcv_data', + dl_mock = mocker.patch('freqtrade.data.history.history_utils.refresh_backtest_ohlcv_data', MagicMock(return_value=["ETH/BTC", "XRP/BTC"])) patch_exchange(mocker, id='binance') - mocker.patch(f'{EXMS}.markets', PropertyMock(return_value={})) + mocker.patch(f'{EXMS}.get_markets', return_value={}) args = [ "download-data", "--exchange", "binance", @@ -724,11 +723,11 @@ def test_download_data_no_markets(mocker, caplog): assert log_has("Pairs [ETH/BTC,XRP/BTC] not available on exchange Binance.", caplog) -def test_download_data_no_exchange(mocker, caplog): - mocker.patch('freqtrade.commands.data_commands.refresh_backtest_ohlcv_data', +def test_download_data_no_exchange(mocker): + mocker.patch('freqtrade.data.history.history_utils.refresh_backtest_ohlcv_data', MagicMock(return_value=["ETH/BTC", "XRP/BTC"])) patch_exchange(mocker) - mocker.patch(f'{EXMS}.markets', PropertyMock(return_value={})) + mocker.patch(f'{EXMS}.get_markets', return_value={}) args = [ "download-data", ] @@ -741,7 +740,7 @@ def test_download_data_no_exchange(mocker, caplog): def test_download_data_no_pairs(mocker): - mocker.patch('freqtrade.commands.data_commands.refresh_backtest_ohlcv_data', + mocker.patch('freqtrade.data.history.history_utils.refresh_backtest_ohlcv_data', MagicMock(return_value=["ETH/BTC", "XRP/BTC"])) patch_exchange(mocker) mocker.patch(f'{EXMS}.markets', PropertyMock(return_value={})) @@ -759,7 +758,7 @@ def test_download_data_no_pairs(mocker): def test_download_data_all_pairs(mocker, markets): - dl_mock = mocker.patch('freqtrade.commands.data_commands.refresh_backtest_ohlcv_data', + dl_mock = mocker.patch('freqtrade.data.history.history_utils.refresh_backtest_ohlcv_data', MagicMock(return_value=["ETH/BTC", "XRP/BTC"])) patch_exchange(mocker) mocker.patch(f'{EXMS}.markets', PropertyMock(return_value=markets)) @@ -793,13 +792,13 @@ def test_download_data_all_pairs(mocker, markets): assert set(dl_mock.call_args_list[0][1]['pairs']) == expected -def test_download_data_trades(mocker, caplog): - dl_mock = mocker.patch('freqtrade.commands.data_commands.refresh_backtest_trades_data', +def test_download_data_trades(mocker): + dl_mock = mocker.patch('freqtrade.data.history.history_utils.refresh_backtest_trades_data', MagicMock(return_value=[])) - convert_mock = mocker.patch('freqtrade.commands.data_commands.convert_trades_to_ohlcv', + convert_mock = mocker.patch('freqtrade.data.history.history_utils.convert_trades_to_ohlcv', MagicMock(return_value=[])) patch_exchange(mocker) - mocker.patch(f'{EXMS}.markets', PropertyMock(return_value={})) + mocker.patch(f'{EXMS}.get_markets', return_value={}) args = [ "download-data", "--exchange", "kraken", @@ -830,7 +829,7 @@ def test_download_data_trades(mocker, caplog): def test_download_data_data_invalid(mocker): patch_exchange(mocker, id="kraken") - mocker.patch(f'{EXMS}.markets', PropertyMock(return_value={})) + mocker.patch(f'{EXMS}.get_markets', return_value={}) args = [ "download-data", "--exchange", "kraken", diff --git a/tests/conftest.py b/tests/conftest.py index 2f2345d54..66f331cae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,16 +3,14 @@ import json import logging import re from copy import deepcopy -from datetime import datetime, timedelta +from datetime import timedelta from pathlib import Path from typing import Optional from unittest.mock import MagicMock, Mock, PropertyMock -import arrow import numpy as np import pandas as pd import pytest -from telegram import Chat, Message, Update from freqtrade import constants from freqtrade.commands import Arguments @@ -24,6 +22,8 @@ from freqtrade.exchange.exchange import timeframe_to_minutes from freqtrade.freqtradebot import FreqtradeBot from freqtrade.persistence import LocalTrade, Order, Trade, init_db from freqtrade.resolvers import ExchangeResolver +from freqtrade.util import dt_ts +from freqtrade.util.datetime_helpers import dt_now from freqtrade.worker import Worker from tests.conftest_trades import (leverage_trade, mock_trade_1, mock_trade_2, mock_trade_3, mock_trade_4, mock_trade_5, mock_trade_6, short_trade) @@ -182,7 +182,7 @@ def get_patched_exchange(mocker, config, api_mock=None, id='binance', patch_exchange(mocker, api_mock, id, mock_markets, mock_supported_modes) config['exchange']['name'] = id try: - exchange = ExchangeResolver.load_exchange(id, config, load_leverage_tiers=True) + exchange = ExchangeResolver.load_exchange(config, load_leverage_tiers=True) except ImportError: exchange = Exchange(config) return exchange @@ -412,6 +412,14 @@ def patch_gc(mocker) -> None: mocker.patch("freqtrade.main.gc_set_threshold") +@pytest.fixture(autouse=True) +def user_dir(mocker, tmpdir) -> Path: + user_dir = Path(tmpdir) / "user_data" + mocker.patch('freqtrade.configuration.configuration.create_userdata_dir', + return_value=user_dir) + return user_dir + + @pytest.fixture(autouse=True) def patch_coingekko(mocker) -> None: """ @@ -486,7 +494,6 @@ def get_default_conf(testdatadir): }, "exchange": { "name": "binance", - "enabled": True, "key": "key", "secret": "secret", "pair_whitelist": [ @@ -550,13 +557,6 @@ def get_default_conf_usdt(testdatadir): return configuration -@pytest.fixture -def update(): - _update = Update(0) - _update.message = Message(0, datetime.utcnow(), Chat(0, 0)) - return _update - - @pytest.fixture def fee(): return MagicMock(return_value=0.0025) @@ -1664,8 +1664,8 @@ def limit_buy_order_open(): 'type': 'limit', 'side': 'buy', 'symbol': 'mocked', - 'timestamp': arrow.utcnow().int_timestamp * 1000, - 'datetime': arrow.utcnow().isoformat(), + 'timestamp': dt_ts(), + 'datetime': dt_now().isoformat(), 'price': 0.00001099, 'average': 0.00001099, 'amount': 90.99181073, @@ -1692,8 +1692,8 @@ def limit_buy_order_old(): 'type': 'limit', 'side': 'buy', 'symbol': 'mocked', - 'datetime': arrow.utcnow().shift(minutes=-601).isoformat(), - 'timestamp': arrow.utcnow().shift(minutes=-601).int_timestamp * 1000, + 'datetime': (dt_now() - timedelta(minutes=601)).isoformat(), + 'timestamp': dt_ts(dt_now() - timedelta(minutes=601)), 'price': 0.00001099, 'amount': 90.99181073, 'filled': 0.0, @@ -1709,8 +1709,8 @@ def limit_sell_order_old(): 'type': 'limit', 'side': 'sell', 'symbol': 'ETH/BTC', - 'timestamp': arrow.utcnow().shift(minutes=-601).int_timestamp * 1000, - 'datetime': arrow.utcnow().shift(minutes=-601).isoformat(), + 'timestamp': dt_ts(dt_now() - timedelta(minutes=601)), + 'datetime': (dt_now() - timedelta(minutes=601)).isoformat(), 'price': 0.00001099, 'amount': 90.99181073, 'filled': 0.0, @@ -1726,8 +1726,8 @@ def limit_buy_order_old_partial(): 'type': 'limit', 'side': 'buy', 'symbol': 'ETH/BTC', - 'timestamp': arrow.utcnow().shift(minutes=-601).int_timestamp * 1000, - 'datetime': arrow.utcnow().shift(minutes=-601).isoformat(), + 'timestamp': dt_ts(dt_now() - timedelta(minutes=601)), + 'datetime': (dt_now() - timedelta(minutes=601)).isoformat(), 'price': 0.00001099, 'amount': 90.99181073, 'filled': 23.0, @@ -1757,8 +1757,8 @@ def limit_buy_order_canceled_empty(request): 'info': {}, 'id': 'AZNPFF-4AC4N-7MKTAT', 'clientOrderId': None, - 'timestamp': arrow.utcnow().shift(minutes=-601).int_timestamp * 1000, - 'datetime': arrow.utcnow().shift(minutes=-601).isoformat(), + 'timestamp': dt_ts(dt_now() - timedelta(minutes=601)), + 'datetime': (dt_now() - timedelta(minutes=601)).isoformat(), 'lastTradeTimestamp': None, 'status': 'canceled', 'symbol': 'LTC/USDT', @@ -1778,8 +1778,8 @@ def limit_buy_order_canceled_empty(request): 'info': {}, 'id': '1234512345', 'clientOrderId': 'alb1234123', - 'timestamp': arrow.utcnow().shift(minutes=-601).int_timestamp * 1000, - 'datetime': arrow.utcnow().shift(minutes=-601).isoformat(), + 'timestamp': dt_ts(dt_now() - timedelta(minutes=601)), + 'datetime': (dt_now() - timedelta(minutes=601)).isoformat(), 'lastTradeTimestamp': None, 'symbol': 'LTC/USDT', 'type': 'limit', @@ -1799,8 +1799,8 @@ def limit_buy_order_canceled_empty(request): 'info': {}, 'id': '1234512345', 'clientOrderId': 'alb1234123', - 'timestamp': arrow.utcnow().shift(minutes=-601).int_timestamp * 1000, - 'datetime': arrow.utcnow().shift(minutes=-601).isoformat(), + 'timestamp': dt_ts(dt_now() - timedelta(minutes=601)), + 'datetime': (dt_now() - timedelta(minutes=601)).isoformat(), 'lastTradeTimestamp': None, 'symbol': 'LTC/USDT', 'type': 'limit', @@ -1824,8 +1824,8 @@ def limit_sell_order_open(): 'type': 'limit', 'side': 'sell', 'symbol': 'mocked', - 'datetime': arrow.utcnow().isoformat(), - 'timestamp': arrow.utcnow().int_timestamp * 1000, + 'datetime': dt_now().isoformat(), + 'timestamp': dt_ts(), 'price': 0.00001173, 'amount': 90.99181073, 'filled': 0.0, @@ -2487,8 +2487,8 @@ def buy_order_fee(): 'type': 'limit', 'side': 'buy', 'symbol': 'mocked', - 'timestamp': arrow.utcnow().shift(minutes=-601).int_timestamp * 1000, - 'datetime': arrow.utcnow().shift(minutes=-601).isoformat(), + 'timestamp': dt_ts(dt_now() - timedelta(minutes=601)), + 'datetime': (dt_now() - timedelta(minutes=601)).isoformat(), 'price': 0.245441, 'amount': 8.0, 'cost': 1.963528, @@ -2597,7 +2597,7 @@ def open_trade(): fee_open=0.0, fee_close=0.0, stake_amount=1, - open_date=arrow.utcnow().shift(minutes=-601).datetime, + open_date=dt_now() - timedelta(minutes=601), is_open=True ) trade.orders = [ @@ -2635,7 +2635,7 @@ def open_trade_usdt(): fee_open=0.0, fee_close=0.0, stake_amount=60.0, - open_date=arrow.utcnow().shift(minutes=-601).datetime, + open_date=dt_now() - timedelta(minutes=601), is_open=True ) trade.orders = [ @@ -2839,8 +2839,8 @@ def limit_buy_order_usdt_open(): 'type': 'limit', 'side': 'buy', 'symbol': 'mocked', - 'datetime': arrow.utcnow().isoformat(), - 'timestamp': arrow.utcnow().int_timestamp * 1000, + 'datetime': dt_now().isoformat(), + 'timestamp': dt_ts(), 'price': 2.00, 'average': 2.00, 'amount': 30.0, @@ -2867,8 +2867,8 @@ def limit_sell_order_usdt_open(): 'type': 'limit', 'side': 'sell', 'symbol': 'mocked', - 'datetime': arrow.utcnow().isoformat(), - 'timestamp': arrow.utcnow().int_timestamp * 1000, + 'datetime': dt_now().isoformat(), + 'timestamp': dt_ts(), 'price': 2.20, 'amount': 30.0, 'cost': 66.0, @@ -2894,8 +2894,8 @@ def market_buy_order_usdt(): 'type': 'market', 'side': 'buy', 'symbol': 'mocked', - 'timestamp': arrow.utcnow().int_timestamp * 1000, - 'datetime': arrow.utcnow().isoformat(), + 'timestamp': dt_ts(), + 'datetime': dt_now().isoformat(), 'price': 2.00, 'amount': 30.0, 'filled': 30.0, @@ -2951,8 +2951,8 @@ def market_sell_order_usdt(): 'type': 'market', 'side': 'sell', 'symbol': 'mocked', - 'timestamp': arrow.utcnow().int_timestamp * 1000, - 'datetime': arrow.utcnow().isoformat(), + 'timestamp': dt_ts(), + 'datetime': dt_now().isoformat(), 'price': 2.20, 'amount': 30.0, 'filled': 30.0, diff --git a/tests/data/test_btanalysis.py b/tests/data/test_btanalysis.py index 2c5515f7c..5e377f851 100644 --- a/tests/data/test_btanalysis.py +++ b/tests/data/test_btanalysis.py @@ -1,8 +1,8 @@ +from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import MagicMock import pytest -from arrow import Arrow from pandas import DataFrame, DateOffset, Timestamp, to_datetime from freqtrade.configuration import TimeRange @@ -18,6 +18,7 @@ from freqtrade.data.metrics import (calculate_cagr, calculate_calmar, calculate_ calculate_underwater, combine_dataframes_with_mean, create_cum_profit) from freqtrade.exceptions import OperationalException +from freqtrade.util import dt_utc from tests.conftest import CURRENT_TEST_STRATEGY, create_mock_trades from tests.conftest_trades import MOCK_TRADE_COUNT @@ -162,25 +163,25 @@ def test_extract_trades_of_period(testdatadir): {'pair': [pair, pair, pair, pair], 'profit_ratio': [0.0, 0.1, -0.2, -0.5], 'profit_abs': [0.0, 1, -2, -5], - 'open_date': to_datetime([Arrow(2017, 11, 13, 15, 40, 0).datetime, - Arrow(2017, 11, 14, 9, 41, 0).datetime, - Arrow(2017, 11, 14, 14, 20, 0).datetime, - Arrow(2017, 11, 15, 3, 40, 0).datetime, + 'open_date': to_datetime([datetime(2017, 11, 13, 15, 40, 0, tzinfo=timezone.utc), + datetime(2017, 11, 14, 9, 41, 0, tzinfo=timezone.utc), + datetime(2017, 11, 14, 14, 20, 0, tzinfo=timezone.utc), + datetime(2017, 11, 15, 3, 40, 0, tzinfo=timezone.utc), ], utc=True ), - 'close_date': to_datetime([Arrow(2017, 11, 13, 16, 40, 0).datetime, - Arrow(2017, 11, 14, 10, 41, 0).datetime, - Arrow(2017, 11, 14, 15, 25, 0).datetime, - Arrow(2017, 11, 15, 3, 55, 0).datetime, + 'close_date': to_datetime([datetime(2017, 11, 13, 16, 40, 0, tzinfo=timezone.utc), + datetime(2017, 11, 14, 10, 41, 0, tzinfo=timezone.utc), + datetime(2017, 11, 14, 15, 25, 0, tzinfo=timezone.utc), + datetime(2017, 11, 15, 3, 55, 0, tzinfo=timezone.utc), ], utc=True) }) trades1 = extract_trades_of_period(data, trades) # First and last trade are dropped as they are out of range assert len(trades1) == 2 - assert trades1.iloc[0].open_date == Arrow(2017, 11, 14, 9, 41, 0).datetime - assert trades1.iloc[0].close_date == Arrow(2017, 11, 14, 10, 41, 0).datetime - assert trades1.iloc[-1].open_date == Arrow(2017, 11, 14, 14, 20, 0).datetime - assert trades1.iloc[-1].close_date == Arrow(2017, 11, 14, 15, 25, 0).datetime + assert trades1.iloc[0].open_date == datetime(2017, 11, 14, 9, 41, 0, tzinfo=timezone.utc) + assert trades1.iloc[0].close_date == datetime(2017, 11, 14, 10, 41, 0, tzinfo=timezone.utc) + assert trades1.iloc[-1].open_date == datetime(2017, 11, 14, 14, 20, 0, tzinfo=timezone.utc) + assert trades1.iloc[-1].close_date == datetime(2017, 11, 14, 15, 25, 0, tzinfo=timezone.utc) def test_analyze_trade_parallelism(testdatadir): @@ -420,7 +421,7 @@ def test_calculate_max_drawdown2(): -0.025782, 0.010400, 0.012374, 0.012467, 0.114741, 0.010303, 0.010088, -0.033961, 0.010680, 0.010886, -0.029274, 0.011178, 0.010693, 0.010711] - dates = [Arrow(2020, 1, 1).shift(days=i) for i in range(len(values))] + dates = [dt_utc(2020, 1, 1) + timedelta(days=i) for i in range(len(values))] df = DataFrame(zip(values, dates), columns=['profit', 'open_date']) # sort by profit and reset index df = df.sort_values('profit').reset_index(drop=True) @@ -454,8 +455,8 @@ def test_calculate_max_drawdown_abs(profits, relative, highd, lowd, result, resu [1000, 500, 1000, 11000, 10000] # absolute results [1000, 50%, 0%, 0%, ~9%] # Relative drawdowns """ - init_date = Arrow(2020, 1, 1) - dates = [init_date.shift(days=i) for i in range(len(profits))] + init_date = datetime(2020, 1, 1, tzinfo=timezone.utc) + dates = [init_date + timedelta(days=i) for i in range(len(profits))] df = DataFrame(zip(profits, dates), columns=['profit_abs', 'open_date']) # sort by profit and reset index df = df.sort_values('profit_abs').reset_index(drop=True) @@ -467,8 +468,8 @@ def test_calculate_max_drawdown_abs(profits, relative, highd, lowd, result, resu assert isinstance(drawdown, float) assert isinstance(drawdown_rel, float) - assert hdate == init_date.shift(days=highd) - assert ldate == init_date.shift(days=lowd) + assert hdate == init_date + timedelta(days=highd) + assert ldate == init_date + timedelta(days=lowd) # High must be before low assert hdate < ldate diff --git a/tests/data/test_converter.py b/tests/data/test_converter.py index b37a2100d..da1459ef7 100644 --- a/tests/data/test_converter.py +++ b/tests/data/test_converter.py @@ -315,6 +315,8 @@ def test_convert_ohlcv_format(default_conf, testdatadir, tmpdir, file_base, cand files_new.append(file_new) default_conf['datadir'] = tmpdir1 + default_conf['candle_types'] = [candletype] + if candletype == CandleType.SPOT: default_conf['pairs'] = ['XRP/ETH', 'XRP/USDT', 'UNITTEST/USDT'] else: @@ -328,7 +330,6 @@ def test_convert_ohlcv_format(default_conf, testdatadir, tmpdir, file_base, cand convert_from='json', convert_to='jsongz', erase=False, - candle_type=candletype ) for file in (files_temp + files_new): assert file.exists() @@ -342,7 +343,6 @@ def test_convert_ohlcv_format(default_conf, testdatadir, tmpdir, file_base, cand convert_from='jsongz', convert_to='json', erase=True, - candle_type=candletype ) for file in (files_temp): assert file.exists() diff --git a/tests/data/test_download_data.py b/tests/data/test_download_data.py new file mode 100644 index 000000000..191dbb7d3 --- /dev/null +++ b/tests/data/test_download_data.py @@ -0,0 +1,96 @@ +from unittest.mock import MagicMock, PropertyMock + +import pytest + +from freqtrade.configuration.config_setup import setup_utils_configuration +from freqtrade.data.history.history_utils import download_data_main +from freqtrade.enums import RunMode +from freqtrade.exceptions import OperationalException +from tests.conftest import EXMS, log_has, patch_exchange + + +def test_download_data_main_no_markets(mocker, caplog): + dl_mock = mocker.patch('freqtrade.data.history.history_utils.refresh_backtest_ohlcv_data', + MagicMock(return_value=["ETH/BTC", "XRP/BTC"])) + patch_exchange(mocker, id='binance') + mocker.patch(f'{EXMS}.get_markets', return_value={}) + config = setup_utils_configuration({"exchange": "binance"}, RunMode.UTIL_EXCHANGE) + config.update({ + "days": 20, + "pairs": ["ETH/BTC", "XRP/BTC"], + "timeframes": ["5m", "1h"] + }) + download_data_main(config) + assert dl_mock.call_args[1]['timerange'].starttype == "date" + assert log_has("Pairs [ETH/BTC,XRP/BTC] not available on exchange Binance.", caplog) + + +def test_download_data_main_all_pairs(mocker, markets): + + dl_mock = mocker.patch('freqtrade.data.history.history_utils.refresh_backtest_ohlcv_data', + MagicMock(return_value=["ETH/BTC", "XRP/BTC"])) + patch_exchange(mocker) + mocker.patch(f'{EXMS}.markets', PropertyMock(return_value=markets)) + + config = setup_utils_configuration({"exchange": "binance"}, RunMode.UTIL_EXCHANGE) + config.update({ + "pairs": [".*/USDT"], + "timeframes": ["5m", "1h"] + }) + download_data_main(config) + expected = set(['ETH/USDT', 'XRP/USDT', 'NEO/USDT', 'TKN/USDT']) + assert set(dl_mock.call_args_list[0][1]['pairs']) == expected + assert dl_mock.call_count == 1 + + dl_mock.reset_mock() + + config.update({ + "pairs": [".*/USDT"], + "timeframes": ["5m", "1h"], + "include_inactive": True + }) + download_data_main(config) + expected = set(['ETH/USDT', 'LTC/USDT', 'XRP/USDT', 'NEO/USDT', 'TKN/USDT']) + assert set(dl_mock.call_args_list[0][1]['pairs']) == expected + + +def test_download_data_main_trades(mocker): + dl_mock = mocker.patch('freqtrade.data.history.history_utils.refresh_backtest_trades_data', + MagicMock(return_value=[])) + convert_mock = mocker.patch('freqtrade.data.history.history_utils.convert_trades_to_ohlcv', + MagicMock(return_value=[])) + patch_exchange(mocker) + mocker.patch(f'{EXMS}.get_markets', return_value={}) + config = setup_utils_configuration({"exchange": "binance"}, RunMode.UTIL_EXCHANGE) + config.update({ + "days": 20, + "pairs": ["ETH/BTC", "XRP/BTC"], + "timeframes": ["5m", "1h"], + "download_trades": True, + }) + download_data_main(config) + + assert dl_mock.call_args[1]['timerange'].starttype == "date" + assert dl_mock.call_count == 1 + assert convert_mock.call_count == 1 + config.update({ + "download_trades": True, + "trading_mode": "futures", + }) + + with pytest.raises(OperationalException, + match="Trade download not supported for futures."): + download_data_main(config) + + +def test_download_data_main_data_invalid(mocker): + patch_exchange(mocker, id="kraken") + mocker.patch(f'{EXMS}.get_markets', return_value={}) + config = setup_utils_configuration({"exchange": "kraken"}, RunMode.UTIL_EXCHANGE) + config.update({ + "days": 20, + "pairs": ["ETH/BTC", "XRP/BTC"], + "timeframes": ["5m", "1h"], + }) + with pytest.raises(OperationalException, match=r"Historic klines not available for .*"): + download_data_main(config) diff --git a/tests/data/test_entryexitanalysis.py b/tests/data/test_entryexitanalysis.py index 3b073bc32..810e2c53b 100644 --- a/tests/data/test_entryexitanalysis.py +++ b/tests/data/test_entryexitanalysis.py @@ -18,8 +18,9 @@ def entryexitanalysis_cleanup() -> None: Backtesting.cleanup() -def test_backtest_analysis_nomock(default_conf, mocker, caplog, testdatadir, tmpdir, capsys): +def test_backtest_analysis_nomock(default_conf, mocker, caplog, testdatadir, user_dir, capsys): caplog.set_level(logging.INFO) + (user_dir / 'backtest_results').mkdir(parents=True, exist_ok=True) default_conf.update({ "use_exit_signal": True, @@ -80,7 +81,7 @@ def test_backtest_analysis_nomock(default_conf, mocker, caplog, testdatadir, tmp 'backtesting', '--config', 'config.json', '--datadir', str(testdatadir), - '--user-data-dir', str(tmpdir), + '--user-data-dir', str(user_dir), '--timeframe', '5m', '--timerange', '1515560100-1517287800', '--export', 'signals', @@ -98,7 +99,7 @@ def test_backtest_analysis_nomock(default_conf, mocker, caplog, testdatadir, tmp 'backtesting-analysis', '--config', 'config.json', '--datadir', str(testdatadir), - '--user-data-dir', str(tmpdir), + '--user-data-dir', str(user_dir), ] # test group 0 and indicator list @@ -200,8 +201,17 @@ def test_backtest_analysis_nomock(default_conf, mocker, caplog, testdatadir, tmp assert 'trailing_stop_loss' in captured.out # test date filtering - args = get_args(base_args + ['--timerange', "20180129-20180130"]) + args = get_args(base_args + + ['--analysis-groups', "0", "1", "2", + '--timerange', "20180129-20180130"] + ) start_analysis_entries_exits(args) captured = capsys.readouterr() assert 'enter_tag_long_a' in captured.out assert 'enter_tag_long_b' not in captured.out + + # Due to the backtest mock, there's no rejected signals generated. + args = get_args(base_args + ['--rejected-signals']) + start_analysis_entries_exits(args) + captured = capsys.readouterr() + assert 'no rejected signals' in captured.out diff --git a/tests/data/test_history.py b/tests/data/test_history.py index 24ad8bcc9..ab2238b08 100644 --- a/tests/data/test_history.py +++ b/tests/data/test_history.py @@ -1,12 +1,12 @@ # pragma pylint: disable=missing-docstring, protected-access, C0103 import json +import logging import uuid from pathlib import Path from shutil import copyfile from unittest.mock import MagicMock, PropertyMock -import arrow import pytest from pandas import DataFrame from pandas.testing import assert_frame_equal @@ -26,6 +26,7 @@ from freqtrade.enums import CandleType from freqtrade.exchange import timeframe_to_minutes from freqtrade.misc import file_dump_json from freqtrade.resolvers import StrategyResolver +from freqtrade.util import dt_utc from tests.conftest import (CURRENT_TEST_STRATEGY, EXMS, get_patched_exchange, log_has, log_has_re, patch_exchange) @@ -198,7 +199,6 @@ def test_load_cached_data_for_updating(mocker, testdatadir) -> None: fill_missing=False, drop_incomplete=False) # now = last cached item + 1 hour now_ts = test_data[-1][0] / 1000 + 60 * 60 - mocker.patch('arrow.utcnow', return_value=arrow.get(now_ts)) # timeframe starts earlier than the cached data # should fully update data @@ -353,10 +353,10 @@ def test_download_backtesting_data_exception(mocker, caplog, default_conf, tmpdi def test_load_partial_missing(testdatadir, caplog) -> None: # Make sure we start fresh - test missing data at start - start = arrow.get('2018-01-01T00:00:00') - end = arrow.get('2018-01-11T00:00:00') + start = dt_utc(2018, 1, 1) + end = dt_utc(2018, 1, 11) data = load_data(testdatadir, '5m', ['UNITTEST/BTC'], startup_candles=20, - timerange=TimeRange('date', 'date', start.int_timestamp, end.int_timestamp)) + timerange=TimeRange('date', 'date', start.timestamp(), end.timestamp())) assert log_has( 'Using indicator startup period: 20 ...', caplog ) @@ -369,16 +369,16 @@ def test_load_partial_missing(testdatadir, caplog) -> None: caplog) # Make sure we start fresh - test missing data at end caplog.clear() - start = arrow.get('2018-01-10T00:00:00') - end = arrow.get('2018-02-20T00:00:00') + start = dt_utc(2018, 1, 10) + end = dt_utc(2018, 2, 20) data = load_data(datadir=testdatadir, timeframe='5m', pairs=['UNITTEST/BTC'], - timerange=TimeRange('date', 'date', start.int_timestamp, end.int_timestamp)) + timerange=TimeRange('date', 'date', start.timestamp(), end.timestamp())) # timedifference in 5 minutes td = ((end - start).total_seconds() // 60 // 5) + 1 assert td != len(data['UNITTEST/BTC']) # Shift endtime with +5 - end_real = arrow.get(data['UNITTEST/BTC'].iloc[-1, 0]) + end_real = data['UNITTEST/BTC'].iloc[-1, 0].to_pydatetime() assert log_has(f'UNITTEST/BTC, spot, 5m, ' f'data ends at {end_real.strftime(DATETIME_PRINT_FORMAT)}', caplog) @@ -504,9 +504,10 @@ def test_validate_backtest_data(default_conf, mocker, caplog, testdatadir) -> No ]) def test_refresh_backtest_ohlcv_data( mocker, default_conf, markets, caplog, testdatadir, trademode, callcount): - dl_mock = mocker.patch('freqtrade.data.history.history_utils._download_pair_history', - MagicMock()) + caplog.set_level(logging.DEBUG) + dl_mock = mocker.patch('freqtrade.data.history.history_utils._download_pair_history') mocker.patch(f'{EXMS}.markets', PropertyMock(return_value=markets)) + mocker.patch.object(Path, "exists", MagicMock(return_value=True)) mocker.patch.object(Path, "unlink", MagicMock()) @@ -521,7 +522,7 @@ def test_refresh_backtest_ohlcv_data( assert dl_mock.call_count == callcount assert dl_mock.call_args[1]['timerange'].starttype == 'date' - assert log_has("Downloading pair ETH/BTC, interval 1m.", caplog) + assert log_has_re(r"Downloading pair ETH/BTC, .* interval 1m\.", caplog) def test_download_data_no_markets(mocker, default_conf, caplog, testdatadir): diff --git a/tests/edge/test_edge.py b/tests/edge/test_edge.py index be0346b78..4829dd035 100644 --- a/tests/edge/test_edge.py +++ b/tests/edge/test_edge.py @@ -3,9 +3,9 @@ import logging import math +from datetime import timedelta from unittest.mock import MagicMock -import arrow import numpy as np import pytest from pandas import DataFrame @@ -14,6 +14,7 @@ from freqtrade.data.converter import ohlcv_to_dataframe from freqtrade.edge import Edge, PairInfo from freqtrade.enums import ExitType from freqtrade.exceptions import OperationalException +from freqtrade.util.datetime_helpers import dt_ts, dt_utc from tests.conftest import EXMS, get_patched_freqtradebot, log_has from tests.optimize import (BTContainer, BTrade, _build_backtest_dataframe, _get_frame_time_from_offset) @@ -27,7 +28,7 @@ from tests.optimize import (BTContainer, BTrade, _build_backtest_dataframe, # 5) Stoploss and sell are hit. should sell on stoploss #################################################################### -tests_start_time = arrow.get(2018, 10, 3) +tests_start_time = dt_utc(2018, 10, 3) timeframe_in_minute = 60 # End helper functions @@ -220,7 +221,7 @@ def test_edge_heartbeat_calculate(mocker, edge_conf): heartbeat = edge_conf['edge']['process_throttle_secs'] # should not recalculate if heartbeat not reached - edge._last_updated = arrow.utcnow().int_timestamp - heartbeat + 1 + edge._last_updated = dt_ts() - heartbeat + 1 assert edge.calculate(edge_conf['exchange']['pair_whitelist']) is False @@ -232,7 +233,7 @@ def mocked_load_data(datadir, pairs=[], timeframe='0m', NEOBTC = [ [ - tests_start_time.shift(minutes=(x * timeframe_in_minute)).int_timestamp * 1000, + dt_ts(tests_start_time + timedelta(minutes=(x * timeframe_in_minute))), math.sin(x * hz) / 1000 + base, math.sin(x * hz) / 1000 + base + 0.0001, math.sin(x * hz) / 1000 + base - 0.0001, @@ -244,7 +245,7 @@ def mocked_load_data(datadir, pairs=[], timeframe='0m', base = 0.002 LTCBTC = [ [ - tests_start_time.shift(minutes=(x * timeframe_in_minute)).int_timestamp * 1000, + dt_ts(tests_start_time + timedelta(minutes=(x * timeframe_in_minute))), math.sin(x * hz) / 1000 + base, math.sin(x * hz) / 1000 + base + 0.0001, math.sin(x * hz) / 1000 + base - 0.0001, @@ -268,7 +269,7 @@ def test_edge_process_downloaded_data(mocker, edge_conf): assert edge.calculate(edge_conf['exchange']['pair_whitelist']) assert len(edge._cached_pairs) == 2 - assert edge._last_updated <= arrow.utcnow().int_timestamp + 2 + assert edge._last_updated <= dt_ts() + 2 def test_edge_process_no_data(mocker, edge_conf, caplog): diff --git a/tests/exchange/test_binance.py b/tests/exchange/test_binance.py index d44dae00d..9018d2db9 100644 --- a/tests/exchange/test_binance.py +++ b/tests/exchange/test_binance.py @@ -514,7 +514,7 @@ def test_fill_leverage_tiers_binance_dryrun(default_conf, mocker, leverage_tiers def test_additional_exchange_init_binance(default_conf, mocker): api_mock = MagicMock() - api_mock.fapiPrivateGetPositionsideDual = MagicMock(return_value={"dualSidePosition": True}) + 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 @@ -522,12 +522,12 @@ def test_additional_exchange_init_binance(default_conf, mocker): 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.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") + "additional_exchange_init", "fapiPrivateGetPositionSideDual") def test__set_leverage_binance(mocker, default_conf): diff --git a/tests/exchange/test_ccxt_compat.py b/tests/exchange/test_ccxt_compat.py index 60855ca54..51d016d11 100644 --- a/tests/exchange/test_ccxt_compat.py +++ b/tests/exchange/test_ccxt_compat.py @@ -43,6 +43,11 @@ EXCHANGES = { 'hasQuoteVolumeFutures': True, 'leverage_tiers_public': False, 'leverage_in_spot_market': False, + 'trades_lookback_hours': 4, + 'private_methods': [ + 'fapiPrivateGetPositionSideDual', + 'fapiPrivateGetMultiAssetsMargin' + ], 'sample_order': [{ "symbol": "SOLUSDT", "orderId": 3551312894, @@ -88,12 +93,13 @@ EXCHANGES = { }] }, 'kraken': { - 'pair': 'BTC/USDT', - 'stake_currency': 'USDT', + 'pair': 'BTC/USD', + 'stake_currency': 'USD', 'hasQuoteVolume': True, 'timeframe': '1h', 'leverage_tiers_public': False, 'leverage_in_spot_market': True, + 'trades_lookback_hours': 12, }, 'kucoin': { 'pair': 'XRP/USDT', @@ -221,11 +227,13 @@ EXCHANGES = { 'hasQuoteVolumeFutures': False, 'leverage_tiers_public': True, 'leverage_in_spot_market': True, + 'private_methods': ['fetch_accounts'], }, 'bybit': { 'pair': 'BTC/USDT', 'stake_currency': 'USDT', 'hasQuoteVolume': True, + 'use_ci_proxy': True, 'timeframe': '1h', 'futures_pair': 'BTC/USDT:USDT', 'futures': True, @@ -285,11 +293,7 @@ def set_test_proxy(config: Config, use_proxy: bool) -> Config: if use_proxy and (proxy := os.environ.get('CI_WEB_PROXY')): config1 = deepcopy(config) config1['exchange']['ccxt_config'] = { - "aiohttp_proxy": proxy, - 'proxies': { - 'https': proxy, - 'http': proxy, - } + "httpsProxy": proxy, } return config1 @@ -302,7 +306,7 @@ def exchange(request, exchange_conf): exchange_conf, EXCHANGES[request.param].get('use_ci_proxy', False)) exchange_conf['exchange']['name'] = request.param exchange_conf['stake_currency'] = EXCHANGES[request.param]['stake_currency'] - exchange = ExchangeResolver.load_exchange(request.param, exchange_conf, validate=True) + exchange = ExchangeResolver.load_exchange(exchange_conf, validate=True) yield exchange, request.param @@ -330,13 +334,13 @@ def exchange_futures(request, exchange_conf, class_mocker): class_mocker.patch(f'{EXMS}.cache_leverage_tiers') exchange = ExchangeResolver.load_exchange( - request.param, exchange_conf, validate=True, load_leverage_tiers=True) + exchange_conf, validate=True, load_leverage_tiers=True) yield exchange, request.param @pytest.mark.longrun -class TestCCXTExchange(): +class TestCCXTExchange: def test_load_markets(self, exchange: EXCHANGE_FIXTURE_TYPE): exch, exchangename = exchange @@ -634,7 +638,21 @@ class TestCCXTExchange(): assert isinstance(funding_fee, float) # assert funding_fee > 0 - # TODO: tests fetch_trades (?) + def test_ccxt__async_get_trade_history(self, exchange: EXCHANGE_FIXTURE_TYPE): + exch, exchangename = exchange + if not (lookback := EXCHANGES[exchangename].get('trades_lookback_hours')): + pytest.skip('test_fetch_trades not enabled for this exchange') + pair = EXCHANGES[exchangename]['pair'] + since = int((datetime.now(timezone.utc) - timedelta(hours=lookback)).timestamp() * 1000) + res = exch.loop.run_until_complete( + exch._async_get_trade_history(pair, since, None, None) + ) + assert len(res) == 2 + res_pair, res_trades = res + assert res_pair == pair + assert isinstance(res_trades, list) + assert res_trades[0][0] >= since + assert len(res_trades) > 1200 def test_ccxt_get_fee(self, exchange: EXCHANGE_FIXTURE_TYPE): exch, exchangename = exchange @@ -755,3 +773,8 @@ class TestCCXTExchange(): max_stake_amount = futures.get_max_pair_stake_amount(futures_pair, 40000) assert (isinstance(max_stake_amount, float)) assert max_stake_amount >= 0.0 + + def test_private_method_presence(self, exchange: EXCHANGE_FIXTURE_TYPE): + exch, exchangename = exchange + for method in EXCHANGES[exchangename].get('private_methods', []): + assert hasattr(exch._api, method) diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index b0760944a..5fa2755d2 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -5,7 +5,6 @@ from datetime import datetime, timedelta, timezone from random import randint from unittest.mock import MagicMock, Mock, PropertyMock, patch -import arrow import ccxt import pytest from ccxt import DECIMAL_PLACES, ROUND, ROUND_UP, TICK_SIZE, TRUNCATE @@ -20,9 +19,10 @@ from freqtrade.exchange import (Binance, Bittrex, Exchange, Kraken, amount_to_pr timeframe_to_minutes, timeframe_to_msecs, timeframe_to_next_date, timeframe_to_prev_date, timeframe_to_seconds) from freqtrade.exchange.common import (API_FETCH_ORDER_RETRY_COUNT, API_RETRY_COUNT, - calculate_backoff, remove_credentials) + calculate_backoff, remove_exchange_credentials) from freqtrade.exchange.exchange import amount_to_contract_precision from freqtrade.resolvers.exchange_resolver import ExchangeResolver +from freqtrade.util import dt_now, dt_ts from tests.conftest import (EXMS, generate_test_data_raw, get_mock_coro, get_patched_exchange, log_has, log_has_re, num_log_has_re) @@ -137,16 +137,14 @@ def test_init(default_conf, mocker, caplog): assert log_has('Instance is running with dry_run enabled', caplog) -def test_remove_credentials(default_conf, caplog) -> None: +def test_remove_exchange_credentials(default_conf) -> None: conf = deepcopy(default_conf) - conf['dry_run'] = False - remove_credentials(conf) + remove_exchange_credentials(conf['exchange'], False) assert conf['exchange']['key'] != '' assert conf['exchange']['secret'] != '' - conf['dry_run'] = True - remove_credentials(conf) + remove_exchange_credentials(conf['exchange'], True) assert conf['exchange']['key'] == '' assert conf['exchange']['secret'] == '' assert conf['exchange']['password'] == '' @@ -228,27 +226,30 @@ def test_exchange_resolver(default_conf, mocker, caplog): mocker.patch(f'{EXMS}.validate_timeframes') mocker.patch(f'{EXMS}.validate_stakecurrency') mocker.patch(f'{EXMS}.validate_pricing') - - exchange = ExchangeResolver.load_exchange('zaif', default_conf) + default_conf['exchange']['name'] = 'zaif' + exchange = ExchangeResolver.load_exchange(default_conf) assert isinstance(exchange, Exchange) assert log_has_re(r"No .* specific subclass found. Using the generic class instead.", caplog) caplog.clear() - exchange = ExchangeResolver.load_exchange('Bittrex', default_conf) + default_conf['exchange']['name'] = 'Bittrex' + exchange = ExchangeResolver.load_exchange(default_conf) assert isinstance(exchange, Exchange) assert isinstance(exchange, Bittrex) assert not log_has_re(r"No .* specific subclass found. Using the generic class instead.", caplog) caplog.clear() - exchange = ExchangeResolver.load_exchange('kraken', default_conf) + default_conf['exchange']['name'] = 'kraken' + exchange = ExchangeResolver.load_exchange(default_conf) assert isinstance(exchange, Exchange) assert isinstance(exchange, Kraken) assert not isinstance(exchange, Binance) assert not log_has_re(r"No .* specific subclass found. Using the generic class instead.", caplog) - exchange = ExchangeResolver.load_exchange('binance', default_conf) + default_conf['exchange']['name'] = 'binance' + exchange = ExchangeResolver.load_exchange(default_conf) assert isinstance(exchange, Exchange) assert isinstance(exchange, Binance) assert not isinstance(exchange, Kraken) @@ -257,7 +258,8 @@ def test_exchange_resolver(default_conf, mocker, caplog): caplog) # Test mapping - exchange = ExchangeResolver.load_exchange('binanceus', default_conf) + default_conf['exchange']['name'] = 'binanceus' + exchange = ExchangeResolver.load_exchange(default_conf) assert isinstance(exchange, Exchange) assert isinstance(exchange, Binance) assert not isinstance(exchange, Kraken) @@ -631,21 +633,23 @@ def test__load_markets(default_conf, mocker, caplog): assert ex.markets == expected_return -def test_reload_markets(default_conf, mocker, caplog): +def test_reload_markets(default_conf, mocker, caplog, time_machine): caplog.set_level(logging.DEBUG) initial_markets = {'ETH/BTC': {}} updated_markets = {'ETH/BTC': {}, "LTC/BTC": {}} - + start_dt = dt_now() + time_machine.move_to(start_dt, tick=False) api_mock = MagicMock() api_mock.load_markets = MagicMock(return_value=initial_markets) default_conf['exchange']['markets_refresh_interval'] = 10 exchange = get_patched_exchange(mocker, default_conf, api_mock, id="binance", mock_markets=False) exchange._load_async_markets = MagicMock() - exchange._last_markets_refresh = arrow.utcnow().int_timestamp + assert exchange._last_markets_refresh == dt_ts() assert exchange.markets == initial_markets + time_machine.move_to(start_dt + timedelta(minutes=8), tick=False) # less than 10 minutes have passed, no reload exchange.reload_markets() assert exchange.markets == initial_markets @@ -653,12 +657,18 @@ def test_reload_markets(default_conf, mocker, caplog): api_mock.load_markets = MagicMock(return_value=updated_markets) # more than 10 minutes have passed, reload is executed - exchange._last_markets_refresh = arrow.utcnow().int_timestamp - 15 * 60 + time_machine.move_to(start_dt + timedelta(minutes=11), tick=False) exchange.reload_markets() assert exchange.markets == updated_markets assert exchange._load_async_markets.call_count == 1 assert log_has('Performing scheduled market reload..', caplog) + # Not called again + exchange._load_async_markets.reset_mock() + + exchange.reload_markets() + assert exchange._load_async_markets.call_count == 0 + def test_reload_markets_exception(default_conf, mocker, caplog): caplog.set_level(logging.DEBUG) @@ -990,19 +1000,20 @@ def test_validate_pricing(default_conf, mocker): mocker.patch(f'{EXMS}.validate_timeframes') mocker.patch(f'{EXMS}.validate_stakecurrency') mocker.patch(f'{EXMS}.name', 'Binance') - ExchangeResolver.load_exchange('binance', default_conf) + default_conf['exchange']['name'] = 'binance' + ExchangeResolver.load_exchange(default_conf) has.update({'fetchTicker': False}) with pytest.raises(OperationalException, match="Ticker pricing not available for .*"): - ExchangeResolver.load_exchange('binance', default_conf) + ExchangeResolver.load_exchange(default_conf) has.update({'fetchTicker': True}) default_conf['exit_pricing']['use_order_book'] = True - ExchangeResolver.load_exchange('binance', default_conf) + ExchangeResolver.load_exchange(default_conf) has.update({'fetchL2OrderBook': False}) with pytest.raises(OperationalException, match="Orderbook not available for .*"): - ExchangeResolver.load_exchange('binance', default_conf) + ExchangeResolver.load_exchange(default_conf) has.update({'fetchL2OrderBook': True}) @@ -1011,7 +1022,7 @@ def test_validate_pricing(default_conf, mocker): default_conf['margin_mode'] = MarginMode.ISOLATED with pytest.raises(OperationalException, match="Ticker pricing not available for .*"): - ExchangeResolver.load_exchange('binance', default_conf) + ExchangeResolver.load_exchange(default_conf) def test_validate_ordertypes(default_conf, mocker): @@ -1091,12 +1102,13 @@ def test_validate_ordertypes_stop_advanced(default_conf, mocker, exchange_name, 'stoploss_on_exchange': True, 'stoploss_price_type': stopadv, } + default_conf['exchange']['name'] = exchange_name if expected: - ExchangeResolver.load_exchange(exchange_name, default_conf) + ExchangeResolver.load_exchange(default_conf) else: with pytest.raises(OperationalException, match=r'On exchange stoploss price type is not supported for .*'): - ExchangeResolver.load_exchange(exchange_name, default_conf) + ExchangeResolver.load_exchange(default_conf) def test_validate_order_types_not_in_config(default_conf, mocker): @@ -1773,6 +1785,71 @@ def test_fetch_positions(default_conf, mocker, exchange_name): "fetch_positions", "fetch_positions") +@pytest.mark.parametrize("exchange_name", EXCHANGES) +def test_fetch_orders(default_conf, mocker, exchange_name, limit_order): + + api_mock = MagicMock() + api_mock.fetch_orders = MagicMock(return_value=[ + limit_order['buy'], + limit_order['sell'], + ]) + api_mock.fetch_open_orders = MagicMock(return_value=[limit_order['buy']]) + api_mock.fetch_closed_orders = MagicMock(return_value=[limit_order['buy']]) + + mocker.patch(f'{EXMS}.exchange_has', return_value=True) + start_time = datetime.now(timezone.utc) - timedelta(days=5) + + exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) + # Not available in dry-run + assert exchange.fetch_orders('mocked', start_time) == [] + assert api_mock.fetch_orders.call_count == 0 + default_conf['dry_run'] = False + + exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) + res = exchange.fetch_orders('mocked', start_time) + assert api_mock.fetch_orders.call_count == 1 + assert api_mock.fetch_open_orders.call_count == 0 + assert api_mock.fetch_closed_orders.call_count == 0 + assert len(res) == 2 + + res = exchange.fetch_orders('mocked', start_time) + + api_mock.fetch_orders.reset_mock() + + def has_resp(_, endpoint): + if endpoint == 'fetchOrders': + return False + if endpoint == 'fetchClosedOrders': + return True + if endpoint == 'fetchOpenOrders': + return True + + mocker.patch(f'{EXMS}.exchange_has', has_resp) + + # happy path without fetchOrders + res = exchange.fetch_orders('mocked', start_time) + assert api_mock.fetch_orders.call_count == 0 + assert api_mock.fetch_open_orders.call_count == 1 + assert api_mock.fetch_closed_orders.call_count == 1 + + mocker.patch(f'{EXMS}.exchange_has', return_value=True) + + ccxt_exceptionhandlers(mocker, default_conf, api_mock, exchange_name, + "fetch_orders", "fetch_orders", retries=1, + pair='mocked', since=start_time) + + # Unhappy path - first fetch-orders call fails. + api_mock.fetch_orders = MagicMock(side_effect=ccxt.NotSupported()) + api_mock.fetch_open_orders.reset_mock() + api_mock.fetch_closed_orders.reset_mock() + + res = exchange.fetch_orders('mocked', start_time) + + assert api_mock.fetch_orders.call_count == 1 + assert api_mock.fetch_open_orders.call_count == 1 + assert api_mock.fetch_closed_orders.call_count == 1 + + def test_fetch_trading_fees(default_conf, mocker): api_mock = MagicMock() tick = { @@ -2007,7 +2084,7 @@ def test_get_historic_ohlcv(default_conf, mocker, caplog, exchange_name, candle_ exchange = get_patched_exchange(mocker, default_conf, id=exchange_name) ohlcv = [ [ - arrow.utcnow().int_timestamp * 1000, # unix timestamp ms + dt_ts(), # unix timestamp ms 1, # open 2, # high 3, # low @@ -2027,7 +2104,7 @@ def test_get_historic_ohlcv(default_conf, mocker, caplog, exchange_name, candle_ ret = exchange.get_historic_ohlcv( pair, "5m", - int((arrow.utcnow().int_timestamp - since) * 1000), + dt_ts(dt_now() - timedelta(seconds=since)), candle_type=candle_type ) @@ -2045,7 +2122,7 @@ def test_get_historic_ohlcv(default_conf, mocker, caplog, exchange_name, candle_ ret = exchange.get_historic_ohlcv( pair, "5m", - int((arrow.utcnow().int_timestamp - since) * 1000), + dt_ts(dt_now() - timedelta(seconds=since)), candle_type=candle_type ) assert log_has_re(r"Async code raised an exception: .*", caplog) @@ -2097,7 +2174,7 @@ async def test__async_get_historic_ohlcv(default_conf, mocker, caplog, exchange_ def test_refresh_latest_ohlcv(mocker, default_conf, caplog, candle_type) -> None: ohlcv = [ [ - (arrow.utcnow().shift(minutes=-5).int_timestamp) * 1000, # unix timestamp ms + dt_ts(dt_now() - timedelta(minutes=5)), # unix timestamp ms 1, # open 2, # high 3, # low @@ -2105,7 +2182,7 @@ def test_refresh_latest_ohlcv(mocker, default_conf, caplog, candle_type) -> None 5, # volume (in quote currency) ], [ - arrow.utcnow().int_timestamp * 1000, # unix timestamp ms + dt_ts(), # unix timestamp ms 3, # open 1, # high 4, # low @@ -2295,7 +2372,7 @@ def test_refresh_latest_ohlcv_cache(mocker, default_conf, candle_type, time_mach async def test__async_get_candle_history(default_conf, mocker, caplog, exchange_name): ohlcv = [ [ - arrow.utcnow().int_timestamp * 1000, # unix timestamp ms + dt_ts(), # unix timestamp ms 1, # open 2, # high 3, # low @@ -2332,7 +2409,7 @@ async def test__async_get_candle_history(default_conf, mocker, caplog, exchange_ api_mock.fetch_ohlcv = MagicMock(side_effect=ccxt.BaseError("Unknown error")) exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) await exchange._async_get_candle_history(pair, "5m", CandleType.SPOT, - (arrow.utcnow().int_timestamp - 2000) * 1000) + dt_ts(dt_now() - timedelta(seconds=2000))) exchange.close() @@ -2341,7 +2418,7 @@ async def test__async_get_candle_history(default_conf, mocker, caplog, exchange_ api_mock.fetch_ohlcv = MagicMock(side_effect=ccxt.NotSupported("Not supported")) exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) await exchange._async_get_candle_history(pair, "5m", CandleType.SPOT, - (arrow.utcnow().int_timestamp - 2000) * 1000) + dt_ts(dt_now() - timedelta(seconds=2000))) exchange.close() @@ -2364,7 +2441,7 @@ async def test__async_kucoin_get_candle_history(default_conf, mocker, caplog): with pytest.raises(DDosProtection, match=r'429 Too Many Requests'): await exchange._async_get_candle_history( "ETH/BTC", "5m", CandleType.SPOT, - since_ms=(arrow.utcnow().int_timestamp - 2000) * 1000, count=3) + since_ms=dt_ts(dt_now() - timedelta(seconds=2000)), count=3) assert num_log_has_re(msg, caplog) == 3 caplog.clear() @@ -2381,7 +2458,7 @@ async def test__async_kucoin_get_candle_history(default_conf, mocker, caplog): with pytest.raises(DDosProtection, match=r'429 Too Many Requests'): await exchange._async_get_candle_history( "ETH/BTC", "5m", CandleType.SPOT, - (arrow.utcnow().int_timestamp - 2000) * 1000, count=3) + dt_ts(dt_now() - timedelta(seconds=2000)), count=3) # Expect the "returned exception" message 12 times (4 retries * 3 (loop)) assert num_log_has_re(msg, caplog) == 12 assert num_log_has_re(msg2, caplog) == 9 @@ -2839,14 +2916,14 @@ async def test__async_fetch_trades(default_conf, mocker, caplog, exchange_name, with pytest.raises(OperationalException, match=r'Could not fetch trade data*'): api_mock.fetch_trades = MagicMock(side_effect=ccxt.BaseError("Unknown error")) exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) - await exchange._async_fetch_trades(pair, since=(arrow.utcnow().int_timestamp - 2000) * 1000) + await exchange._async_fetch_trades(pair, since=dt_ts(dt_now() - timedelta(seconds=2000))) exchange.close() with pytest.raises(OperationalException, match=r'Exchange.* does not support fetching ' r'historical trade data\..*'): api_mock.fetch_trades = MagicMock(side_effect=ccxt.NotSupported("Not supported")) exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) - await exchange._async_fetch_trades(pair, since=(arrow.utcnow().int_timestamp - 2000) * 1000) + await exchange._async_fetch_trades(pair, since=dt_ts(dt_now() - timedelta(seconds=2000))) exchange.close() @@ -3412,6 +3489,27 @@ def test_stoploss_order_unsupported_exchange(default_conf, mocker): exchange.stoploss_adjust(1, {}, side="sell") +@pytest.mark.parametrize('side,ratio,expected', [ + ('sell', 0.99, 99.0), # Default + ('sell', 0.999, 99.9), + ('sell', 1, 100), + ('sell', 1.1, InvalidOrderException), + ('buy', 0.99, 101.0), # Default + ('buy', 0.999, 100.1), + ('buy', 1, 100), + ('buy', 1.1, InvalidOrderException), + ]) +def test__get_stop_limit_rate(default_conf_usdt, mocker, side, ratio, expected): + exchange = get_patched_exchange(mocker, default_conf_usdt, id='binance') + + order_types = {'stoploss_on_exchange_limit_ratio': ratio} + if isinstance(expected, type) and issubclass(expected, Exception): + with pytest.raises(expected): + exchange._get_stop_limit_rate(100, order_types, side) + else: + assert exchange._get_stop_limit_rate(100, order_types, side) == expected + + def test_merge_ft_has_dict(default_conf, mocker): mocker.patch.multiple(EXMS, _init_ccxt=MagicMock(return_value=MagicMock()), @@ -4932,7 +5030,7 @@ def test_get_maintenance_ratio_and_amt_exceptions(mocker, default_conf, leverage exchange._leverage_tiers = leverage_tiers with pytest.raises( - OperationalException, + DependencyException, match='nominal value can not be lower than 0', ): exchange.get_maintenance_ratio_and_amt('1000SHIB/USDT:USDT', -1) diff --git a/tests/exchange/test_okx.py b/tests/exchange/test_okx.py index 7a3fa22f0..e8f059118 100644 --- a/tests/exchange/test_okx.py +++ b/tests/exchange/test_okx.py @@ -1,14 +1,14 @@ from datetime import datetime, timedelta, timezone from pathlib import Path -from unittest.mock import MagicMock, PropertyMock +from unittest.mock import AsyncMock, MagicMock, PropertyMock import ccxt import pytest from freqtrade.enums import CandleType, MarginMode, TradingMode -from freqtrade.exceptions import RetryableOrderError +from freqtrade.exceptions import RetryableOrderError, TemporaryError from freqtrade.exchange.exchange import timeframe_to_minutes -from tests.conftest import EXMS, get_mock_coro, get_patched_exchange, log_has +from tests.conftest import EXMS, get_patched_exchange, log_has from tests.exchange.test_exchange import ccxt_exceptionhandlers @@ -278,7 +278,7 @@ def test_load_leverage_tiers_okx(default_conf, mocker, markets, tmpdir, caplog, 'fetchLeverageTiers': False, 'fetchMarketLeverageTiers': True, }) - api_mock.fetch_market_leverage_tiers = get_mock_coro(side_effect=[ + api_mock.fetch_market_leverage_tiers = AsyncMock(side_effect=[ [ { 'tier': 1, @@ -341,6 +341,7 @@ def test_load_leverage_tiers_okx(default_conf, mocker, markets, tmpdir, caplog, } }, ], + TemporaryError("this Failed"), [ { 'tier': 1, @@ -498,7 +499,11 @@ def test__set_leverage_okx(mocker, default_conf): assert api_mock.set_leverage.call_args_list[0][1]['params'] == { 'mgnMode': 'isolated', 'posSide': 'net'} + api_mock.set_leverage = MagicMock(side_effect=ccxt.NetworkError()) + exchange._lev_prep('BTC/USDT:USDT', 3.2, 'buy') + api_mock.fetch_leverage.call_count == 1 + api_mock.fetch_leverage = MagicMock(side_effect=ccxt.NetworkError()) ccxt_exceptionhandlers( mocker, default_conf, @@ -591,3 +596,25 @@ def test_stoploss_adjust_okx(mocker, default_conf, sl1, sl2, sl3, side): } assert exchange.stoploss_adjust(sl1, order, side=side) assert not exchange.stoploss_adjust(sl2, order, side=side) + + +def test_stoploss_cancel_okx(mocker, default_conf): + exchange = get_patched_exchange(mocker, default_conf, id='okx') + + exchange.cancel_order = MagicMock() + + exchange.cancel_stoploss_order('1234', 'ETH/USDT') + assert exchange.cancel_order.call_count == 1 + assert exchange.cancel_order.call_args_list[0][1]['order_id'] == '1234' + assert exchange.cancel_order.call_args_list[0][1]['pair'] == 'ETH/USDT' + assert exchange.cancel_order.call_args_list[0][1]['params'] == {'stop': True} + + +def test__get_stop_params_okx(mocker, default_conf): + default_conf['trading_mode'] = 'futures' + default_conf['margin_mode'] = 'isolated' + exchange = get_patched_exchange(mocker, default_conf, id='okx') + params = exchange._get_stop_params('ETH/USDT:USDT', 1500, 'sell') + + assert params['tdMode'] == 'isolated' + assert params['posSide'] == 'net' diff --git a/tests/freqai/conftest.py b/tests/freqai/conftest.py index ab4a62a9e..4c4891ceb 100644 --- a/tests/freqai/conftest.py +++ b/tests/freqai/conftest.py @@ -1,3 +1,4 @@ +import platform from copy import deepcopy from pathlib import Path from typing import Any, Dict @@ -14,6 +15,11 @@ from freqtrade.resolvers.freqaimodel_resolver import FreqaiModelResolver from tests.conftest import get_patched_exchange +def is_mac() -> bool: + machine = platform.system() + return "Darwin" in machine + + @pytest.fixture(scope="function") def freqai_conf(default_conf, tmpdir): freqaiconf = deepcopy(default_conf) @@ -36,6 +42,7 @@ def freqai_conf(default_conf, tmpdir): "identifier": "uniqe-id100", "live_trained_timestamp": 0, "data_kitchen_thread_count": 2, + "activate_tensorboard": False, "feature_parameters": { "include_timeframes": ["5m"], "include_corr_pairlist": ["ADA/BTC"], diff --git a/tests/freqai/test_freqai_datakitchen.py b/tests/freqai/test_freqai_datakitchen.py index 3f0fc697d..8d09cfc58 100644 --- a/tests/freqai/test_freqai_datakitchen.py +++ b/tests/freqai/test_freqai_datakitchen.py @@ -9,9 +9,10 @@ from freqtrade.configuration import TimeRange from freqtrade.data.dataprovider import DataProvider from freqtrade.exceptions import OperationalException from freqtrade.freqai.data_kitchen import FreqaiDataKitchen -from tests.conftest import get_patched_exchange, log_has_re +from tests.conftest import get_patched_exchange from tests.freqai.conftest import (get_patched_data_kitchen, get_patched_freqai_strategy, - make_data_dictionary, make_unfiltered_dataframe) + make_unfiltered_dataframe) +from tests.freqai.test_freqai_interface import is_mac @pytest.mark.parametrize( @@ -71,68 +72,6 @@ def test_check_if_model_expired(mocker, freqai_conf): shutil.rmtree(Path(dk.full_path)) -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(r"DBSCAN found eps of 1\.7\d\.", caplog) - - -def test_compute_distances(mocker, freqai_conf): - freqai = make_data_dictionary(mocker, freqai_conf) - freqai_conf['freqai']['feature_parameters'].update({"DI_threshold": 1}) - avg_mean_dist = freqai.dk.compute_distances() - assert round(avg_mean_dist, 2) == 1.98 - - -def test_use_SVM_to_remove_outliers_and_outlier_protection(mocker, freqai_conf, caplog): - freqai = make_data_dictionary(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.83%", - caplog, - ) - - -def test_compute_inlier_metric(mocker, freqai_conf, caplog): - freqai = make_data_dictionary(mocker, freqai_conf) - freqai_conf['freqai']['feature_parameters'].update({"inlier_metric_window": 10}) - freqai.dk.compute_inlier_metric(set_='train') - assert log_has_re( - "Inlier metric computed and added to features.", - caplog, - ) - - -def test_add_noise_to_training_features(mocker, freqai_conf): - freqai = make_data_dictionary(mocker, freqai_conf) - freqai_conf['freqai']['feature_parameters'].update({"noise_standard_deviation": 0.1}) - freqai.dk.add_noise_to_training_features() - - -def test_remove_beginning_points_from_data_dict(mocker, freqai_conf): - freqai = make_data_dictionary(mocker, freqai_conf) - freqai.dk.remove_beginning_points_from_data_dict(set_='train') - - -def test_principal_component_analysis(mocker, freqai_conf, caplog): - freqai = make_data_dictionary(mocker, freqai_conf) - freqai.dk.principal_component_analysis() - assert log_has_re( - "reduced feature dimension by", - caplog, - ) - - -def test_normalize_data(mocker, freqai_conf): - freqai = make_data_dictionary(mocker, freqai_conf) - data_dict = freqai.dk.data_dictionary - freqai.dk.normalize_data(data_dict) - assert any('_max' in entry for entry in freqai.dk.data.keys()) - assert any('_min' in entry for entry in freqai.dk.data.keys()) - - def test_filter_features(mocker, freqai_conf): freqai, unfiltered_dataframe = make_unfiltered_dataframe(mocker, freqai_conf) freqai.dk.find_features(unfiltered_dataframe) @@ -173,6 +112,9 @@ def test_get_full_model_path(mocker, freqai_conf, model): freqai_conf.update({"timerange": "20180110-20180130"}) freqai_conf.update({"strategy": "freqai_test_strat"}) + if is_mac(): + pytest.skip("Mac is confused during this test for unknown reasons") + strategy = get_patched_freqai_strategy(mocker, freqai_conf) exchange = get_patched_exchange(mocker, freqai_conf) strategy.dp = DataProvider(freqai_conf, exchange) @@ -188,7 +130,7 @@ def test_get_full_model_path(mocker, freqai_conf, model): data_load_timerange = TimeRange.parse_timerange("20180110-20180130") new_timerange = TimeRange.parse_timerange("20180120-20180130") - + freqai.dk.set_paths('ADA/BTC', None) freqai.extract_data_and_train_model( new_timerange, "ADA/BTC", strategy, freqai.dk, data_load_timerange) diff --git a/tests/freqai/test_freqai_interface.py b/tests/freqai/test_freqai_interface.py index 7346191db..55338f611 100644 --- a/tests/freqai/test_freqai_interface.py +++ b/tests/freqai/test_freqai_interface.py @@ -1,3 +1,4 @@ +import logging import platform import shutil import sys @@ -15,7 +16,7 @@ from freqtrade.optimize.backtesting import Backtesting from freqtrade.persistence import Trade from freqtrade.plugins.pairlistmanager import PairListManager from tests.conftest import EXMS, create_mock_trades, get_patched_exchange, log_has_re -from tests.freqai.conftest import (get_patched_freqai_strategy, make_rl_config, +from tests.freqai.conftest import (get_patched_freqai_strategy, is_mac, make_rl_config, mock_pytorch_mlp_model_training_parameters) @@ -28,39 +29,38 @@ def is_arm() -> bool: return "arm" in machine or "aarch64" in machine -def is_mac() -> bool: - machine = platform.system() - return "Darwin" in machine - - def can_run_model(model: str) -> None: - if (is_arm() or is_py11()) and "Catboost" in model: + if is_arm() and "Catboost" in model: pytest.skip("CatBoost is not supported on ARM.") is_pytorch_model = 'Reinforcement' in model or 'PyTorch' in model if is_pytorch_model and is_mac() and not is_arm(): pytest.skip("Reinforcement learning / PyTorch module not available on intel based Mac OS.") - if is_pytorch_model and is_py11(): - pytest.skip("Reinforcement learning / PyTorch currently not available on python 3.11.") - -@pytest.mark.parametrize('model, pca, dbscan, float32, can_short, shuffle, buffer', [ - ('LightGBMRegressor', True, False, True, True, False, 0), - ('XGBoostRegressor', False, True, False, True, False, 10), - ('XGBoostRFRegressor', False, False, False, True, False, 0), - ('CatboostRegressor', False, False, False, True, True, 0), - ('PyTorchMLPRegressor', False, False, False, True, False, 0), - ('ReinforcementLearner', False, True, False, True, False, 0), - ('ReinforcementLearner_multiproc', False, False, False, True, False, 0), - ('ReinforcementLearner_test_3ac', False, False, False, False, False, 0), - ('ReinforcementLearner_test_3ac', False, False, False, True, False, 0), - ('ReinforcementLearner_test_4ac', False, False, False, True, False, 0), +@pytest.mark.parametrize('model, pca, dbscan, float32, can_short, shuffle, buffer, noise', [ + ('LightGBMRegressor', True, False, True, True, False, 0, 0), + ('XGBoostRegressor', False, True, False, True, False, 10, 0.05), + ('XGBoostRFRegressor', False, False, False, True, False, 0, 0), + ('CatboostRegressor', False, False, False, True, True, 0, 0), + ('PyTorchMLPRegressor', False, False, False, False, False, 0, 0), + ('PyTorchTransformerRegressor', False, False, False, False, False, 0, 0), + ('ReinforcementLearner', False, True, False, True, False, 0, 0), + ('ReinforcementLearner_multiproc', False, False, False, True, False, 0, 0), + ('ReinforcementLearner_test_3ac', False, False, False, False, False, 0, 0), + ('ReinforcementLearner_test_3ac', False, False, False, True, False, 0, 0), + ('ReinforcementLearner_test_4ac', False, False, False, True, False, 0, 0), ]) def test_extract_data_and_train_model_Standard(mocker, freqai_conf, model, pca, - dbscan, float32, can_short, shuffle, buffer): + dbscan, float32, can_short, shuffle, + buffer, noise): can_run_model(model) + + test_tb = True + if is_mac(): + test_tb = False + model_save_ext = 'joblib' freqai_conf.update({"freqaimodel": model}) freqai_conf.update({"timerange": "20180110-20180130"}) @@ -70,22 +70,27 @@ def test_extract_data_and_train_model_Standard(mocker, freqai_conf, model, pca, freqai_conf.update({"reduce_df_footprint": float32}) freqai_conf['freqai']['feature_parameters'].update({"shuffle_after_split": shuffle}) freqai_conf['freqai']['feature_parameters'].update({"buffer_train_data_candles": buffer}) + freqai_conf['freqai']['feature_parameters'].update({"noise_standard_deviation": noise}) if 'ReinforcementLearner' in model: model_save_ext = 'zip' freqai_conf = make_rl_config(freqai_conf) # test the RL guardrails freqai_conf['freqai']['feature_parameters'].update({"use_SVM_to_remove_outliers": True}) + freqai_conf['freqai']['feature_parameters'].update({"DI_threshold": 2}) freqai_conf['freqai']['data_split_parameters'].update({'shuffle': True}) if 'test_3ac' in model or 'test_4ac' in model: freqai_conf["freqaimodel_path"] = str(Path(__file__).parents[1] / "freqai" / "test_models") freqai_conf["freqai"]["rl_config"]["drop_ohlc_from_features"] = True - if 'PyTorchMLPRegressor' in model: + if 'PyTorch' in model: model_save_ext = 'zip' pytorch_mlp_mtp = mock_pytorch_mlp_model_training_parameters() freqai_conf['freqai']['model_training_parameters'].update(pytorch_mlp_mtp) + if 'Transformer' in model: + # transformer model takes a window, unlike the MLP regressor + freqai_conf.update({"conv_width": 10}) strategy = get_patched_freqai_strategy(mocker, freqai_conf) exchange = get_patched_exchange(mocker, freqai_conf) @@ -93,6 +98,7 @@ def test_extract_data_and_train_model_Standard(mocker, freqai_conf, model, pca, strategy.freqai_info = freqai_conf.get("freqai", {}) freqai = strategy.freqai freqai.live = True + freqai.activate_tensorboard = test_tb freqai.can_short = can_short freqai.dk = FreqaiDataKitchen(freqai_conf) freqai.dk.live = True @@ -160,7 +166,6 @@ def test_extract_data_and_train_model_MultiTargets(mocker, freqai_conf, model, s assert Path(freqai.dk.data_path / f"{freqai.dk.model_filename}_model.joblib").is_file() assert Path(freqai.dk.data_path / f"{freqai.dk.model_filename}_metadata.json").is_file() assert Path(freqai.dk.data_path / f"{freqai.dk.model_filename}_trained_df.pkl").is_file() - assert Path(freqai.dk.data_path / f"{freqai.dk.model_filename}_svm_model.joblib").is_file() assert len(freqai.dk.data['training_features_list']) == 14 shutil.rmtree(Path(freqai.dk.full_path)) @@ -216,7 +221,6 @@ def test_extract_data_and_train_model_Classifiers(mocker, freqai_conf, model): f"{freqai.dk.model_filename}_model{model_file_extension}").exists() assert Path(freqai.dk.data_path / f"{freqai.dk.model_filename}_metadata.json").exists() assert Path(freqai.dk.data_path / f"{freqai.dk.model_filename}_trained_df.pkl").exists() - assert Path(freqai.dk.data_path / f"{freqai.dk.model_filename}_svm_model.joblib").exists() shutil.rmtree(Path(freqai.dk.full_path)) @@ -228,6 +232,7 @@ def test_extract_data_and_train_model_Classifiers(mocker, freqai_conf, model): ("XGBoostRegressor", 2, "freqai_test_strat"), ("CatboostRegressor", 2, "freqai_test_strat"), ("PyTorchMLPRegressor", 2, "freqai_test_strat"), + ("PyTorchTransformerRegressor", 2, "freqai_test_strat"), ("ReinforcementLearner", 3, "freqai_rl_test_strat"), ("XGBoostClassifier", 2, "freqai_test_classifier"), ("LightGBMClassifier", 2, "freqai_test_classifier"), @@ -237,6 +242,9 @@ def test_extract_data_and_train_model_Classifiers(mocker, freqai_conf, model): ) def test_start_backtesting(mocker, freqai_conf, model, num_files, strat, caplog): can_run_model(model) + test_tb = True + if is_mac(): + test_tb = False freqai_conf.get("freqai", {}).update({"save_backtest_models": True}) freqai_conf['runmode'] = RunMode.BACKTEST @@ -253,9 +261,12 @@ def test_start_backtesting(mocker, freqai_conf, model, num_files, strat, caplog) if 'test_4ac' in model: freqai_conf["freqaimodel_path"] = str(Path(__file__).parents[1] / "freqai" / "test_models") - if 'PyTorchMLP' in model: + if 'PyTorch' in model: pytorch_mlp_mtp = mock_pytorch_mlp_model_training_parameters() freqai_conf['freqai']['model_training_parameters'].update(pytorch_mlp_mtp) + if 'Transformer' in model: + # transformer model takes a window, unlike the MLP regressor + freqai_conf.update({"conv_width": 10}) freqai_conf.get("freqai", {}).get("feature_parameters", {}).update( {"indicator_periods_candles": [2]}) @@ -266,6 +277,7 @@ def test_start_backtesting(mocker, freqai_conf, model, num_files, strat, caplog) strategy.freqai_info = freqai_conf.get("freqai", {}) freqai = strategy.freqai freqai.live = False + freqai.activate_tensorboard = test_tb freqai.dk = FreqaiDataKitchen(freqai_conf) timerange = TimeRange.parse_timerange("20180110-20180130") freqai.dd.load_all_pair_histories(timerange, freqai.dk) @@ -273,23 +285,13 @@ def test_start_backtesting(mocker, freqai_conf, model, num_files, strat, caplog) _, base_df = freqai.dd.get_base_and_corr_dataframes(sub_timerange, "LTC/BTC", freqai.dk) df = base_df[freqai_conf["timeframe"]] - for i in range(5): - df[f'%-constant_{i}'] = i - metadata = {"pair": "LTC/BTC"} + freqai.dk.set_paths('LTC/BTC', None) freqai.start_backtesting(df, metadata, freqai.dk, strategy) model_folders = [x for x in freqai.dd.full_path.iterdir() if x.is_dir()] assert len(model_folders) == num_files Trade.use_db = True - assert log_has_re( - "Removed features ", - caplog, - ) - assert log_has_re( - "Removed 5 features from prediction features, ", - caplog, - ) Backtesting.cleanup() shutil.rmtree(Path(freqai.dk.full_path)) @@ -414,35 +416,6 @@ def test_backtesting_fit_live_predictions(mocker, freqai_conf, caplog): shutil.rmtree(Path(freqai.dk.full_path)) -def test_principal_component_analysis(mocker, freqai_conf): - freqai_conf.update({"timerange": "20180110-20180130"}) - freqai_conf.get("freqai", {}).get("feature_parameters", {}).update( - {"princpial_component_analysis": "true"}) - - strategy = get_patched_freqai_strategy(mocker, freqai_conf) - exchange = get_patched_exchange(mocker, freqai_conf) - strategy.dp = DataProvider(freqai_conf, exchange) - strategy.freqai_info = freqai_conf.get("freqai", {}) - freqai = strategy.freqai - freqai.live = True - freqai.dk = FreqaiDataKitchen(freqai_conf) - freqai.dk.live = True - timerange = TimeRange.parse_timerange("20180110-20180130") - freqai.dd.load_all_pair_histories(timerange, freqai.dk) - - freqai.dd.pair_dict = MagicMock() - - data_load_timerange = TimeRange.parse_timerange("20180110-20180130") - new_timerange = TimeRange.parse_timerange("20180120-20180130") - - freqai.extract_data_and_train_model( - new_timerange, "ADA/BTC", strategy, freqai.dk, data_load_timerange) - - assert Path(freqai.dk.data_path / f"{freqai.dk.model_filename}_pca_object.pkl") - - shutil.rmtree(Path(freqai.dk.full_path)) - - def test_plot_feature_importance(mocker, freqai_conf): from freqtrade.freqai.utils import plot_feature_importance @@ -467,6 +440,7 @@ def test_plot_feature_importance(mocker, freqai_conf): data_load_timerange = TimeRange.parse_timerange("20180110-20180130") new_timerange = TimeRange.parse_timerange("20180120-20180130") + freqai.dk.set_paths('ADA/BTC', None) freqai.extract_data_and_train_model( new_timerange, "ADA/BTC", strategy, freqai.dk, data_load_timerange) @@ -527,6 +501,7 @@ def test_get_required_data_timerange(mocker, freqai_conf): def test_download_all_data_for_training(mocker, freqai_conf, caplog, tmpdir): + caplog.set_level(logging.DEBUG) strategy = get_patched_freqai_strategy(mocker, freqai_conf) exchange = get_patched_exchange(mocker, freqai_conf) pairlist = PairListManager(exchange, freqai_conf) diff --git a/tests/freqai/test_models/ReinforcementLearner_test_3ac.py b/tests/freqai/test_models/ReinforcementLearner_test_3ac.py index c267c76a8..f77120c3c 100644 --- a/tests/freqai/test_models/ReinforcementLearner_test_3ac.py +++ b/tests/freqai/test_models/ReinforcementLearner_test_3ac.py @@ -18,6 +18,11 @@ class ReinforcementLearner_test_3ac(ReinforcementLearner): """ User can override any function in BaseRLEnv and gym.Env. Here the user sets a custom reward based on profit and trade duration. + + Warning! + This is function is a showcase of functionality designed to show as many possible + environment control features as possible. It is also designed to run quickly + on small computers. This is a benchmark, it is *not* for live production. """ def calculate_reward(self, action: int) -> float: diff --git a/tests/freqai/test_models/ReinforcementLearner_test_4ac.py b/tests/freqai/test_models/ReinforcementLearner_test_4ac.py index 29e3e3b64..4fc2b0005 100644 --- a/tests/freqai/test_models/ReinforcementLearner_test_4ac.py +++ b/tests/freqai/test_models/ReinforcementLearner_test_4ac.py @@ -18,6 +18,11 @@ class ReinforcementLearner_test_4ac(ReinforcementLearner): """ User can override any function in BaseRLEnv and gym.Env. Here the user sets a custom reward based on profit and trade duration. + + Warning! + This is function is a showcase of functionality designed to show as many possible + environment control features as possible. It is also designed to run quickly + on small computers. This is a benchmark, it is *not* for live production. """ def calculate_reward(self, action: int) -> float: diff --git a/tests/optimize/__init__.py b/tests/optimize/__init__.py index a3dd59004..b95764ba5 100644 --- a/tests/optimize/__init__.py +++ b/tests/optimize/__init__.py @@ -1,13 +1,14 @@ +from datetime import timedelta from typing import Dict, List, NamedTuple, Optional -import arrow from pandas import DataFrame from freqtrade.enums import ExitType from freqtrade.exchange import timeframe_to_minutes +from freqtrade.util.datetime_helpers import dt_utc -tests_start_time = arrow.get(2018, 10, 3) +tests_start_time = dt_utc(2018, 10, 3) tests_timeframe = '1h' @@ -46,7 +47,7 @@ class BTContainer(NamedTuple): def _get_frame_time_from_offset(offset): minutes = offset * timeframe_to_minutes(tests_timeframe) - return tests_start_time.shift(minutes=minutes).datetime + return tests_start_time + timedelta(minutes=minutes) def _build_backtest_dataframe(data): diff --git a/tests/optimize/test_backtest_detail.py b/tests/optimize/test_backtest_detail.py index 158dd04dc..82c036e07 100644 --- a/tests/optimize/test_backtest_detail.py +++ b/tests/optimize/test_backtest_detail.py @@ -820,7 +820,7 @@ tc52 = BTContainer(data=[ [2, 4900, 5250, 4500, 5100, 6172, 0, 0], # Order readjust [3, 5100, 5100, 4650, 4750, 6172, 0, 0], # stoploss hit? [4, 4750, 4950, 4350, 4750, 6172, 0, 0]], - stop_loss=-0.03, roi={"0": 0.10}, profit_perc=-0.03, + stop_loss=-0.03, roi={}, profit_perc=-0.03, use_exit_signal=True, timeout=1000, custom_entry_price=4200, adjust_entry_price=5200, trades=[BTrade(exit_reason=ExitType.STOP_LOSS, open_tick=1, close_tick=2, is_short=False)] diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index 9dbda51b0..a333cda9d 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -9,7 +9,6 @@ from unittest.mock import MagicMock, PropertyMock import numpy as np import pandas as pd import pytest -from arrow import Arrow from freqtrade import constants from freqtrade.commands.optimize_commands import setup_optimize_configuration, start_backtesting @@ -26,6 +25,7 @@ from freqtrade.optimize.backtest_caching import get_strategy_run_id from freqtrade.optimize.backtesting import Backtesting from freqtrade.persistence import LocalTrade, Trade from freqtrade.resolvers import StrategyResolver +from freqtrade.util.datetime_helpers import dt_utc from tests.conftest import (CURRENT_TEST_STRATEGY, EXMS, get_args, log_has, log_has_re, patch_exchange, patched_configuration_load_config_file) @@ -346,7 +346,7 @@ def test_backtest_abort(default_conf, mocker, testdatadir) -> None: def test_backtesting_start(default_conf, mocker, caplog) -> None: def get_timerange(input1): - return Arrow(2017, 11, 14, 21, 17), Arrow(2017, 11, 14, 22, 59) + return dt_utc(2017, 11, 14, 21, 17), dt_utc(2017, 11, 14, 22, 59) mocker.patch('freqtrade.data.history.get_timerange', get_timerange) patch_exchange(mocker) @@ -354,7 +354,7 @@ def test_backtesting_start(default_conf, mocker, caplog) -> None: mocker.patch('freqtrade.optimize.backtesting.generate_backtest_stats') mocker.patch('freqtrade.optimize.backtesting.show_backtest_results') sbs = mocker.patch('freqtrade.optimize.backtesting.store_backtest_stats') - sbc = mocker.patch('freqtrade.optimize.backtesting.store_backtest_signal_candles') + sbc = mocker.patch('freqtrade.optimize.backtesting.store_backtest_analysis_results') mocker.patch('freqtrade.plugins.pairlistmanager.PairListManager.whitelist', PropertyMock(return_value=['UNITTEST/BTC'])) @@ -385,7 +385,7 @@ def test_backtesting_start(default_conf, mocker, caplog) -> None: def test_backtesting_start_no_data(default_conf, mocker, caplog, testdatadir) -> None: def get_timerange(input1): - return Arrow(2017, 11, 14, 21, 17), Arrow(2017, 11, 14, 22, 59) + return dt_utc(2017, 11, 14, 21, 17), dt_utc(2017, 11, 14, 22, 59) mocker.patch('freqtrade.data.history.history_utils.load_pair_history', MagicMock(return_value=pd.DataFrame())) @@ -710,11 +710,11 @@ def test_backtest_one(default_conf, fee, mocker, testdatadir) -> None: 'stake_amount': [0.001, 0.001], 'max_stake_amount': [0.001, 0.001], 'amount': [0.00957442, 0.0097064], - 'open_date': pd.to_datetime([Arrow(2018, 1, 29, 18, 40, 0).datetime, - Arrow(2018, 1, 30, 3, 30, 0).datetime], utc=True + 'open_date': pd.to_datetime([dt_utc(2018, 1, 29, 18, 40, 0), + dt_utc(2018, 1, 30, 3, 30, 0)], utc=True ), - 'close_date': pd.to_datetime([Arrow(2018, 1, 29, 22, 35, 0).datetime, - Arrow(2018, 1, 30, 4, 10, 0).datetime], utc=True), + 'close_date': pd.to_datetime([dt_utc(2018, 1, 29, 22, 35, 0), + dt_utc(2018, 1, 30, 4, 10, 0)], utc=True), 'open_rate': [0.104445, 0.10302485], 'close_rate': [0.104969, 0.103541], 'fee_open': [0.0025, 0.0025], @@ -1437,9 +1437,11 @@ def test_backtest_start_multi_strat(default_conf, mocker, caplog, testdatadir): strattable_mock = MagicMock() strat_summary = MagicMock() - mocker.patch.multiple('freqtrade.optimize.optimize_reports', + mocker.patch.multiple('freqtrade.optimize.optimize_reports.bt_output', text_table_bt_results=text_table_mock, text_table_strategy=strattable_mock, + ) + mocker.patch.multiple('freqtrade.optimize.optimize_reports.optimize_reports', generate_pair_metrics=MagicMock(), generate_exit_reason_stats=sell_reason_mock, generate_strategy_comparison=strat_summary, diff --git a/tests/optimize/test_backtesting_adjust_position.py b/tests/optimize/test_backtesting_adjust_position.py index 0d57ff89a..ce26e836e 100644 --- a/tests/optimize/test_backtesting_adjust_position.py +++ b/tests/optimize/test_backtesting_adjust_position.py @@ -5,13 +5,13 @@ from unittest.mock import MagicMock import pandas as pd import pytest -from arrow import Arrow from freqtrade.configuration import TimeRange from freqtrade.data import history from freqtrade.data.history import get_timerange from freqtrade.enums import ExitType, TradingMode from freqtrade.optimize.backtesting import Backtesting +from freqtrade.util.datetime_helpers import dt_utc from tests.conftest import EXMS, patch_exchange @@ -52,11 +52,11 @@ def test_backtest_position_adjustment(default_conf, fee, mocker, testdatadir) -> 'stake_amount': [500.0, 100.0], 'max_stake_amount': [500.0, 100], 'amount': [4806.87657523, 970.63960782], - 'open_date': pd.to_datetime([Arrow(2018, 1, 29, 18, 40, 0).datetime, - Arrow(2018, 1, 30, 3, 30, 0).datetime], utc=True + 'open_date': pd.to_datetime([dt_utc(2018, 1, 29, 18, 40, 0), + dt_utc(2018, 1, 30, 3, 30, 0)], utc=True ), - 'close_date': pd.to_datetime([Arrow(2018, 1, 29, 22, 00, 0).datetime, - Arrow(2018, 1, 30, 4, 10, 0).datetime], utc=True), + 'close_date': pd.to_datetime([dt_utc(2018, 1, 29, 22, 00, 0), + dt_utc(2018, 1, 30, 4, 10, 0)], utc=True), 'open_rate': [0.10401764894444211, 0.10302485], 'close_rate': [0.10453904066847439, 0.103541], 'fee_open': [0.0025, 0.0025], diff --git a/tests/optimize/test_hyperopt.py b/tests/optimize/test_hyperopt.py index 786720030..ed5eeafd6 100644 --- a/tests/optimize/test_hyperopt.py +++ b/tests/optimize/test_hyperopt.py @@ -6,7 +6,6 @@ from unittest.mock import ANY, MagicMock, PropertyMock import pandas as pd import pytest -from arrow import Arrow from filelock import Timeout from skopt.space import Integer @@ -20,6 +19,7 @@ from freqtrade.optimize.hyperopt_tools import HyperoptTools from freqtrade.optimize.optimize_reports import generate_strategy_stats from freqtrade.optimize.space import SKDecimal from freqtrade.strategy import IntParameter +from freqtrade.util import dt_utc from tests.conftest import (CURRENT_TEST_STRATEGY, EXMS, get_args, get_markets, log_has, log_has_re, patch_exchange, patched_configuration_load_config_file) @@ -349,14 +349,14 @@ def test_hyperopt_format_results(hyperopt): "UNITTEST/BTC", "UNITTEST/BTC"], "profit_ratio": [0.003312, 0.010801, 0.013803, 0.002780], "profit_abs": [0.000003, 0.000011, 0.000014, 0.000003], - "open_date": [Arrow(2017, 11, 14, 19, 32, 00).datetime, - Arrow(2017, 11, 14, 21, 36, 00).datetime, - Arrow(2017, 11, 14, 22, 12, 00).datetime, - Arrow(2017, 11, 14, 22, 44, 00).datetime], - "close_date": [Arrow(2017, 11, 14, 21, 35, 00).datetime, - Arrow(2017, 11, 14, 22, 10, 00).datetime, - Arrow(2017, 11, 14, 22, 43, 00).datetime, - Arrow(2017, 11, 14, 22, 58, 00).datetime], + "open_date": [dt_utc(2017, 11, 14, 19, 32, 00), + dt_utc(2017, 11, 14, 21, 36, 00), + dt_utc(2017, 11, 14, 22, 12, 00), + dt_utc(2017, 11, 14, 22, 44, 00)], + "close_date": [dt_utc(2017, 11, 14, 21, 35, 00), + dt_utc(2017, 11, 14, 22, 10, 00), + dt_utc(2017, 11, 14, 22, 43, 00), + dt_utc(2017, 11, 14, 22, 58, 00)], "open_rate": [0.002543, 0.003003, 0.003089, 0.003214], "close_rate": [0.002546, 0.003014, 0.003103, 0.003217], "trade_duration": [123, 34, 31, 14], @@ -379,8 +379,8 @@ def test_hyperopt_format_results(hyperopt): 'backtest_end_time': 1619718665, } results_metrics = generate_strategy_stats(['XRP/BTC'], '', bt_result, - Arrow(2017, 11, 14, 19, 32, 00), - Arrow(2017, 12, 14, 19, 32, 00), market_change=0) + dt_utc(2017, 11, 14, 19, 32, 00), + dt_utc(2017, 12, 14, 19, 32, 00), market_change=0) results_explanation = HyperoptTools.format_results_explanation_string(results_metrics, 'BTC') total_profit = results_metrics['profit_total_abs'] @@ -423,14 +423,14 @@ def test_generate_optimizer(mocker, hyperopt_conf) -> None: "UNITTEST/BTC", "UNITTEST/BTC"], "profit_ratio": [0.003312, 0.010801, 0.013803, 0.002780], "profit_abs": [0.000003, 0.000011, 0.000014, 0.000003], - "open_date": [Arrow(2017, 11, 14, 19, 32, 00).datetime, - Arrow(2017, 11, 14, 21, 36, 00).datetime, - Arrow(2017, 11, 14, 22, 12, 00).datetime, - Arrow(2017, 11, 14, 22, 44, 00).datetime], - "close_date": [Arrow(2017, 11, 14, 21, 35, 00).datetime, - Arrow(2017, 11, 14, 22, 10, 00).datetime, - Arrow(2017, 11, 14, 22, 43, 00).datetime, - Arrow(2017, 11, 14, 22, 58, 00).datetime], + "open_date": [dt_utc(2017, 11, 14, 19, 32, 00), + dt_utc(2017, 11, 14, 21, 36, 00), + dt_utc(2017, 11, 14, 22, 12, 00), + dt_utc(2017, 11, 14, 22, 44, 00)], + "close_date": [dt_utc(2017, 11, 14, 21, 35, 00), + dt_utc(2017, 11, 14, 22, 10, 00), + dt_utc(2017, 11, 14, 22, 43, 00), + dt_utc(2017, 11, 14, 22, 58, 00)], "open_rate": [0.002543, 0.003003, 0.003089, 0.003214], "close_rate": [0.002546, 0.003014, 0.003103, 0.003217], "trade_duration": [123, 34, 31, 14], @@ -453,7 +453,7 @@ def test_generate_optimizer(mocker, hyperopt_conf) -> None: mocker.patch('freqtrade.optimize.hyperopt.Backtesting.backtest', return_value=backtest_result) mocker.patch('freqtrade.optimize.hyperopt.get_timerange', - return_value=(Arrow(2017, 12, 10), Arrow(2017, 12, 13))) + return_value=(dt_utc(2017, 12, 10), dt_utc(2017, 12, 13))) patch_exchange(mocker) mocker.patch.object(Path, 'open') mocker.patch('freqtrade.configuration.config_validation.validate_config_schema') @@ -513,8 +513,8 @@ def test_generate_optimizer(mocker, hyperopt_conf) -> None: } hyperopt = Hyperopt(hyperopt_conf) - hyperopt.min_date = Arrow(2017, 12, 10) - hyperopt.max_date = Arrow(2017, 12, 13) + hyperopt.min_date = dt_utc(2017, 12, 10) + hyperopt.max_date = dt_utc(2017, 12, 13) hyperopt.init_spaces() generate_optimizer_value = hyperopt.generate_optimizer(list(optimizer_param.values())) assert generate_optimizer_value == response_expected diff --git a/tests/optimize/test_lookahead_analysis.py b/tests/optimize/test_lookahead_analysis.py new file mode 100644 index 000000000..3c6a5ad6d --- /dev/null +++ b/tests/optimize/test_lookahead_analysis.py @@ -0,0 +1,366 @@ +# pragma pylint: disable=missing-docstring, W0212, line-too-long, C0103, unused-argument +from copy import deepcopy +from pathlib import Path +from unittest.mock import MagicMock, PropertyMock + +import pytest + +from freqtrade.commands.optimize_commands import start_lookahead_analysis +from freqtrade.data.history import get_timerange +from freqtrade.exceptions import OperationalException +from freqtrade.optimize.lookahead_analysis import Analysis, LookaheadAnalysis +from freqtrade.optimize.lookahead_analysis_helpers import LookaheadAnalysisSubFunctions +from tests.conftest import EXMS, get_args, log_has_re, patch_exchange + + +@pytest.fixture +def lookahead_conf(default_conf_usdt): + default_conf_usdt['minimum_trade_amount'] = 10 + default_conf_usdt['targeted_trade_amount'] = 20 + default_conf_usdt['strategy_path'] = str( + Path(__file__).parent.parent / "strategy/strats/lookahead_bias") + default_conf_usdt['strategy'] = 'strategy_test_v3_with_lookahead_bias' + default_conf_usdt['max_open_trades'] = 1 + default_conf_usdt['dry_run_wallet'] = 1000000000 + default_conf_usdt['pairs'] = ['UNITTEST/USDT'] + return default_conf_usdt + + +def test_start_lookahead_analysis(mocker): + single_mock = MagicMock() + text_table_mock = MagicMock() + mocker.patch.multiple( + 'freqtrade.optimize.lookahead_analysis_helpers.LookaheadAnalysisSubFunctions', + initialize_single_lookahead_analysis=single_mock, + text_table_lookahead_analysis_instances=text_table_mock, + ) + args = [ + "lookahead-analysis", + "--strategy", + "strategy_test_v3_with_lookahead_bias", + "--strategy-path", + str(Path(__file__).parent.parent / "strategy/strats/lookahead_bias"), + "--pairs", + "UNITTEST/BTC", + "--max-open-trades", + "1" + ] + pargs = get_args(args) + pargs['config'] = None + + start_lookahead_analysis(pargs) + assert single_mock.call_count == 1 + assert text_table_mock.call_count == 1 + + single_mock.reset_mock() + + # Test invalid config + args = [ + "lookahead-analysis", + "--strategy", + "strategy_test_v3_with_lookahead_bias", + "--strategy-path", + str(Path(__file__).parent.parent / "strategy/strats/lookahead_bias"), + "--targeted-trade-amount", + "10", + "--minimum-trade-amount", + "20", + ] + pargs = get_args(args) + pargs['config'] = None + with pytest.raises(OperationalException, + match=r"Targeted trade amount can't be smaller than minimum trade amount.*"): + start_lookahead_analysis(pargs) + + +def test_lookahead_helper_invalid_config(lookahead_conf) -> None: + conf = deepcopy(lookahead_conf) + conf['targeted_trade_amount'] = 10 + conf['minimum_trade_amount'] = 40 + with pytest.raises(OperationalException, + match=r"Targeted trade amount can't be smaller than minimum trade amount.*"): + LookaheadAnalysisSubFunctions.start(conf) + + +def test_lookahead_helper_no_strategy_defined(lookahead_conf): + conf = deepcopy(lookahead_conf) + conf['pairs'] = ['UNITTEST/USDT'] + del conf['strategy'] + with pytest.raises(OperationalException, + match=r"No Strategy specified"): + LookaheadAnalysisSubFunctions.start(conf) + + +def test_lookahead_helper_start(lookahead_conf, mocker) -> None: + single_mock = MagicMock() + text_table_mock = MagicMock() + mocker.patch.multiple( + 'freqtrade.optimize.lookahead_analysis_helpers.LookaheadAnalysisSubFunctions', + initialize_single_lookahead_analysis=single_mock, + text_table_lookahead_analysis_instances=text_table_mock, + ) + LookaheadAnalysisSubFunctions.start(lookahead_conf) + assert single_mock.call_count == 1 + assert text_table_mock.call_count == 1 + + single_mock.reset_mock() + text_table_mock.reset_mock() + + +def test_lookahead_helper_text_table_lookahead_analysis_instances(lookahead_conf): + analysis = Analysis() + analysis.has_bias = True + analysis.total_signals = 5 + analysis.false_entry_signals = 4 + analysis.false_exit_signals = 3 + + strategy_obj = { + 'name': "strategy_test_v3_with_lookahead_bias", + 'location': Path(lookahead_conf['strategy_path'], f"{lookahead_conf['strategy']}.py") + } + + instance = LookaheadAnalysis(lookahead_conf, strategy_obj) + instance.current_analysis = analysis + table, headers, data = (LookaheadAnalysisSubFunctions. + text_table_lookahead_analysis_instances(lookahead_conf, [instance])) + + # check row contents for a try that has too few signals + assert data[0][0] == 'strategy_test_v3_with_lookahead_bias.py' + assert data[0][1] == 'strategy_test_v3_with_lookahead_bias' + assert data[0][2].__contains__('too few trades') + assert len(data[0]) == 3 + + # now check for an error which occured after enough trades + analysis.total_signals = 12 + analysis.false_entry_signals = 11 + analysis.false_exit_signals = 10 + instance = LookaheadAnalysis(lookahead_conf, strategy_obj) + instance.current_analysis = analysis + table, headers, data = (LookaheadAnalysisSubFunctions. + text_table_lookahead_analysis_instances(lookahead_conf, [instance])) + assert data[0][2].__contains__("error") + + # edit it into not showing an error + instance.failed_bias_check = False + table, headers, data = (LookaheadAnalysisSubFunctions. + text_table_lookahead_analysis_instances(lookahead_conf, [instance])) + assert data[0][0] == 'strategy_test_v3_with_lookahead_bias.py' + assert data[0][1] == 'strategy_test_v3_with_lookahead_bias' + assert data[0][2] # True + assert data[0][3] == 12 + assert data[0][4] == 11 + assert data[0][5] == 10 + assert data[0][6] == '' + + analysis.false_indicators.append('falseIndicator1') + analysis.false_indicators.append('falseIndicator2') + table, headers, data = (LookaheadAnalysisSubFunctions. + text_table_lookahead_analysis_instances(lookahead_conf, [instance])) + + assert data[0][6] == 'falseIndicator1, falseIndicator2' + + # check amount of returning rows + assert len(data) == 1 + + # check amount of multiple rows + table, headers, data = (LookaheadAnalysisSubFunctions.text_table_lookahead_analysis_instances( + lookahead_conf, [instance, instance, instance])) + assert len(data) == 3 + + +def test_lookahead_helper_export_to_csv(lookahead_conf): + import pandas as pd + lookahead_conf['lookahead_analysis_exportfilename'] = "temp_csv_lookahead_analysis.csv" + + # just to be sure the test won't fail: remove file if exists for some reason + # (repeat this at the end once again to clean up) + if Path(lookahead_conf['lookahead_analysis_exportfilename']).exists(): + Path(lookahead_conf['lookahead_analysis_exportfilename']).unlink() + + # before we can start we have to delete the + + # 1st check: create a new file and verify its contents + analysis1 = Analysis() + analysis1.has_bias = True + analysis1.total_signals = 12 + analysis1.false_entry_signals = 11 + analysis1.false_exit_signals = 10 + analysis1.false_indicators.append('falseIndicator1') + analysis1.false_indicators.append('falseIndicator2') + lookahead_conf['lookahead_analysis_exportfilename'] = "temp_csv_lookahead_analysis.csv" + + strategy_obj1 = { + 'name': "strat1", + 'location': Path("file1.py"), + } + + instance1 = LookaheadAnalysis(lookahead_conf, strategy_obj1) + instance1.failed_bias_check = False + instance1.current_analysis = analysis1 + + LookaheadAnalysisSubFunctions.export_to_csv(lookahead_conf, [instance1]) + saved_data1 = pd.read_csv(lookahead_conf['lookahead_analysis_exportfilename']) + + expected_values1 = [ + [ + 'file1.py', 'strat1', True, + 12, 11, 10, + "falseIndicator1,falseIndicator2" + ], + ] + expected_columns = ['filename', 'strategy', 'has_bias', + 'total_signals', 'biased_entry_signals', 'biased_exit_signals', + 'biased_indicators'] + expected_data1 = pd.DataFrame(expected_values1, columns=expected_columns) + + assert Path(lookahead_conf['lookahead_analysis_exportfilename']).exists() + assert expected_data1.equals(saved_data1) + + # 2nd check: update the same strategy (which internally changed or is being retested) + expected_values2 = [ + [ + 'file1.py', 'strat1', False, + 22, 21, 20, + "falseIndicator3,falseIndicator4" + ], + ] + expected_data2 = pd.DataFrame(expected_values2, columns=expected_columns) + + analysis2 = Analysis() + analysis2.has_bias = False + analysis2.total_signals = 22 + analysis2.false_entry_signals = 21 + analysis2.false_exit_signals = 20 + analysis2.false_indicators.append('falseIndicator3') + analysis2.false_indicators.append('falseIndicator4') + + strategy_obj2 = { + 'name': "strat1", + 'location': Path("file1.py"), + } + + instance2 = LookaheadAnalysis(lookahead_conf, strategy_obj2) + instance2.failed_bias_check = False + instance2.current_analysis = analysis2 + + LookaheadAnalysisSubFunctions.export_to_csv(lookahead_conf, [instance2]) + saved_data2 = pd.read_csv(lookahead_conf['lookahead_analysis_exportfilename']) + + assert expected_data2.equals(saved_data2) + + # 3rd check: now we add a new row to an already existing file + expected_values3 = [ + [ + 'file1.py', 'strat1', False, + 22, 21, 20, + "falseIndicator3,falseIndicator4" + ], + [ + 'file3.py', 'strat3', True, + 32, 31, 30, "falseIndicator5,falseIndicator6" + ], + ] + + expected_data3 = pd.DataFrame(expected_values3, columns=expected_columns) + + analysis3 = Analysis() + analysis3.has_bias = True + analysis3.total_signals = 32 + analysis3.false_entry_signals = 31 + analysis3.false_exit_signals = 30 + analysis3.false_indicators.append('falseIndicator5') + analysis3.false_indicators.append('falseIndicator6') + lookahead_conf['lookahead_analysis_exportfilename'] = "temp_csv_lookahead_analysis.csv" + + strategy_obj3 = { + 'name': "strat3", + 'location': Path("file3.py"), + } + + instance3 = LookaheadAnalysis(lookahead_conf, strategy_obj3) + instance3.failed_bias_check = False + instance3.current_analysis = analysis3 + + LookaheadAnalysisSubFunctions.export_to_csv(lookahead_conf, [instance3]) + saved_data3 = pd.read_csv(lookahead_conf['lookahead_analysis_exportfilename']) + assert expected_data3.equals(saved_data3) + + # remove csv file after the test is done + if Path(lookahead_conf['lookahead_analysis_exportfilename']).exists(): + Path(lookahead_conf['lookahead_analysis_exportfilename']).unlink() + + +def test_initialize_single_lookahead_analysis(lookahead_conf, mocker, caplog): + mocker.patch('freqtrade.data.history.get_timerange', get_timerange) + mocker.patch(f'{EXMS}.get_fee', return_value=0.0) + mocker.patch(f'{EXMS}.get_min_pair_stake_amount', return_value=0.00001) + mocker.patch(f'{EXMS}.get_max_pair_stake_amount', return_value=float('inf')) + patch_exchange(mocker) + mocker.patch('freqtrade.plugins.pairlistmanager.PairListManager.whitelist', + PropertyMock(return_value=['UNITTEST/BTC'])) + lookahead_conf['pairs'] = ['UNITTEST/USDT'] + + lookahead_conf['timeframe'] = '5m' + lookahead_conf['timerange'] = '20180119-20180122' + start_mock = mocker.patch('freqtrade.optimize.lookahead_analysis.LookaheadAnalysis.start') + strategy_obj = { + 'name': "strategy_test_v3_with_lookahead_bias", + 'location': Path(lookahead_conf['strategy_path'], f"{lookahead_conf['strategy']}.py") + } + + instance = LookaheadAnalysisSubFunctions.initialize_single_lookahead_analysis( + lookahead_conf, strategy_obj) + assert log_has_re(r"Bias test of .* started\.", caplog) + assert start_mock.call_count == 1 + + assert instance.strategy_obj['name'] == "strategy_test_v3_with_lookahead_bias" + + +@pytest.mark.parametrize('scenario', [ + 'no_bias', 'bias1' +]) +def test_biased_strategy(lookahead_conf, mocker, caplog, scenario) -> None: + mocker.patch('freqtrade.data.history.get_timerange', get_timerange) + mocker.patch(f'{EXMS}.get_fee', return_value=0.0) + mocker.patch(f'{EXMS}.get_min_pair_stake_amount', return_value=0.00001) + mocker.patch(f'{EXMS}.get_max_pair_stake_amount', return_value=float('inf')) + patch_exchange(mocker) + mocker.patch('freqtrade.plugins.pairlistmanager.PairListManager.whitelist', + PropertyMock(return_value=['UNITTEST/BTC'])) + lookahead_conf['pairs'] = ['UNITTEST/USDT'] + + lookahead_conf['timeframe'] = '5m' + lookahead_conf['timerange'] = '20180119-20180122' + + # Patch scenario Parameter to allow for easy selection + mocker.patch('freqtrade.strategy.hyper.HyperStrategyMixin.load_params_from_file', + return_value={ + 'params': { + "buy": { + "scenario": scenario + } + } + }) + + strategy_obj = {'name': "strategy_test_v3_with_lookahead_bias"} + instance = LookaheadAnalysis(lookahead_conf, strategy_obj) + instance.start() + # Assert init correct + assert log_has_re(f"Strategy Parameter: scenario = {scenario}", caplog) + + # check non-biased strategy + if scenario == "no_bias": + assert not instance.current_analysis.has_bias + # check biased strategy + elif scenario == "bias1": + assert instance.current_analysis.has_bias + + +def test_config_overrides(lookahead_conf): + lookahead_conf['max_open_trades'] = 0 + lookahead_conf['dry_run_wallet'] = 1 + lookahead_conf['pairs'] = ['BTC/USDT', 'ETH/USDT', 'SOL/USDT'] + lookahead_conf = LookaheadAnalysisSubFunctions.calculate_config_overrides(lookahead_conf) + + assert lookahead_conf['dry_run_wallet'] == 1000000000 + assert lookahead_conf['max_open_trades'] == 3 diff --git a/tests/optimize/test_optimize_reports.py b/tests/optimize/test_optimize_reports.py index 0cc32baaf..7b85e7978 100644 --- a/tests/optimize/test_optimize_reports.py +++ b/tests/optimize/test_optimize_reports.py @@ -6,25 +6,27 @@ from shutil import copyfile import joblib import pandas as pd import pytest -from arrow import Arrow from freqtrade.configuration import TimeRange -from freqtrade.constants import DATETIME_PRINT_FORMAT, LAST_BT_RESULT_FN +from freqtrade.constants import BACKTEST_BREAKDOWNS, DATETIME_PRINT_FORMAT, LAST_BT_RESULT_FN from freqtrade.data import history from freqtrade.data.btanalysis import (get_latest_backtest_filename, load_backtest_data, load_backtest_stats) from freqtrade.edge import PairInfo from freqtrade.enums import ExitType -from freqtrade.optimize.optimize_reports import (_get_resample_from_period, generate_backtest_stats, - generate_daily_stats, generate_edge_table, - generate_exit_reason_stats, generate_pair_metrics, +from freqtrade.optimize.optimize_reports import (generate_backtest_stats, generate_daily_stats, + generate_edge_table, generate_exit_reason_stats, + generate_pair_metrics, generate_periodic_breakdown_stats, generate_strategy_comparison, generate_trading_stats, show_sorted_pairlist, - store_backtest_signal_candles, + store_backtest_analysis_results, store_backtest_stats, text_table_bt_results, text_table_exit_reason, text_table_strategy) +from freqtrade.optimize.optimize_reports.optimize_reports import _get_resample_from_period from freqtrade.resolvers.strategy_resolver import StrategyResolver +from freqtrade.util import dt_ts +from freqtrade.util.datetime_helpers import dt_from_ts, dt_utc from tests.conftest import CURRENT_TEST_STRATEGY from tests.data.test_history import _clean_test_file @@ -80,14 +82,14 @@ def test_generate_backtest_stats(default_conf, testdatadir, tmpdir): "UNITTEST/BTC", "UNITTEST/BTC"], "profit_ratio": [0.003312, 0.010801, 0.013803, 0.002780], "profit_abs": [0.000003, 0.000011, 0.000014, 0.000003], - "open_date": [Arrow(2017, 11, 14, 19, 32, 00).datetime, - Arrow(2017, 11, 14, 21, 36, 00).datetime, - Arrow(2017, 11, 14, 22, 12, 00).datetime, - Arrow(2017, 11, 14, 22, 44, 00).datetime], - "close_date": [Arrow(2017, 11, 14, 21, 35, 00).datetime, - Arrow(2017, 11, 14, 22, 10, 00).datetime, - Arrow(2017, 11, 14, 22, 43, 00).datetime, - Arrow(2017, 11, 14, 22, 58, 00).datetime], + "open_date": [dt_utc(2017, 11, 14, 19, 32, 00), + dt_utc(2017, 11, 14, 21, 36, 00), + dt_utc(2017, 11, 14, 22, 12, 00), + dt_utc(2017, 11, 14, 22, 44, 00)], + "close_date": [dt_utc(2017, 11, 14, 21, 35, 00), + dt_utc(2017, 11, 14, 22, 10, 00), + dt_utc(2017, 11, 14, 22, 43, 00), + dt_utc(2017, 11, 14, 22, 58, 00)], "open_rate": [0.002543, 0.003003, 0.003089, 0.003214], "close_rate": [0.002546, 0.003014, 0.003103, 0.003217], "trade_duration": [123, 34, 31, 14], @@ -106,14 +108,14 @@ def test_generate_backtest_stats(default_conf, testdatadir, tmpdir): 'canceled_trade_entries': 0, 'canceled_entry_orders': 0, 'replaced_entry_orders': 0, - 'backtest_start_time': Arrow.utcnow().int_timestamp, - 'backtest_end_time': Arrow.utcnow().int_timestamp, + 'backtest_start_time': dt_ts() // 1000, + 'backtest_end_time': dt_ts() // 1000, 'run_id': '123', } } timerange = TimeRange.parse_timerange('1510688220-1510700340') - min_date = Arrow.fromtimestamp(1510688220) - max_date = Arrow.fromtimestamp(1510700340) + min_date = dt_from_ts(1510688220) + max_date = dt_from_ts(1510700340) btdata = history.load_data(testdatadir, '1m', ['UNITTEST/BTC'], timerange=timerange, fill_up_missing=True) @@ -135,14 +137,14 @@ def test_generate_backtest_stats(default_conf, testdatadir, tmpdir): {"pair": ["UNITTEST/BTC", "UNITTEST/BTC", "UNITTEST/BTC", "UNITTEST/BTC"], "profit_ratio": [0.003312, 0.010801, -0.013803, 0.002780], "profit_abs": [0.000003, 0.000011, -0.000014, 0.000003], - "open_date": [Arrow(2017, 11, 14, 19, 32, 00).datetime, - Arrow(2017, 11, 14, 21, 36, 00).datetime, - Arrow(2017, 11, 14, 22, 12, 00).datetime, - Arrow(2017, 11, 14, 22, 44, 00).datetime], - "close_date": [Arrow(2017, 11, 14, 21, 35, 00).datetime, - Arrow(2017, 11, 14, 22, 10, 00).datetime, - Arrow(2017, 11, 14, 22, 43, 00).datetime, - Arrow(2017, 11, 14, 22, 58, 00).datetime], + "open_date": [dt_utc(2017, 11, 14, 19, 32, 00), + dt_utc(2017, 11, 14, 21, 36, 00), + dt_utc(2017, 11, 14, 22, 12, 00), + dt_utc(2017, 11, 14, 22, 44, 00)], + "close_date": [dt_utc(2017, 11, 14, 21, 35, 00), + dt_utc(2017, 11, 14, 22, 10, 00), + dt_utc(2017, 11, 14, 22, 43, 00), + dt_utc(2017, 11, 14, 22, 58, 00)], "open_rate": [0.002543, 0.003003, 0.003089, 0.003214], "close_rate": [0.002546, 0.003014, 0.0032903, 0.003217], "trade_duration": [123, 34, 31, 14], @@ -161,8 +163,8 @@ def test_generate_backtest_stats(default_conf, testdatadir, tmpdir): 'canceled_trade_entries': 0, 'canceled_entry_orders': 0, 'replaced_entry_orders': 0, - 'backtest_start_time': Arrow.utcnow().int_timestamp, - 'backtest_end_time': Arrow.utcnow().int_timestamp, + 'backtest_start_time': dt_ts() // 1000, + 'backtest_end_time': dt_ts() // 1000, 'run_id': '124', } } @@ -208,7 +210,7 @@ def test_generate_backtest_stats(default_conf, testdatadir, tmpdir): def test_store_backtest_stats(testdatadir, mocker): - dump_mock = mocker.patch('freqtrade.optimize.optimize_reports.file_dump_json') + dump_mock = mocker.patch('freqtrade.optimize.optimize_reports.bt_storage.file_dump_json') store_backtest_stats(testdatadir, {'metadata': {}}, '2022_01_01_15_05_13') @@ -227,22 +229,23 @@ def test_store_backtest_stats(testdatadir, mocker): def test_store_backtest_candles(testdatadir, mocker): - dump_mock = mocker.patch('freqtrade.optimize.optimize_reports.file_dump_joblib') + dump_mock = mocker.patch( + 'freqtrade.optimize.optimize_reports.bt_storage.file_dump_joblib') candle_dict = {'DefStrat': {'UNITTEST/BTC': pd.DataFrame()}} # mock directory exporting - store_backtest_signal_candles(testdatadir, candle_dict, '2022_01_01_15_05_13') + store_backtest_analysis_results(testdatadir, candle_dict, {}, '2022_01_01_15_05_13') - assert dump_mock.call_count == 1 + assert dump_mock.call_count == 2 assert isinstance(dump_mock.call_args_list[0][0][0], Path) assert str(dump_mock.call_args_list[0][0][0]).endswith('_signals.pkl') dump_mock.reset_mock() # mock file exporting filename = Path(testdatadir / 'testresult') - store_backtest_signal_candles(filename, candle_dict, '2022_01_01_15_05_13') - assert dump_mock.call_count == 1 + store_backtest_analysis_results(filename, candle_dict, {}, '2022_01_01_15_05_13') + assert dump_mock.call_count == 2 assert isinstance(dump_mock.call_args_list[0][0][0], Path) # result will be testdatadir / testresult-_signals.pkl assert str(dump_mock.call_args_list[0][0][0]).endswith('_signals.pkl') @@ -254,10 +257,11 @@ def test_write_read_backtest_candles(tmpdir): candle_dict = {'DefStrat': {'UNITTEST/BTC': pd.DataFrame()}} # test directory exporting - stored_file = store_backtest_signal_candles(Path(tmpdir), candle_dict, '2022_01_01_15_05_13') - scp = stored_file.open("rb") - pickled_signal_candles = joblib.load(scp) - scp.close() + sample_date = '2022_01_01_15_05_13' + store_backtest_analysis_results(Path(tmpdir), candle_dict, {}, sample_date) + stored_file = Path(tmpdir / f'backtest-result-{sample_date}_signals.pkl') + with stored_file.open("rb") as scp: + pickled_signal_candles = joblib.load(scp) assert pickled_signal_candles.keys() == candle_dict.keys() assert pickled_signal_candles['DefStrat'].keys() == pickled_signal_candles['DefStrat'].keys() @@ -268,10 +272,10 @@ def test_write_read_backtest_candles(tmpdir): # test file exporting filename = Path(tmpdir / 'testresult') - stored_file = store_backtest_signal_candles(filename, candle_dict, '2022_01_01_15_05_13') - scp = stored_file.open("rb") - pickled_signal_candles = joblib.load(scp) - scp.close() + store_backtest_analysis_results(filename, candle_dict, {}, sample_date) + stored_file = Path(tmpdir / f'testresult-{sample_date}_signals.pkl') + with stored_file.open("rb") as scp: + pickled_signal_candles = joblib.load(scp) assert pickled_signal_candles.keys() == candle_dict.keys() assert pickled_signal_candles['DefStrat'].keys() == pickled_signal_candles['DefStrat'].keys() @@ -465,11 +469,14 @@ def test_generate_periodic_breakdown_stats(testdatadir): def test__get_resample_from_period(): assert _get_resample_from_period('day') == '1d' - assert _get_resample_from_period('week') == '1w' + assert _get_resample_from_period('week') == '1W-MON' assert _get_resample_from_period('month') == '1M' with pytest.raises(ValueError, match=r"Period noooo is not supported."): _get_resample_from_period('noooo') + for period in BACKTEST_BREAKDOWNS: + assert isinstance(_get_resample_from_period(period), str) + def test_show_sorted_pairlist(testdatadir, default_conf, capsys): filename = testdatadir / "backtest_results/backtest-result.json" diff --git a/tests/persistence/test_migrations.py b/tests/persistence/test_migrations.py index 854d39994..13b3f89bf 100644 --- a/tests/persistence/test_migrations.py +++ b/tests/persistence/test_migrations.py @@ -1,15 +1,18 @@ # pragma pylint: disable=missing-docstring, C0103 import logging +from importlib import import_module from pathlib import Path from unittest.mock import MagicMock import pytest from sqlalchemy import create_engine, select, text +from sqlalchemy.schema import CreateTable from freqtrade.constants import DEFAULT_DB_PROD_URL from freqtrade.enums import TradingMode from freqtrade.exceptions import OperationalException from freqtrade.persistence import Trade, init_db +from freqtrade.persistence.base import ModelBase from freqtrade.persistence.migrations import get_last_sequence_ids, set_sequence_ids from freqtrade.persistence.models import PairLock from tests.conftest import log_has @@ -411,3 +414,14 @@ def test_migrate_pairlocks(mocker, default_conf, fee, caplog): assert len(pairlocks) == 1 pairlocks[0].pair == 'ETH/BTC' pairlocks[0].side == '*' + + +@pytest.mark.parametrize('dialect', [ + 'sqlite', 'postgresql', 'mysql', 'oracle', 'mssql', + ]) +def test_create_table_compiles(dialect): + + dialect_mod = import_module(f"sqlalchemy.dialects.{dialect}") + for table in ModelBase.metadata.tables.values(): + create_sql = str(CreateTable(table).compile(dialect=dialect_mod.dialect())) + assert 'CREATE TABLE' in create_sql diff --git a/tests/persistence/test_persistence.py b/tests/persistence/test_persistence.py index 948973ed5..4aa3b1e96 100644 --- a/tests/persistence/test_persistence.py +++ b/tests/persistence/test_persistence.py @@ -2,7 +2,6 @@ from datetime import datetime, timedelta, timezone from types import FunctionType -import arrow import pytest from sqlalchemy import select @@ -10,6 +9,7 @@ from freqtrade.constants import CUSTOM_TAG_MAX_LENGTH, DATETIME_PRINT_FORMAT from freqtrade.enums import TradingMode from freqtrade.exceptions import DependencyException from freqtrade.persistence import LocalTrade, Order, Trade, init_db +from freqtrade.util import dt_now from tests.conftest import create_mock_trades, create_mock_trades_with_leverage, log_has, log_has_re @@ -27,7 +27,7 @@ def test_enter_exit_side(fee, is_short): open_rate=0.01, amount=5, is_open=True, - open_date=arrow.utcnow().datetime, + open_date=dt_now(), fee_open=fee.return_value, fee_close=fee.return_value, exchange='binance', @@ -49,7 +49,7 @@ def test_set_stop_loss_liquidation(fee): open_rate=2.0, amount=30.0, is_open=True, - open_date=arrow.utcnow().datetime, + open_date=dt_now(), fee_open=fee.return_value, fee_close=fee.return_value, exchange='binance', @@ -239,7 +239,7 @@ def test_interest(fee, exchange, is_short, lev, minutes, rate, interest, stake_amount=20.0, amount=30.0, open_rate=2.0, - open_date=datetime.utcnow() - timedelta(minutes=minutes), + open_date=datetime.now(timezone.utc) - timedelta(minutes=minutes), fee_open=fee.return_value, fee_close=fee.return_value, exchange=exchange, @@ -329,7 +329,7 @@ def test_borrowed(fee, is_short, lev, borrowed, trading_mode): open_rate=2.0, amount=30.0, is_open=True, - open_date=arrow.utcnow().datetime, + open_date=dt_now(), fee_open=fee.return_value, fee_close=fee.return_value, exchange='binance', @@ -428,7 +428,7 @@ def test_update_limit_order(fee, caplog, limit_buy_order_usdt, limit_sell_order_ open_rate=open_rate, amount=30.0, is_open=True, - open_date=arrow.utcnow().datetime, + open_date=dt_now(), fee_open=fee.return_value, fee_close=fee.return_value, exchange='binance', @@ -485,7 +485,7 @@ def test_update_market_order(market_buy_order_usdt, market_sell_order_usdt, fee, is_open=True, fee_open=fee.return_value, fee_close=fee.return_value, - open_date=arrow.utcnow().datetime, + open_date=dt_now(), exchange='binance', trading_mode=margin, leverage=1.0, @@ -635,7 +635,7 @@ def test_trade_close(fee): assert pytest.approx(trade.close_profit) == 0.094513715 assert trade.close_date is not None - new_date = arrow.Arrow(2020, 2, 2, 15, 6, 1).datetime, + new_date = datetime(2020, 2, 2, 15, 6, 1), assert trade.close_date != new_date # Close should NOT update close_date if the trade has been closed already assert trade.is_open is False @@ -1326,7 +1326,7 @@ def test_to_json(fee): amount_requested=123.0, fee_open=fee.return_value, fee_close=fee.return_value, - open_date=arrow.utcnow().shift(hours=-2).datetime, + open_date=dt_now() - timedelta(hours=2), open_rate=0.123, exchange='binance', enter_tag=None, @@ -1411,8 +1411,8 @@ def test_to_json(fee): amount_requested=101.0, fee_open=fee.return_value, fee_close=fee.return_value, - open_date=arrow.utcnow().shift(hours=-2).datetime, - close_date=arrow.utcnow().shift(hours=-1).datetime, + open_date=dt_now() - timedelta(hours=2), + close_date=dt_now() - timedelta(hours=1), open_rate=0.123, close_rate=0.125, enter_tag='buys_signal_001', @@ -1496,7 +1496,7 @@ def test_stoploss_reinitialization(default_conf, fee): pair='ADA/USDT', stake_amount=30.0, fee_open=fee.return_value, - open_date=arrow.utcnow().shift(hours=-2).datetime, + open_date=dt_now() - timedelta(hours=2), amount=30.0, fee_close=fee.return_value, exchange='binance', @@ -1557,7 +1557,7 @@ def test_stoploss_reinitialization_leverage(default_conf, fee): pair='ADA/USDT', stake_amount=30.0, fee_open=fee.return_value, - open_date=arrow.utcnow().shift(hours=-2).datetime, + open_date=dt_now() - timedelta(hours=2), amount=30.0, fee_close=fee.return_value, exchange='binance', @@ -1619,7 +1619,7 @@ def test_stoploss_reinitialization_short(default_conf, fee): pair='ADA/USDT', stake_amount=0.001, fee_open=fee.return_value, - open_date=arrow.utcnow().shift(hours=-2).datetime, + open_date=dt_now() - timedelta(hours=2), amount=10, fee_close=fee.return_value, exchange='binance', @@ -1678,7 +1678,7 @@ def test_update_fee(fee): pair='ADA/USDT', stake_amount=30.0, fee_open=fee.return_value, - open_date=arrow.utcnow().shift(hours=-2).datetime, + open_date=dt_now() - timedelta(hours=2), amount=30.0, fee_close=fee.return_value, exchange='binance', @@ -1717,7 +1717,7 @@ def test_fee_updated(fee): pair='ADA/USDT', stake_amount=30.0, fee_open=fee.return_value, - open_date=arrow.utcnow().shift(hours=-2).datetime, + open_date=dt_now() - timedelta(hours=2), amount=30.0, fee_close=fee.return_value, exchange='binance', @@ -2063,7 +2063,7 @@ def test_trade_truncates_string_fields(): stake_amount=20.0, amount=30.0, open_rate=2.0, - open_date=datetime.utcnow() - timedelta(minutes=20), + open_date=datetime.now(timezone.utc) - timedelta(minutes=20), fee_open=0.001, fee_close=0.001, exchange='binance', @@ -2092,7 +2092,7 @@ def test_recalc_trade_from_orders(fee): trade = Trade( pair='ADA/USDT', stake_amount=o1_cost, - open_date=arrow.utcnow().shift(hours=-2).datetime, + open_date=dt_now() - timedelta(hours=2), amount=o1_amount, fee_open=fee.return_value, fee_close=fee.return_value, @@ -2167,8 +2167,8 @@ def test_recalc_trade_from_orders(fee): filled=o2_amount, remaining=0, cost=o2_cost, - order_date=arrow.utcnow().shift(hours=-1).datetime, - order_filled_date=arrow.utcnow().shift(hours=-1).datetime, + order_date=dt_now() - timedelta(hours=1), + order_filled_date=dt_now() - timedelta(hours=1), ) trade.orders.append(order2) trade.recalc_trade_from_orders() @@ -2201,8 +2201,8 @@ def test_recalc_trade_from_orders(fee): filled=o3_amount, remaining=0, cost=o3_cost, - order_date=arrow.utcnow().shift(hours=-1).datetime, - order_filled_date=arrow.utcnow().shift(hours=-1).datetime, + order_date=dt_now() - timedelta(hours=1), + order_filled_date=dt_now() - timedelta(hours=1), ) trade.orders.append(order3) trade.recalc_trade_from_orders() @@ -2257,7 +2257,7 @@ def test_recalc_trade_from_orders_ignores_bad_orders(fee, is_short): trade = Trade( pair='ADA/USDT', stake_amount=o1_cost, - open_date=arrow.utcnow().shift(hours=-2).datetime, + open_date=dt_now() - timedelta(hours=2), amount=o1_amount, fee_open=fee.return_value, fee_close=fee.return_value, @@ -2309,8 +2309,8 @@ def test_recalc_trade_from_orders_ignores_bad_orders(fee, is_short): filled=o1_amount, remaining=0, cost=o1_cost, - order_date=arrow.utcnow().shift(hours=-1).datetime, - order_filled_date=arrow.utcnow().shift(hours=-1).datetime, + order_date=dt_now() - timedelta(hours=1), + order_filled_date=dt_now() - timedelta(hours=1), ) trade.orders.append(order2) trade.recalc_trade_from_orders() @@ -2337,8 +2337,8 @@ def test_recalc_trade_from_orders_ignores_bad_orders(fee, is_short): filled=0, remaining=4, cost=5, - order_date=arrow.utcnow().shift(hours=-1).datetime, - order_filled_date=arrow.utcnow().shift(hours=-1).datetime, + order_date=dt_now() - timedelta(hours=1), + order_filled_date=dt_now() - timedelta(hours=1), ) trade.orders.append(order3) trade.recalc_trade_from_orders() @@ -2364,8 +2364,8 @@ def test_recalc_trade_from_orders_ignores_bad_orders(fee, is_short): filled=o1_amount, remaining=0, cost=o1_cost, - order_date=arrow.utcnow().shift(hours=-1).datetime, - order_filled_date=arrow.utcnow().shift(hours=-1).datetime, + order_date=dt_now() - timedelta(hours=1), + order_filled_date=dt_now() - timedelta(hours=1), ) trade.orders.append(order4) trade.recalc_trade_from_orders() @@ -2481,7 +2481,7 @@ def test_select_filled_orders(fee): @pytest.mark.usefixtures("init_persistence") -def test_order_to_ccxt(limit_buy_order_open): +def test_order_to_ccxt(limit_buy_order_open, limit_sell_order_usdt_open): order = Order.parse_from_ccxt_object(limit_buy_order_open, 'mocked', 'buy') order.ft_trade_id = 1 @@ -2495,11 +2495,23 @@ def test_order_to_ccxt(limit_buy_order_open): del raw_order['fee'] del raw_order['datetime'] del raw_order['info'] - assert raw_order['stopPrice'] is None - del raw_order['stopPrice'] + assert raw_order.get('stopPrice') is None + raw_order.pop('stopPrice', None) del limit_buy_order_open['datetime'] assert raw_order == limit_buy_order_open + order1 = Order.parse_from_ccxt_object(limit_sell_order_usdt_open, 'mocked', 'sell') + order1.ft_order_side = 'stoploss' + order1.stop_price = order1.price * 0.9 + order1.ft_trade_id = 1 + order1.session.add(order1) + Order.session.commit() + + order_resp1 = Order.order_by_id(limit_sell_order_usdt_open['id']) + raw_order1 = order_resp1.to_ccxt_object() + + assert raw_order1.get('stopPrice') is not None + @pytest.mark.usefixtures("init_persistence") @pytest.mark.parametrize('data', [ @@ -2580,7 +2592,7 @@ def test_recalc_trade_from_orders_dca(data) -> None: open_rate=data['orders'][0][0][2], amount=data['orders'][0][0][1], is_open=True, - open_date=arrow.utcnow().datetime, + open_date=dt_now(), fee_open=data['fee'], fee_close=data['fee'], exchange='binance', @@ -2610,8 +2622,8 @@ def test_recalc_trade_from_orders_dca(data) -> None: filled=amount, remaining=0, cost=amount * price, - order_date=arrow.utcnow().shift(hours=-10 + idx).datetime, - order_filled_date=arrow.utcnow().shift(hours=-10 + idx).datetime, + order_date=dt_now() - timedelta(hours=10 + idx), + order_filled_date=dt_now() - timedelta(hours=10 + idx), ) trade.orders.append(order_obj) trade.recalc_trade_from_orders() diff --git a/tests/persistence/test_trade_fromjson.py b/tests/persistence/test_trade_fromjson.py index 22053463d..24522e744 100644 --- a/tests/persistence/test_trade_fromjson.py +++ b/tests/persistence/test_trade_fromjson.py @@ -1,8 +1,11 @@ from datetime import datetime, timezone +import pytest + from freqtrade.persistence.trade_model import Trade +@pytest.mark.usefixtures("init_persistence") def test_trade_fromjson(): """Test the Trade.from_json() method.""" trade_string = """{ @@ -168,14 +171,17 @@ def test_trade_fromjson(): ] }""" trade = Trade.from_json(trade_string) + Trade.session.add(trade) + Trade.commit() assert trade.id == 25 assert trade.pair == 'ETH/USDT' - assert trade.open_date == datetime(2022, 10, 18, 9, 12, 42, tzinfo=timezone.utc) + assert trade.open_date_utc == datetime(2022, 10, 18, 9, 12, 42, tzinfo=timezone.utc) assert isinstance(trade.open_date, datetime) assert trade.exit_reason == 'no longer good' + assert trade.realized_profit == 2.76315361 assert len(trade.orders) == 5 last_o = trade.orders[-1] - assert last_o.order_filled_date == datetime(2022, 10, 18, 9, 45, 22, tzinfo=timezone.utc) + assert last_o.order_filled_utc == datetime(2022, 10, 18, 9, 45, 22, tzinfo=timezone.utc) assert isinstance(last_o.order_date, datetime) diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index bc8fe84f1..cbbf1e6da 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -1200,6 +1200,10 @@ def test_spreadfilter_invalid_data(mocker, default_conf, markets, tickers, caplo "[{'ProducerPairList': 'ProducerPairList - default'}]", None ), + ({"method": "RemotePairList", "number_assets": 10, "pairlist_url": "https://example.com"}, + "[{'RemotePairList': 'RemotePairList - 10 pairs from RemotePairlist.'}]", + None + ), ]) def test_pricefilter_desc(mocker, whitelist_conf, markets, pairlistconfig, desc_expected, exception_expected): diff --git a/tests/plugins/test_pairlocks.py b/tests/plugins/test_pairlocks.py index 6b7112f98..6e209df60 100644 --- a/tests/plugins/test_pairlocks.py +++ b/tests/plugins/test_pairlocks.py @@ -1,10 +1,10 @@ from datetime import datetime, timedelta, timezone -import arrow import pytest from freqtrade.persistence import PairLocks from freqtrade.persistence.models import PairLock +from freqtrade.util import dt_now @pytest.mark.parametrize('use_db', (False, True)) @@ -20,20 +20,20 @@ def test_PairLocks(use_db): pair = 'ETH/BTC' assert not PairLocks.is_pair_locked(pair) - PairLocks.lock_pair(pair, arrow.utcnow().shift(minutes=4).datetime) + PairLocks.lock_pair(pair, dt_now() + timedelta(minutes=4)) # ETH/BTC locked for 4 minutes (on both sides) assert PairLocks.is_pair_locked(pair) assert PairLocks.is_pair_locked(pair, side='long') assert PairLocks.is_pair_locked(pair, side='short') pair = 'BNB/BTC' - PairLocks.lock_pair(pair, arrow.utcnow().shift(minutes=4).datetime, side='long') + PairLocks.lock_pair(pair, dt_now() + timedelta(minutes=4), side='long') assert not PairLocks.is_pair_locked(pair) assert PairLocks.is_pair_locked(pair, side='long') assert not PairLocks.is_pair_locked(pair, side='short') pair = 'BNB/USDT' - PairLocks.lock_pair(pair, arrow.utcnow().shift(minutes=4).datetime, side='short') + PairLocks.lock_pair(pair, dt_now() + timedelta(minutes=4), side='short') assert not PairLocks.is_pair_locked(pair) assert not PairLocks.is_pair_locked(pair, side='long') assert PairLocks.is_pair_locked(pair, side='short') @@ -44,7 +44,7 @@ def test_PairLocks(use_db): # Unlocking a pair that's not locked should not raise an error PairLocks.unlock_pair(pair) - PairLocks.lock_pair(pair, arrow.utcnow().shift(minutes=4).datetime) + PairLocks.lock_pair(pair, dt_now() + timedelta(minutes=4)) assert PairLocks.is_pair_locked(pair) # Get both locks from above @@ -113,20 +113,20 @@ def test_PairLocks_getlongestlock(use_db): pair = 'ETH/BTC' assert not PairLocks.is_pair_locked(pair) - PairLocks.lock_pair(pair, arrow.utcnow().shift(minutes=4).datetime) + PairLocks.lock_pair(pair, dt_now() + timedelta(minutes=4)) # ETH/BTC locked for 4 minutes assert PairLocks.is_pair_locked(pair) lock = PairLocks.get_pair_longest_lock(pair) - assert lock.lock_end_time.replace(tzinfo=timezone.utc) > arrow.utcnow().shift(minutes=3) - assert lock.lock_end_time.replace(tzinfo=timezone.utc) < arrow.utcnow().shift(minutes=14) + assert lock.lock_end_time.replace(tzinfo=timezone.utc) > dt_now() + timedelta(minutes=3) + assert lock.lock_end_time.replace(tzinfo=timezone.utc) < dt_now() + timedelta(minutes=14) - PairLocks.lock_pair(pair, arrow.utcnow().shift(minutes=15).datetime) + PairLocks.lock_pair(pair, dt_now() + timedelta(minutes=15)) assert PairLocks.is_pair_locked(pair) lock = PairLocks.get_pair_longest_lock(pair) # Must be longer than above - assert lock.lock_end_time.replace(tzinfo=timezone.utc) > arrow.utcnow().shift(minutes=14) + assert lock.lock_end_time.replace(tzinfo=timezone.utc) > dt_now() + timedelta(minutes=14) PairLocks.reset_locks() PairLocks.use_db = True @@ -143,8 +143,8 @@ def test_PairLocks_reason(use_db): assert PairLocks.use_db == use_db - PairLocks.lock_pair('XRP/USDT', arrow.utcnow().shift(minutes=4).datetime, 'TestLock1') - PairLocks.lock_pair('ETH/USDT', arrow.utcnow().shift(minutes=4).datetime, 'TestLock2') + PairLocks.lock_pair('XRP/USDT', dt_now() + timedelta(minutes=4), 'TestLock1') + PairLocks.lock_pair('ETH/USDT', dt_now() + timedelta(minutes=4), 'TestLock2') assert PairLocks.is_pair_locked('XRP/USDT') assert PairLocks.is_pair_locked('ETH/USDT') diff --git a/tests/plugins/test_protections.py b/tests/plugins/test_protections.py index 5e6128c73..8fe8cec6b 100644 --- a/tests/plugins/test_protections.py +++ b/tests/plugins/test_protections.py @@ -1,5 +1,5 @@ import random -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone import pytest @@ -24,8 +24,8 @@ def generate_mock_trade(pair: str, fee: float, is_open: bool, stake_amount=0.01, fee_open=fee, fee_close=fee, - open_date=datetime.utcnow() - timedelta(minutes=min_ago_open or 200), - close_date=datetime.utcnow() - timedelta(minutes=min_ago_close or 30), + open_date=datetime.now(timezone.utc) - timedelta(minutes=min_ago_open or 200), + close_date=datetime.now(timezone.utc) - timedelta(minutes=min_ago_close or 30), open_rate=open_rate, is_open=is_open, amount=0.01 / open_rate, @@ -87,9 +87,9 @@ def test_protectionmanager(mocker, default_conf): for handler in freqtrade.protections._protection_handlers: assert handler.name in constants.AVAILABLE_PROTECTIONS if not handler.has_global_stop: - assert handler.global_stop(datetime.utcnow(), '*') is None + assert handler.global_stop(datetime.now(timezone.utc), '*') is None if not handler.has_local_stop: - assert handler.stop_per_pair('XRP/BTC', datetime.utcnow(), '*') is None + assert handler.stop_per_pair('XRP/BTC', datetime.now(timezone.utc), '*') is None @pytest.mark.parametrize('timeframe,expected,protconf', [ diff --git a/tests/plugins/test_remotepairlist.py b/tests/plugins/test_remotepairlist.py index ac1d1f5ed..5e6f5cbf1 100644 --- a/tests/plugins/test_remotepairlist.py +++ b/tests/plugins/test_remotepairlist.py @@ -1,5 +1,5 @@ import json -from unittest.mock import MagicMock +from unittest.mock import MagicMock, PropertyMock import pytest import requests @@ -7,7 +7,7 @@ import requests from freqtrade.exceptions import OperationalException from freqtrade.plugins.pairlist.RemotePairList import RemotePairList from freqtrade.plugins.pairlistmanager import PairListManager -from tests.conftest import get_patched_exchange, get_patched_freqtradebot, log_has +from tests.conftest import EXMS, get_patched_exchange, get_patched_freqtradebot, log_has @pytest.fixture(scope="function") @@ -16,11 +16,12 @@ def rpl_config(default_conf): default_conf['exchange']['pair_whitelist'] = [ 'ETH/USDT', - 'BTC/USDT', + 'XRP/USDT', ] default_conf['exchange']['pair_blacklist'] = [ 'BLK/USDT' ] + return default_conf @@ -183,3 +184,159 @@ def test_fetch_pairlist_mock_response_valid(mocker, rpl_config): assert pairs == ["ETH/USDT", "XRP/USDT", "LTC/USDT", "EOS/USDT"] assert time_elapsed == 0.4 assert remote_pairlist._refresh_period == 60 + + +def test_remote_pairlist_init_wrong_mode(mocker, rpl_config): + rpl_config['pairlists'] = [ + { + "method": "RemotePairList", + "mode": "blacklis", + "number_assets": 20, + "pairlist_url": "http://example.com/pairlist", + "keep_pairlist_on_failure": True, + } + ] + + with pytest.raises( + OperationalException, + match=r'`mode` not configured correctly. Supported Modes are "whitelist","blacklist"' + ): + get_patched_freqtradebot(mocker, rpl_config) + + rpl_config['pairlists'] = [ + { + "method": "RemotePairList", + "mode": "blacklist", + "number_assets": 20, + "pairlist_url": "http://example.com/pairlist", + "keep_pairlist_on_failure": True, + } + ] + + with pytest.raises( + OperationalException, + match=r'A `blacklist` mode RemotePairList can not be.*first.*' + ): + get_patched_freqtradebot(mocker, rpl_config) + + +def test_remote_pairlist_init_wrong_proc_mode(mocker, rpl_config): + rpl_config['pairlists'] = [ + { + "method": "RemotePairList", + "processing_mode": "filler", + "mode": "whitelist", + "number_assets": 20, + "pairlist_url": "http://example.com/pairlist", + "keep_pairlist_on_failure": True, + } + ] + + get_patched_exchange(mocker, rpl_config) + with pytest.raises( + OperationalException, + match=r'`processing_mode` not configured correctly. Supported Modes are "filter","append"' + ): + get_patched_freqtradebot(mocker, rpl_config) + + +def test_remote_pairlist_blacklist(mocker, rpl_config, caplog, markets, tickers): + + mock_response = MagicMock() + + mock_response.json.return_value = { + "pairs": ["XRP/USDT"], + "refresh_period": 60 + } + + mock_response.headers = { + "content-type": "application/json" + } + + rpl_config['pairlists'] = [ + { + "method": "StaticPairList", + }, + { + "method": "RemotePairList", + "mode": "blacklist", + "pairlist_url": "http://example.com/pairlist", + "number_assets": 3 + } + ] + + mocker.patch.multiple(EXMS, + markets=PropertyMock(return_value=markets), + exchange_has=MagicMock(return_value=True), + get_tickers=tickers + ) + + mocker.patch("freqtrade.plugins.pairlist.RemotePairList.requests.get", + return_value=mock_response) + + exchange = get_patched_exchange(mocker, rpl_config) + + pairlistmanager = PairListManager(exchange, rpl_config) + + remote_pairlist = RemotePairList(exchange, pairlistmanager, rpl_config, + rpl_config["pairlists"][1], 1) + + pairs, time_elapsed = remote_pairlist.fetch_pairlist() + + assert pairs == ["XRP/USDT"] + + whitelist = remote_pairlist.filter_pairlist(rpl_config['exchange']['pair_whitelist'], {}) + assert whitelist == ["ETH/USDT"] + + assert log_has(f"Blacklist - Filtered out pairs: {pairs}", caplog) + + +@pytest.mark.parametrize("processing_mode", ["filter", "append"]) +def test_remote_pairlist_whitelist(mocker, rpl_config, processing_mode, markets, tickers): + + mock_response = MagicMock() + + mock_response.json.return_value = { + "pairs": ["XRP/USDT"], + "refresh_period": 60 + } + + mock_response.headers = { + "content-type": "application/json" + } + + rpl_config['pairlists'] = [ + { + "method": "StaticPairList", + }, + { + "method": "RemotePairList", + "mode": "whitelist", + "processing_mode": processing_mode, + "pairlist_url": "http://example.com/pairlist", + "number_assets": 3 + } + ] + + mocker.patch.multiple(EXMS, + markets=PropertyMock(return_value=markets), + exchange_has=MagicMock(return_value=True), + get_tickers=tickers + ) + + mocker.patch("freqtrade.plugins.pairlist.RemotePairList.requests.get", + return_value=mock_response) + + exchange = get_patched_exchange(mocker, rpl_config) + + pairlistmanager = PairListManager(exchange, rpl_config) + + remote_pairlist = RemotePairList(exchange, pairlistmanager, rpl_config, + rpl_config["pairlists"][1], 1) + + pairs, time_elapsed = remote_pairlist.fetch_pairlist() + + assert pairs == ["XRP/USDT"] + + whitelist = remote_pairlist.filter_pairlist(rpl_config['exchange']['pair_whitelist'], {}) + assert whitelist == (["XRP/USDT"] if processing_mode == "filter" else ['ETH/USDT', 'XRP/USDT']) diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index 9dc151ee8..1c59748ed 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -97,7 +97,7 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: 'order_date': ANY, 'order_timestamp': ANY, 'order_filled_date': ANY, 'order_filled_timestamp': ANY, 'order_type': 'limit', 'price': 1.098e-05, 'is_open': False, 'pair': 'ETH/BTC', 'order_id': ANY, - 'remaining': ANY, 'status': ANY, 'ft_is_entry': True, + 'remaining': ANY, 'status': ANY, 'ft_is_entry': True, 'ft_fee_base': None, }], } mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) @@ -261,8 +261,7 @@ def test_rpc_status_table(default_conf, ticker, fee, mocker) -> None: assert isnan(fiat_profit_sum) -def test__rpc_timeunit_profit(default_conf_usdt, ticker, fee, - limit_buy_order, limit_sell_order, markets, mocker) -> None: +def test__rpc_timeunit_profit(default_conf_usdt, ticker, fee, markets, mocker) -> None: mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) mocker.patch.multiple( EXMS, @@ -295,7 +294,7 @@ def test__rpc_timeunit_profit(default_conf_usdt, ticker, fee, assert day['starting_balance'] in (pytest.approx(1062.37), pytest.approx(1066.46)) assert day['fiat_value'] in (0.0, ) # ensure first day is current date - assert str(days['data'][0]['date']) == str(datetime.utcnow().date()) + assert str(days['data'][0]['date']) == str(datetime.now(timezone.utc).date()) # Try invalid data with pytest.raises(RPCException, match=r'.*must be an integer greater than 0*'): @@ -418,8 +417,8 @@ def test_rpc_trade_statistics(default_conf_usdt, ticker, fee, mocker) -> None: assert pytest.approx(stats['profit_all_fiat']) == -85.205614098 assert pytest.approx(stats['winrate']) == 66.666666667 assert stats['trade_count'] == 7 - assert stats['first_trade_date'] == '2 days ago' - assert stats['latest_trade_date'] == '17 minutes ago' + assert stats['first_trade_humanized'] == '2 days ago' + assert stats['latest_trade_humanized'] == '17 minutes ago' assert stats['avg_duration'] in ('0:17:40') assert stats['best_pair'] == 'XRP/USDT' assert stats['best_rate'] == 10.0 @@ -431,8 +430,8 @@ def test_rpc_trade_statistics(default_conf_usdt, ticker, fee, mocker) -> None: MagicMock(side_effect=ExchangeError("Pair 'XRP/USDT' not available"))) stats = rpc._rpc_trade_statistics(stake_currency, fiat_display_currency) assert stats['trade_count'] == 7 - assert stats['first_trade_date'] == '2 days ago' - assert stats['latest_trade_date'] == '17 minutes ago' + assert stats['first_trade_humanized'] == '2 days ago' + assert stats['latest_trade_humanized'] == '17 minutes ago' assert stats['avg_duration'] in ('0:17:40') assert stats['best_pair'] == 'XRP/USDT' assert stats['best_rate'] == 10.0 @@ -551,51 +550,67 @@ def test_rpc_balance_handle(default_conf, mocker, tickers): 'free': 10.0, 'balance': 12.0, 'used': 2.0, + 'bot_owned': 9.9, # available stake - reducing by reserved amount 'est_stake': 10.0, # In futures mode, "free" is used here. + 'est_stake_bot': 9.9, 'stake': 'BTC', 'is_position': False, 'leverage': 1.0, 'position': 0.0, 'side': 'long', + 'is_bot_managed': True, }, { 'free': 1.0, 'balance': 5.0, 'currency': 'ETH', + 'bot_owned': 0, 'est_stake': 0.30794, + 'est_stake_bot': 0, 'used': 4.0, 'stake': 'BTC', 'is_position': False, 'leverage': 1.0, 'position': 0.0, 'side': 'long', - + 'is_bot_managed': False, }, { 'free': 5.0, 'balance': 10.0, 'currency': 'USDT', + 'bot_owned': 0, 'est_stake': 0.0011562404610161968, + 'est_stake_bot': 0, 'used': 5.0, 'stake': 'BTC', 'is_position': False, 'leverage': 1.0, 'position': 0.0, 'side': 'long', + 'is_bot_managed': False, }, { 'free': 0.0, 'balance': 0.0, 'currency': 'ETH/USDT:USDT', 'est_stake': 20, + 'est_stake_bot': 20, 'used': 0, 'stake': 'BTC', 'is_position': True, 'leverage': 5.0, 'position': 1000.0, 'side': 'short', + 'is_bot_managed': True, } ] + assert pytest.approx(result['total_bot']) == 29.9 + assert pytest.approx(result['total']) == 30.309096 + assert result['starting_capital'] == 10 + # Very high starting capital ratio, because the futures position really has the wrong unit. + # TODO: improve this test (see comment above) + assert result['starting_capital_ratio'] == pytest.approx(1.98999999) def test_rpc_start(mocker, default_conf) -> None: @@ -693,15 +708,15 @@ def test_rpc_force_exit(default_conf, ticker, fee, mocker) -> None: rpc._rpc_force_exit(None) msg = rpc._rpc_force_exit('all') - assert msg == {'result': 'Created sell orders for all open trades.'} + assert msg == {'result': 'Created exit orders for all open trades.'} freqtradebot.enter_positions() msg = rpc._rpc_force_exit('all') - assert msg == {'result': 'Created sell orders for all open trades.'} + assert msg == {'result': 'Created exit orders for all open trades.'} freqtradebot.enter_positions() msg = rpc._rpc_force_exit('2') - assert msg == {'result': 'Created sell order for trade 2.'} + assert msg == {'result': 'Created exit order for trade 2.'} freqtradebot.state = State.STOPPED with pytest.raises(RPCException, match=r'.*trader is not running*'): @@ -751,27 +766,11 @@ def test_rpc_force_exit(default_conf, ticker, fee, mocker) -> None: freqtradebot.config['max_open_trades'] = 3 freqtradebot.enter_positions() - trade = Trade.session.scalars(select(Trade).filter(Trade.id == '2')).first() - amount = trade.amount - # make an limit-buy open trade, if there is no 'filled', don't sell it - mocker.patch( - f'{EXMS}.fetch_order', - return_value={ - 'status': 'open', - 'type': 'limit', - 'side': 'buy', - 'filled': None - } - ) - # check that the trade is called, which is done by ensuring exchange.cancel_order is called - msg = rpc._rpc_force_exit('4') - assert msg == {'result': 'Created sell order for trade 4.'} - assert cancel_order_mock.call_count == 2 - assert trade.amount == amount + cancel_order_mock.reset_mock() trade = Trade.session.scalars(select(Trade).filter(Trade.id == '3')).first() - - # make an limit-sell open trade + amount = trade.amount + # make an limit-sell open order trade mocker.patch( f'{EXMS}.fetch_order', return_value={ @@ -784,10 +783,54 @@ def test_rpc_force_exit(default_conf, ticker, fee, mocker) -> None: 'id': trade.orders[0].order_id, } ) + cancel_order_3 = mocker.patch( + f'{EXMS}.cancel_order_with_result', + return_value={ + 'status': 'canceled', + 'type': 'limit', + 'side': 'sell', + 'amount': amount, + 'remaining': amount, + 'filled': 0.0, + 'id': trade.orders[0].order_id, + } + ) msg = rpc._rpc_force_exit('3') - assert msg == {'result': 'Created sell order for trade 3.'} + assert msg == {'result': 'Created exit order for trade 3.'} # status quo, no exchange calls - assert cancel_order_mock.call_count == 3 + assert cancel_order_3.call_count == 1 + assert cancel_order_mock.call_count == 0 + + trade = Trade.session.scalars(select(Trade).filter(Trade.id == '2')).first() + amount = trade.amount + # make an limit-buy open trade, if there is no 'filled', don't sell it + mocker.patch( + f'{EXMS}.fetch_order', + return_value={ + 'status': 'open', + 'type': 'limit', + 'side': 'buy', + 'filled': None + } + ) + cancel_order_4 = mocker.patch( + f'{EXMS}.cancel_order_with_result', + return_value={ + 'status': 'canceled', + 'type': 'limit', + 'side': 'sell', + 'amount': amount, + 'remaining': 0.0, + 'filled': amount, + 'id': trade.orders[0].order_id, + } + ) + # check that the trade is called, which is done by ensuring exchange.cancel_order is called + msg = rpc._rpc_force_exit('4') + assert msg == {'result': 'Created exit order for trade 4.'} + assert cancel_order_4.call_count == 1 + assert cancel_order_mock.call_count == 0 + assert trade.amount == amount def test_performance_handle(default_conf_usdt, ticker, fee, mocker) -> None: diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 58c904838..f793b1f9c 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -21,11 +21,13 @@ from freqtrade.__init__ import __version__ from freqtrade.enums import CandleType, RunMode, State, TradingMode from freqtrade.exceptions import DependencyException, ExchangeError, OperationalException from freqtrade.loggers import setup_logging, setup_logging_pre +from freqtrade.optimize.backtesting import Backtesting from freqtrade.persistence import PairLocks, Trade from freqtrade.rpc import RPC from freqtrade.rpc.api_server import ApiServer from freqtrade.rpc.api_server.api_auth import create_token, get_user_from_token from freqtrade.rpc.api_server.uvicorn_threaded import UvicornServer +from freqtrade.rpc.api_server.webserver_bgwork import ApiBG from tests.conftest import (CURRENT_TEST_STRATEGY, EXMS, create_mock_trades, get_mock_coro, get_patched_freqtradebot, log_has, log_has_re, patch_get_signal) @@ -283,7 +285,7 @@ def test_api__init__(default_conf, mocker): "username": "TestUser", "password": "testPass", }}) - mocker.patch('freqtrade.rpc.telegram.Updater', MagicMock()) + mocker.patch('freqtrade.rpc.telegram.Telegram._init') mocker.patch('freqtrade.rpc.api_server.webserver.ApiServer.start_api', MagicMock()) apiserver = ApiServer(default_conf) apiserver.add_rpc_handler(RPC(get_patched_freqtradebot(mocker, default_conf))) @@ -341,7 +343,7 @@ def test_api_run(default_conf, mocker, caplog): "username": "TestUser", "password": "testPass", }}) - mocker.patch('freqtrade.rpc.telegram.Updater', MagicMock()) + mocker.patch('freqtrade.rpc.telegram.Telegram._init') server_inst_mock = MagicMock() server_inst_mock.run_in_thread = MagicMock() @@ -419,7 +421,7 @@ def test_api_cleanup(default_conf, mocker, caplog): "username": "TestUser", "password": "testPass", }}) - mocker.patch('freqtrade.rpc.telegram.Updater', MagicMock()) + mocker.patch('freqtrade.rpc.telegram.Telegram._init') server_mock = MagicMock() server_mock.cleanup = MagicMock() @@ -480,13 +482,18 @@ def test_api_balance(botclient, mocker, rpc_balance, tickers): 'free': 12.0, 'balance': 12.0, 'used': 0.0, + 'bot_owned': pytest.approx(11.879999), 'est_stake': 12.0, + 'est_stake_bot': pytest.approx(11.879999), 'stake': 'BTC', 'is_position': False, 'leverage': 1.0, 'position': 0.0, 'side': 'long', + 'is_bot_managed': True, } + assert response['total'] == 12.159513094 + assert response['total_bot'] == pytest.approx(11.879999) assert 'starting_capital' in response assert 'starting_capital_fiat' in response assert 'starting_capital_pct' in response @@ -596,7 +603,7 @@ def test_api_daily(botclient, mocker, ticker, fee, markets): assert len(rc.json()['data']) == 7 assert rc.json()['stake_currency'] == 'BTC' assert rc.json()['fiat_display_currency'] == 'USD' - assert rc.json()['data'][0]['date'] == str(datetime.utcnow().date()) + assert rc.json()['data'][0]['date'] == str(datetime.now(timezone.utc).date()) @pytest.mark.parametrize('is_short', [True, False]) @@ -735,6 +742,33 @@ def test_api_delete_open_order(botclient, mocker, fee, markets, ticker, is_short assert cancel_mock.call_count == 1 +@pytest.mark.parametrize('is_short', [True, False]) +def test_api_trade_reload_trade(botclient, mocker, fee, markets, ticker, is_short): + ftbot, client = botclient + patch_get_signal(ftbot, enter_long=not is_short, enter_short=is_short) + stoploss_mock = MagicMock() + cancel_mock = MagicMock() + ftbot.handle_onexchange_order = MagicMock() + mocker.patch.multiple( + EXMS, + markets=PropertyMock(return_value=markets), + fetch_ticker=ticker, + cancel_order=cancel_mock, + cancel_stoploss_order=stoploss_mock, + ) + + rc = client_post(client, f"{BASE_URI}/trades/10/reload") + assert_response(rc, 502) + assert 'Could not find trade with id 10.' in rc.json()['error'] + assert ftbot.handle_onexchange_order.call_count == 0 + + create_mock_trades(fee, is_short=is_short) + Trade.commit() + + rc = client_post(client, f"{BASE_URI}/trades/5/reload") + assert ftbot.handle_onexchange_order.call_count == 1 + + def test_api_logs(botclient): ftbot, client = botclient rc = client_get(client, f"{BASE_URI}/logs") @@ -856,8 +890,10 @@ def test_api_profit(botclient, mocker, ticker, fee, markets, is_short, expected) 'best_pair_profit_ratio': expected['best_pair_profit_ratio'], 'best_rate': expected['best_rate'], 'first_trade_date': ANY, + 'first_trade_humanized': ANY, 'first_trade_timestamp': ANY, - 'latest_trade_date': '5 minutes ago', + 'latest_trade_date': ANY, + 'latest_trade_humanized': '5 minutes ago', 'latest_trade_timestamp': ANY, 'profit_all_coin': pytest.approx(expected['profit_all_coin']), 'profit_all_fiat': pytest.approx(expected['profit_all_fiat']), @@ -1192,7 +1228,7 @@ def test_api_force_entry(botclient, mocker, fee, endpoint): stake_amount=1, open_rate=0.245441, open_order_id="123456", - open_date=datetime.utcnow(), + open_date=datetime.now(timezone.utc), is_open=False, is_short=False, fee_close=fee.return_value, @@ -1297,7 +1333,7 @@ def test_api_forceexit(botclient, mocker, ticker, fee, markets): rc = client_post(client, f"{BASE_URI}/forceexit", data={"tradeid": "5", "ordertype": "market", "amount": 23}) assert_response(rc) - assert rc.json() == {'result': 'Created sell order for trade 5.'} + assert rc.json() == {'result': 'Created exit order for trade 5.'} Trade.rollback() trade = Trade.get_trades([Trade.id == 5]).first() @@ -1307,7 +1343,7 @@ def test_api_forceexit(botclient, mocker, ticker, fee, markets): rc = client_post(client, f"{BASE_URI}/forceexit", data={"tradeid": "5"}) assert_response(rc) - assert rc.json() == {'result': 'Created sell order for trade 5.'} + assert rc.json() == {'result': 'Created exit order for trade 5.'} Trade.rollback() trade = Trade.get_trades([Trade.id == 5]).first() @@ -1542,6 +1578,47 @@ def test_api_strategy(botclient): assert_response(rc, 500) +def test_api_exchanges(botclient): + ftbot, client = botclient + + rc = client_get(client, f"{BASE_URI}/exchanges") + assert_response(rc) + response = rc.json() + assert isinstance(response['exchanges'], list) + assert len(response['exchanges']) > 20 + okx = [x for x in response['exchanges'] if x['name'] == 'okx'][0] + assert okx == { + "name": "okx", + "valid": True, + "supported": True, + "comment": "", + "trade_modes": [ + { + "trading_mode": "spot", + "margin_mode": "" + }, + { + "trading_mode": "futures", + "margin_mode": "isolated" + } + ] + } + + mexc = [x for x in response['exchanges'] if x['name'] == 'mexc'][0] + assert mexc == { + "name": "mexc", + "valid": True, + "supported": False, + "comment": "", + "trade_modes": [ + { + "trading_mode": "spot", + "margin_mode": "" + } + ] + } + + def test_api_freqaimodels(botclient, tmpdir, mocker): ftbot, client = botclient ftbot.config['user_data_dir'] = Path(tmpdir) @@ -1580,6 +1657,122 @@ def test_api_freqaimodels(botclient, tmpdir, mocker): ]} +def test_api_pairlists_available(botclient, tmpdir): + ftbot, client = botclient + ftbot.config['user_data_dir'] = Path(tmpdir) + + rc = client_get(client, f"{BASE_URI}/pairlists/available") + + assert_response(rc, 503) + assert rc.json()['detail'] == 'Bot is not in the correct state.' + + ftbot.config['runmode'] = RunMode.WEBSERVER + + rc = client_get(client, f"{BASE_URI}/pairlists/available") + assert_response(rc) + response = rc.json() + assert isinstance(response['pairlists'], list) + assert len(response['pairlists']) > 0 + + assert len([r for r in response['pairlists'] if r['name'] == 'AgeFilter']) == 1 + assert len([r for r in response['pairlists'] if r['name'] == 'VolumePairList']) == 1 + assert len([r for r in response['pairlists'] if r['name'] == 'StaticPairList']) == 1 + + volumepl = [r for r in response['pairlists'] if r['name'] == 'VolumePairList'][0] + assert volumepl['is_pairlist_generator'] is True + assert len(volumepl['params']) > 1 + age_pl = [r for r in response['pairlists'] if r['name'] == 'AgeFilter'][0] + assert age_pl['is_pairlist_generator'] is False + assert len(volumepl['params']) > 2 + + +def test_api_pairlists_evaluate(botclient, tmpdir, mocker): + ftbot, client = botclient + ftbot.config['user_data_dir'] = Path(tmpdir) + + rc = client_get(client, f"{BASE_URI}/pairlists/evaluate/randomJob") + + assert_response(rc, 503) + assert rc.json()['detail'] == 'Bot is not in the correct state.' + + ftbot.config['runmode'] = RunMode.WEBSERVER + + rc = client_get(client, f"{BASE_URI}/pairlists/evaluate/randomJob") + assert_response(rc, 404) + assert rc.json()['detail'] == 'Job not found.' + + body = { + "pairlists": [ + {"method": "StaticPairList", }, + ], + "blacklist": [ + ], + "stake_currency": "BTC" + } + # Fail, already running + ApiBG.pairlist_running = True + rc = client_post(client, f"{BASE_URI}/pairlists/evaluate", body) + assert_response(rc, 400) + assert rc.json()['detail'] == 'Pairlist evaluation is already running.' + + # should start the run + ApiBG.pairlist_running = False + rc = client_post(client, f"{BASE_URI}/pairlists/evaluate", body) + assert_response(rc) + assert rc.json()['status'] == 'Pairlist evaluation started in background.' + job_id = rc.json()['job_id'] + + rc = client_get(client, f"{BASE_URI}/background/RandomJob") + assert_response(rc, 404) + assert rc.json()['detail'] == 'Job not found.' + + rc = client_get(client, f"{BASE_URI}/background/{job_id}") + assert_response(rc) + response = rc.json() + assert response['job_id'] == job_id + assert response['job_category'] == 'pairlist' + + rc = client_get(client, f"{BASE_URI}/pairlists/evaluate/{job_id}") + assert_response(rc) + response = rc.json() + assert response['result']['whitelist'] == ['ETH/BTC', 'LTC/BTC', 'XRP/BTC', 'NEO/BTC',] + assert response['result']['length'] == 4 + + # Restart with additional filter, reducing the list to 2 + body['pairlists'].append({"method": "OffsetFilter", "number_assets": 2}) + rc = client_post(client, f"{BASE_URI}/pairlists/evaluate", body) + assert_response(rc) + assert rc.json()['status'] == 'Pairlist evaluation started in background.' + job_id = rc.json()['job_id'] + + rc = client_get(client, f"{BASE_URI}/pairlists/evaluate/{job_id}") + assert_response(rc) + response = rc.json() + assert response['result']['whitelist'] == ['ETH/BTC', 'LTC/BTC', ] + assert response['result']['length'] == 2 + # Patch __run_pairlists + plm = mocker.patch('freqtrade.rpc.api_server.api_background_tasks.__run_pairlist', + return_value=None) + body = { + "pairlists": [ + {"method": "StaticPairList", }, + ], + "blacklist": [ + ], + "stake_currency": "BTC", + "exchange": "randomExchange", + "trading_mode": "futures", + "margin_mode": "isolated", + } + rc = client_post(client, f"{BASE_URI}/pairlists/evaluate", body) + assert_response(rc) + assert plm.call_count == 1 + call_config = plm.call_args_list[0][0][1] + assert call_config['exchange']['name'] == 'randomExchange' + assert call_config['trading_mode'] == 'futures' + assert call_config['margin_mode'] == 'isolated' + + def test_list_available_pairs(botclient): ftbot, client = botclient @@ -1631,137 +1824,141 @@ def test_sysinfo(botclient): def test_api_backtesting(botclient, mocker, fee, caplog, tmpdir): - ftbot, client = botclient - mocker.patch(f'{EXMS}.get_fee', fee) + try: + ftbot, client = botclient + mocker.patch(f'{EXMS}.get_fee', fee) - rc = client_get(client, f"{BASE_URI}/backtest") - # Backtest prevented in default mode - assert_response(rc, 502) + rc = client_get(client, f"{BASE_URI}/backtest") + # Backtest prevented in default mode + assert_response(rc, 503) + assert rc.json()['detail'] == 'Bot is not in the correct state.' - ftbot.config['runmode'] = RunMode.WEBSERVER - # Backtesting not started yet - rc = client_get(client, f"{BASE_URI}/backtest") - assert_response(rc) + ftbot.config['runmode'] = RunMode.WEBSERVER + # Backtesting not started yet + rc = client_get(client, f"{BASE_URI}/backtest") + assert_response(rc) - result = rc.json() - assert result['status'] == 'not_started' - assert not result['running'] - assert result['status_msg'] == 'Backtest not yet executed' - assert result['progress'] == 0 + result = rc.json() + assert result['status'] == 'not_started' + assert not result['running'] + assert result['status_msg'] == 'Backtest not yet executed' + assert result['progress'] == 0 - # Reset backtesting - rc = client_delete(client, f"{BASE_URI}/backtest") - assert_response(rc) - result = rc.json() - assert result['status'] == 'reset' - assert not result['running'] - assert result['status_msg'] == 'Backtest reset' - ftbot.config['export'] = 'trades' - ftbot.config['backtest_cache'] = 'day' - ftbot.config['user_data_dir'] = Path(tmpdir) - ftbot.config['exportfilename'] = Path(tmpdir) / "backtest_results" - ftbot.config['exportfilename'].mkdir() + # Reset backtesting + rc = client_delete(client, f"{BASE_URI}/backtest") + assert_response(rc) + result = rc.json() + assert result['status'] == 'reset' + assert not result['running'] + assert result['status_msg'] == 'Backtest reset' + ftbot.config['export'] = 'trades' + ftbot.config['backtest_cache'] = 'day' + ftbot.config['user_data_dir'] = Path(tmpdir) + ftbot.config['exportfilename'] = Path(tmpdir) / "backtest_results" + ftbot.config['exportfilename'].mkdir() - # start backtesting - data = { - "strategy": CURRENT_TEST_STRATEGY, - "timeframe": "5m", - "timerange": "20180110-20180111", - "max_open_trades": 3, - "stake_amount": 100, - "dry_run_wallet": 1000, - "enable_protections": False - } - rc = client_post(client, f"{BASE_URI}/backtest", data=data) - assert_response(rc) - result = rc.json() + # start backtesting + data = { + "strategy": CURRENT_TEST_STRATEGY, + "timeframe": "5m", + "timerange": "20180110-20180111", + "max_open_trades": 3, + "stake_amount": 100, + "dry_run_wallet": 1000, + "enable_protections": False + } + rc = client_post(client, f"{BASE_URI}/backtest", data=data) + assert_response(rc) + result = rc.json() - assert result['status'] == 'running' - assert result['progress'] == 0 - assert result['running'] - assert result['status_msg'] == 'Backtest started' + assert result['status'] == 'running' + assert result['progress'] == 0 + assert result['running'] + assert result['status_msg'] == 'Backtest started' - rc = client_get(client, f"{BASE_URI}/backtest") - assert_response(rc) + rc = client_get(client, f"{BASE_URI}/backtest") + assert_response(rc) - result = rc.json() - assert result['status'] == 'ended' - assert not result['running'] - assert result['status_msg'] == 'Backtest ended' - assert result['progress'] == 1 - assert result['backtest_result'] + result = rc.json() + assert result['status'] == 'ended' + assert not result['running'] + assert result['status_msg'] == 'Backtest ended' + assert result['progress'] == 1 + assert result['backtest_result'] - rc = client_get(client, f"{BASE_URI}/backtest/abort") - assert_response(rc) - result = rc.json() - assert result['status'] == 'not_running' - assert not result['running'] - assert result['status_msg'] == 'Backtest ended' + rc = client_get(client, f"{BASE_URI}/backtest/abort") + assert_response(rc) + result = rc.json() + assert result['status'] == 'not_running' + assert not result['running'] + assert result['status_msg'] == 'Backtest ended' - # Simulate running backtest - ApiServer._bgtask_running = True - rc = client_get(client, f"{BASE_URI}/backtest/abort") - assert_response(rc) - result = rc.json() - assert result['status'] == 'stopping' - assert not result['running'] - assert result['status_msg'] == 'Backtest ended' + # Simulate running backtest + ApiBG.bgtask_running = True + rc = client_get(client, f"{BASE_URI}/backtest/abort") + assert_response(rc) + result = rc.json() + assert result['status'] == 'stopping' + assert not result['running'] + assert result['status_msg'] == 'Backtest ended' - # Get running backtest... - rc = client_get(client, f"{BASE_URI}/backtest") - assert_response(rc) - result = rc.json() - assert result['status'] == 'running' - assert result['running'] - assert result['step'] == "backtest" - assert result['status_msg'] == "Backtest running" + # Get running backtest... + rc = client_get(client, f"{BASE_URI}/backtest") + assert_response(rc) + result = rc.json() + assert result['status'] == 'running' + assert result['running'] + assert result['step'] == "backtest" + assert result['status_msg'] == "Backtest running" - # Try delete with task still running - rc = client_delete(client, f"{BASE_URI}/backtest") - assert_response(rc) - result = rc.json() - assert result['status'] == 'running' + # Try delete with task still running + rc = client_delete(client, f"{BASE_URI}/backtest") + assert_response(rc) + result = rc.json() + assert result['status'] == 'running' - # Post to backtest that's still running - rc = client_post(client, f"{BASE_URI}/backtest", data=data) - assert_response(rc, 502) - result = rc.json() - assert 'Bot Background task already running' in result['error'] + # Post to backtest that's still running + rc = client_post(client, f"{BASE_URI}/backtest", data=data) + assert_response(rc, 502) + result = rc.json() + assert 'Bot Background task already running' in result['error'] - ApiServer._bgtask_running = False + ApiBG.bgtask_running = False - # Rerun backtest (should get previous result) - rc = client_post(client, f"{BASE_URI}/backtest", data=data) - assert_response(rc) - result = rc.json() - assert log_has_re('Reusing result of previous backtest.*', caplog) + # Rerun backtest (should get previous result) + rc = client_post(client, f"{BASE_URI}/backtest", data=data) + assert_response(rc) + result = rc.json() + assert log_has_re('Reusing result of previous backtest.*', caplog) - data['stake_amount'] = 101 + data['stake_amount'] = 101 - mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest_one_strategy', - side_effect=DependencyException('DeadBeef')) - rc = client_post(client, f"{BASE_URI}/backtest", data=data) - assert log_has("Backtesting caused an error: DeadBeef", caplog) + mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest_one_strategy', + side_effect=DependencyException('DeadBeef')) + rc = client_post(client, f"{BASE_URI}/backtest", data=data) + assert log_has("Backtesting caused an error: DeadBeef", caplog) - rc = client_get(client, f"{BASE_URI}/backtest") - assert_response(rc) - result = rc.json() - assert result['status'] == 'error' - assert 'Backtest failed' in result['status_msg'] + rc = client_get(client, f"{BASE_URI}/backtest") + assert_response(rc) + result = rc.json() + assert result['status'] == 'error' + assert 'Backtest failed' in result['status_msg'] - # Delete backtesting to avoid leakage since the backtest-object may stick around. - rc = client_delete(client, f"{BASE_URI}/backtest") - assert_response(rc) + # Delete backtesting to avoid leakage since the backtest-object may stick around. + rc = client_delete(client, f"{BASE_URI}/backtest") + assert_response(rc) - result = rc.json() - assert result['status'] == 'reset' - assert not result['running'] - assert result['status_msg'] == 'Backtest reset' + result = rc.json() + assert result['status'] == 'reset' + 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=data) - assert_response(rc, 500) + # Disallow base64 strategies + data['strategy'] = "xx:cHJpbnQoImhlbGxvIHdvcmxkIik=" + rc = client_post(client, f"{BASE_URI}/backtest", data=data) + assert_response(rc, 500) + finally: + Backtesting.cleanup() def test_api_backtest_history(botclient, mocker, testdatadir): @@ -1773,7 +1970,9 @@ def test_api_backtest_history(botclient, mocker, testdatadir): ]) rc = client_get(client, f"{BASE_URI}/backtest/history") - assert_response(rc, 502) + assert_response(rc, 503) + assert rc.json()['detail'] == 'Bot is not in the correct state.' + ftbot.config['user_data_dir'] = testdatadir ftbot.config['runmode'] = RunMode.WEBSERVER @@ -1872,7 +2071,7 @@ def test_api_ws_send_msg(default_conf, mocker, caplog): "password": _TEST_PASS, "ws_token": _TEST_WS_TOKEN }}) - mocker.patch('freqtrade.rpc.telegram.Updater') + mocker.patch('freqtrade.rpc.telegram.Telegram._init') mocker.patch('freqtrade.rpc.api_server.ApiServer.start_api') apiserver = ApiServer(default_conf) apiserver.add_rpc_handler(RPC(get_patched_freqtradebot(mocker, default_conf))) @@ -1891,3 +2090,4 @@ def test_api_ws_send_msg(default_conf, mocker, caplog): finally: ApiServer.shutdown() + ApiServer.shutdown() diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 823b270ef..ee4115f97 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -2,15 +2,16 @@ # pragma pylint: disable=protected-access, unused-argument, invalid-name # pragma pylint: disable=too-many-lines, too-many-arguments +import asyncio import logging import re +import threading from datetime import datetime, timedelta, timezone from functools import reduce from random import choice, randint from string import ascii_uppercase -from unittest.mock import ANY, MagicMock +from unittest.mock import ANY, AsyncMock, MagicMock -import arrow import pytest import time_machine from pandas import DataFrame @@ -31,11 +32,17 @@ from freqtrade.persistence.models import Order from freqtrade.rpc import RPC from freqtrade.rpc.rpc import RPCException from freqtrade.rpc.telegram import Telegram, authorized_only +from freqtrade.util.datetime_helpers import dt_now from tests.conftest import (CURRENT_TEST_STRATEGY, EXMS, create_mock_trades, create_mock_trades_usdt, get_patched_freqtradebot, log_has, log_has_re, patch_exchange, patch_get_signal, patch_whitelist) +@pytest.fixture(autouse=True) +def mock_exchange_loop(mocker): + mocker.patch('freqtrade.exchange.exchange.Exchange._init_async_loop') + + @pytest.fixture def default_conf(default_conf) -> dict: # Telegram is enabled by default @@ -43,6 +50,28 @@ def default_conf(default_conf) -> dict: return default_conf +@pytest.fixture +def update(): + message = Message(0, datetime.now(timezone.utc), Chat(0, 0)) + _update = Update(0, message=message) + + return _update + + +def patch_eventloop_threading(telegrambot): + is_init = False + + def thread_fuck(): + nonlocal is_init + telegrambot._loop = asyncio.new_event_loop() + is_init = True + telegrambot._loop.run_forever() + x = threading.Thread(target=thread_fuck, daemon=True) + x.start() + while not is_init: + pass + + class DummyCls(Telegram): """ Dummy class for testing the Telegram @authorized_only decorator @@ -56,14 +85,14 @@ class DummyCls(Telegram): pass @authorized_only - def dummy_handler(self, *args, **kwargs) -> None: + async def dummy_handler(self, *args, **kwargs) -> None: """ Fake method that only change the state of the object """ self.state['called'] = True @authorized_only - def dummy_exception(self, *args, **kwargs) -> None: + async def dummy_exception(self, *args, **kwargs) -> None: """ Fake method that throw an exception """ @@ -71,23 +100,26 @@ class DummyCls(Telegram): def get_telegram_testobject(mocker, default_conf, mock=True, ftbot=None): - msg_mock = MagicMock() + msg_mock = AsyncMock() if mock: mocker.patch.multiple( 'freqtrade.rpc.telegram.Telegram', _init=MagicMock(), - _send_msg=msg_mock + _send_msg=msg_mock, + _start_thread=MagicMock(), ) if not ftbot: + mocker.patch('freqtrade.exchange.exchange.Exchange._init_async_loop') ftbot = get_patched_freqtradebot(mocker, default_conf) rpc = RPC(ftbot) telegram = Telegram(rpc, default_conf) + telegram._loop = MagicMock() + patch_eventloop_threading(telegram) return telegram, ftbot, msg_mock def test_telegram__init__(default_conf, mocker) -> None: - mocker.patch('freqtrade.rpc.telegram.Updater', MagicMock()) mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock()) telegram, _, _ = get_telegram_testobject(mocker, default_conf) @@ -95,43 +127,73 @@ def test_telegram__init__(default_conf, mocker) -> None: def test_telegram_init(default_conf, mocker, caplog) -> None: - start_polling = MagicMock() - mocker.patch('freqtrade.rpc.telegram.Updater', MagicMock(return_value=start_polling)) + app_mock = MagicMock() + mocker.patch('freqtrade.rpc.telegram.Telegram._start_thread', MagicMock()) + mocker.patch('freqtrade.rpc.telegram.Telegram._init_telegram_app', return_value=app_mock) + mocker.patch('freqtrade.rpc.telegram.Telegram._startup_telegram', AsyncMock()) - get_telegram_testobject(mocker, default_conf, mock=False) - assert start_polling.call_count == 0 + telegram, _, _ = get_telegram_testobject(mocker, default_conf, mock=False) + telegram._init() + assert app_mock.call_count == 0 # number of handles registered - assert start_polling.dispatcher.add_handler.call_count > 0 - assert start_polling.start_polling.call_count == 1 + assert app_mock.add_handler.call_count > 0 + # assert start_polling.start_polling.call_count == 1 message_str = ("rpc.telegram is listening for following commands: [['status'], ['profit'], " "['balance'], ['start'], ['stop'], " - "['forcesell', 'forceexit', 'fx'], ['forcebuy', 'forcelong'], ['forceshort'], " - "['trades'], ['delete'], ['coo', 'cancel_open_order'], ['performance'], " - "['buys', 'entries'], ['sells', 'exits'], ['mix_tags'], " + "['forceexit', 'forcesell', 'fx'], ['forcebuy', 'forcelong'], ['forceshort'], " + "['reload_trade'], ['trades'], ['delete'], ['cancel_open_order', 'coo'], " + "['performance'], ['buys', 'entries'], ['exits', 'sells'], ['mix_tags'], " "['stats'], ['daily'], ['weekly'], ['monthly'], " - "['count'], ['locks'], ['unlock', 'delete_locks'], " - "['reload_config', 'reload_conf'], ['show_config', 'show_conf'], " + "['count'], ['locks'], ['delete_locks', 'unlock'], " + "['reload_conf', 'reload_config'], ['show_conf', 'show_config'], " "['stopbuy', 'stopentry'], ['whitelist'], ['blacklist'], " - "['blacklist_delete', 'bl_delete'], " + "['bl_delete', 'blacklist_delete'], " "['logs'], ['edge'], ['health'], ['help'], ['version'], ['marketdir']" "]") assert log_has(message_str, caplog) -def test_cleanup(default_conf, mocker, ) -> None: +async def test_telegram_startup(default_conf, mocker) -> None: + app_mock = MagicMock() + app_mock.initialize = AsyncMock() + app_mock.start = AsyncMock() + app_mock.updater.start_polling = AsyncMock() + app_mock.updater.running = False + sleep_mock = mocker.patch('freqtrade.rpc.telegram.asyncio.sleep', AsyncMock()) + + telegram, _, _ = get_telegram_testobject(mocker, default_conf) + telegram._app = app_mock + await telegram._startup_telegram() + assert app_mock.initialize.call_count == 1 + assert app_mock.start.call_count == 1 + assert app_mock.updater.start_polling.call_count == 1 + assert sleep_mock.call_count == 1 + + +async def test_telegram_cleanup(default_conf, mocker, ) -> None: + app_mock = MagicMock() + app_mock.stop = AsyncMock() + app_mock.initialize = AsyncMock() + updater_mock = MagicMock() - updater_mock.stop = MagicMock() - mocker.patch('freqtrade.rpc.telegram.Updater', updater_mock) + updater_mock.stop = AsyncMock() + app_mock.updater = updater_mock + # mocker.patch('freqtrade.rpc.telegram.Application', app_mock) - telegram, _, _ = get_telegram_testobject(mocker, default_conf, mock=False) + telegram, _, _ = get_telegram_testobject(mocker, default_conf) + telegram._app = app_mock + telegram._loop = asyncio.get_running_loop() + telegram._thread = MagicMock() telegram.cleanup() - assert telegram._updater.stop.call_count == 1 + await asyncio.sleep(0.1) + assert app_mock.stop.call_count == 1 + assert telegram._thread.join.call_count == 1 -def test_authorized_only(default_conf, mocker, caplog, update) -> None: +async def test_authorized_only(default_conf, mocker, caplog, update) -> None: patch_exchange(mocker) caplog.set_level(logging.DEBUG) default_conf['telegram']['enabled'] = False @@ -140,19 +202,19 @@ def test_authorized_only(default_conf, mocker, caplog, update) -> None: dummy = DummyCls(rpc, default_conf) patch_get_signal(bot) - dummy.dummy_handler(update=update, context=MagicMock()) + await dummy.dummy_handler(update=update, context=MagicMock()) assert dummy.state['called'] is True assert log_has('Executing handler: dummy_handler for chat_id: 0', caplog) assert not log_has('Rejected unauthorized message from: 0', caplog) assert not log_has('Exception occurred within Telegram module', caplog) -def test_authorized_only_unauthorized(default_conf, mocker, caplog) -> None: +async def test_authorized_only_unauthorized(default_conf, mocker, caplog) -> None: patch_exchange(mocker) caplog.set_level(logging.DEBUG) chat = Chat(0xdeadbeef, 0) - update = Update(randint(1, 100)) - update.message = Message(randint(1, 100), datetime.utcnow(), chat) + message = Message(randint(1, 100), datetime.now(timezone.utc), chat) + update = Update(randint(1, 100), message=message) default_conf['telegram']['enabled'] = False bot = FreqtradeBot(default_conf) @@ -160,14 +222,14 @@ def test_authorized_only_unauthorized(default_conf, mocker, caplog) -> None: dummy = DummyCls(rpc, default_conf) patch_get_signal(bot) - dummy.dummy_handler(update=update, context=MagicMock()) + await dummy.dummy_handler(update=update, context=MagicMock()) assert dummy.state['called'] is False assert not log_has('Executing handler: dummy_handler for chat_id: 3735928559', caplog) assert log_has('Rejected unauthorized message from: 3735928559', caplog) assert not log_has('Exception occurred within Telegram module', caplog) -def test_authorized_only_exception(default_conf, mocker, caplog, update) -> None: +async def test_authorized_only_exception(default_conf, mocker, caplog, update) -> None: patch_exchange(mocker) default_conf['telegram']['enabled'] = False @@ -177,17 +239,15 @@ def test_authorized_only_exception(default_conf, mocker, caplog, update) -> None dummy = DummyCls(rpc, default_conf) patch_get_signal(bot) - dummy.dummy_exception(update=update, context=MagicMock()) + await dummy.dummy_exception(update=update, context=MagicMock()) assert dummy.state['called'] is False assert not log_has('Executing handler: dummy_handler for chat_id: 0', caplog) assert not log_has('Rejected unauthorized message from: 0', caplog) assert log_has('Exception occurred within Telegram module', caplog) -def test_telegram_status(default_conf, update, mocker) -> None: - update.message.chat.id = "123" +async def test_telegram_status(default_conf, update, mocker) -> None: default_conf['telegram']['enabled'] = False - default_conf['telegram']['chat_id'] = "123" status_table = MagicMock() mocker.patch('freqtrade.rpc.telegram.Telegram._status_table', status_table) @@ -199,7 +259,7 @@ def test_telegram_status(default_conf, update, mocker) -> None: 'pair': 'ETH/BTC', 'base_currency': 'ETH', 'quote_currency': 'BTC', - 'open_date': arrow.utcnow(), + 'open_date': dt_now(), 'close_date': None, 'open_rate': 1.099e-05, 'close_rate': None, @@ -232,21 +292,19 @@ def test_telegram_status(default_conf, update, mocker) -> None: telegram, _, msg_mock = get_telegram_testobject(mocker, default_conf) - telegram._status(update=update, context=MagicMock()) + await telegram._status(update=update, context=MagicMock()) assert msg_mock.call_count == 1 context = MagicMock() # /status table context.args = ["table"] - telegram._status(update=update, context=context) + await telegram._status(update=update, context=context) assert status_table.call_count == 1 @pytest.mark.usefixtures("init_persistence") -def test_telegram_status_multi_entry(default_conf, update, mocker, fee) -> None: - update.message.chat.id = "123" +async def test_telegram_status_multi_entry(default_conf, update, mocker, fee) -> None: default_conf['telegram']['enabled'] = False - default_conf['telegram']['chat_id'] = "123" default_conf['position_adjustment_enable'] = True mocker.patch.multiple( EXMS, @@ -284,7 +342,7 @@ def test_telegram_status_multi_entry(default_conf, update, mocker, fee) -> None: trade.recalc_trade_from_orders() Trade.commit() - telegram._status(update=update, context=MagicMock()) + await telegram._status(update=update, context=MagicMock()) assert msg_mock.call_count == 4 msg = msg_mock.call_args_list[0][0][0] assert re.search(r'Number of Entries.*2', msg) @@ -296,10 +354,7 @@ def test_telegram_status_multi_entry(default_conf, update, mocker, fee) -> None: @pytest.mark.usefixtures("init_persistence") -def test_telegram_status_closed_trade(default_conf, update, mocker, fee) -> None: - update.message.chat.id = "123" - default_conf['telegram']['enabled'] = False - default_conf['telegram']['chat_id'] = "123" +async def test_telegram_status_closed_trade(default_conf, update, mocker, fee) -> None: default_conf['position_adjustment_enable'] = True mocker.patch.multiple( EXMS, @@ -313,14 +368,14 @@ def test_telegram_status_closed_trade(default_conf, update, mocker, fee) -> None trade = Trade.get_trades([Trade.is_open.is_(False)]).first() context = MagicMock() context.args = [str(trade.id)] - telegram._status(update=update, context=context) + await telegram._status(update=update, context=context) assert msg_mock.call_count == 1 msg = msg_mock.call_args_list[0][0][0] assert re.search(r'Close Date:', msg) assert re.search(r'Close Profit:', msg) -def test_status_handle(default_conf, update, ticker, fee, mocker) -> None: +async def test_status_handle(default_conf, update, ticker, fee, mocker) -> None: default_conf['max_open_trades'] = 3 mocker.patch.multiple( EXMS, @@ -340,13 +395,13 @@ def test_status_handle(default_conf, update, ticker, fee, mocker) -> None: freqtradebot.state = State.STOPPED # Status is also enabled when stopped - telegram._status(update=update, context=MagicMock()) + await telegram._status(update=update, context=MagicMock()) assert msg_mock.call_count == 1 assert 'no active trade' in msg_mock.call_args_list[0][0][0] msg_mock.reset_mock() freqtradebot.state = State.RUNNING - telegram._status(update=update, context=MagicMock()) + await telegram._status(update=update, context=MagicMock()) assert msg_mock.call_count == 1 assert 'no active trade' in msg_mock.call_args_list[0][0][0] msg_mock.reset_mock() @@ -354,7 +409,7 @@ def test_status_handle(default_conf, update, ticker, fee, mocker) -> None: # Create some test data freqtradebot.enter_positions() # Trigger status while we have a fulfilled order for the open trade - telegram._status(update=update, context=MagicMock()) + await telegram._status(update=update, context=MagicMock()) # close_rate should not be included in the message as the trade is not closed # and no line should be empty @@ -371,7 +426,7 @@ def test_status_handle(default_conf, update, ticker, fee, mocker) -> None: context = MagicMock() context.args = ["2", "3"] - telegram._status(update=update, context=context) + await telegram._status(update=update, context=context) lines = msg_mock.call_args_list[0][0][0].split('\n') assert '' not in lines[:-1] @@ -386,7 +441,7 @@ def test_status_handle(default_conf, update, ticker, fee, mocker) -> None: msg_mock.reset_mock() context = MagicMock() context.args = ["2"] - telegram._status(update=update, context=context) + await telegram._status(update=update, context=context) assert msg_mock.call_count == 2 @@ -398,7 +453,7 @@ def test_status_handle(default_conf, update, ticker, fee, mocker) -> None: assert 'Trade ID:* `2` - continued' in msg2 -def test_status_table_handle(default_conf, update, ticker, fee, mocker) -> None: +async def test_status_table_handle(default_conf, update, ticker, fee, mocker) -> None: mocker.patch.multiple( EXMS, fetch_ticker=ticker, @@ -413,13 +468,13 @@ def test_status_table_handle(default_conf, update, ticker, fee, mocker) -> None: freqtradebot.state = State.STOPPED # Status table is also enabled when stopped - telegram._status_table(update=update, context=MagicMock()) + await telegram._status_table(update=update, context=MagicMock()) assert msg_mock.call_count == 1 assert 'no active trade' in msg_mock.call_args_list[0][0][0] msg_mock.reset_mock() freqtradebot.state = State.RUNNING - telegram._status_table(update=update, context=MagicMock()) + await telegram._status_table(update=update, context=MagicMock()) assert msg_mock.call_count == 1 assert 'no active trade' in msg_mock.call_args_list[0][0][0] msg_mock.reset_mock() @@ -427,7 +482,7 @@ def test_status_table_handle(default_conf, update, ticker, fee, mocker) -> None: # Create some test data freqtradebot.enter_positions() - telegram._status_table(update=update, context=MagicMock()) + await telegram._status_table(update=update, context=MagicMock()) text = re.sub('', '', msg_mock.call_args_list[-1][0][0]) line = text.split("\n") @@ -439,7 +494,7 @@ def test_status_table_handle(default_conf, update, ticker, fee, mocker) -> None: assert msg_mock.call_count == 1 -def test_daily_handle(default_conf_usdt, update, ticker, fee, mocker, time_machine) -> None: +async def test_daily_handle(default_conf_usdt, update, ticker, fee, mocker, time_machine) -> None: mocker.patch( 'freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', return_value=1.1 @@ -461,11 +516,11 @@ def test_daily_handle(default_conf_usdt, update, ticker, fee, mocker, time_machi # /daily 2 context = MagicMock() context.args = ["2"] - telegram._daily(update=update, context=context) + await telegram._daily(update=update, context=context) assert msg_mock.call_count == 1 assert "Daily Profit over the last 2 days:" in msg_mock.call_args_list[0][0][0] assert 'Day ' in msg_mock.call_args_list[0][0][0] - assert str(datetime.utcnow().date()) in msg_mock.call_args_list[0][0][0] + assert str(datetime.now(timezone.utc).date()) in msg_mock.call_args_list[0][0][0] assert ' 6.83 USDT' in msg_mock.call_args_list[0][0][0] assert ' 7.51 USD' in msg_mock.call_args_list[0][0][0] assert '(2)' in msg_mock.call_args_list[0][0][0] @@ -475,11 +530,12 @@ def test_daily_handle(default_conf_usdt, update, ticker, fee, mocker, time_machi # Reset msg_mock msg_mock.reset_mock() context.args = [] - telegram._daily(update=update, context=context) + await telegram._daily(update=update, context=context) assert msg_mock.call_count == 1 assert "Daily Profit over the last 7 days:" in msg_mock.call_args_list[0][0][0] - assert str(datetime.utcnow().date()) in msg_mock.call_args_list[0][0][0] - assert str((datetime.utcnow() - timedelta(days=5)).date()) in msg_mock.call_args_list[0][0][0] + assert str(datetime.now(timezone.utc).date()) in msg_mock.call_args_list[0][0][0] + assert str((datetime.now(timezone.utc) - timedelta(days=5)).date() + ) in msg_mock.call_args_list[0][0][0] assert ' 6.83 USDT' in msg_mock.call_args_list[0][0][0] assert ' 7.51 USD' in msg_mock.call_args_list[0][0][0] assert '(2)' in msg_mock.call_args_list[0][0][0] @@ -492,13 +548,13 @@ def test_daily_handle(default_conf_usdt, update, ticker, fee, mocker, time_machi # /daily 1 context = MagicMock() context.args = ["1"] - telegram._daily(update=update, context=context) + await telegram._daily(update=update, context=context) assert ' 6.83 USDT' in msg_mock.call_args_list[0][0][0] assert ' 7.51 USD' in msg_mock.call_args_list[0][0][0] assert '(2)' in msg_mock.call_args_list[0][0][0] -def test_daily_wrong_input(default_conf, update, ticker, mocker) -> None: +async def test_daily_wrong_input(default_conf, update, ticker, mocker) -> None: mocker.patch.multiple( EXMS, fetch_ticker=ticker @@ -513,7 +569,7 @@ def test_daily_wrong_input(default_conf, update, ticker, mocker) -> None: # /daily -2 context = MagicMock() context.args = ["-2"] - telegram._daily(update=update, context=context) + await telegram._daily(update=update, context=context) assert msg_mock.call_count == 1 assert 'must be an integer greater than 0' in msg_mock.call_args_list[0][0][0] @@ -523,11 +579,11 @@ def test_daily_wrong_input(default_conf, update, ticker, mocker) -> None: # /daily today context = MagicMock() context.args = ["today"] - telegram._daily(update=update, context=context) + await telegram._daily(update=update, context=context) assert 'Daily Profit over the last 7 days:' in msg_mock.call_args_list[0][0][0] -def test_weekly_handle(default_conf_usdt, update, ticker, fee, mocker, time_machine) -> None: +async def test_weekly_handle(default_conf_usdt, update, ticker, fee, mocker, time_machine) -> None: default_conf_usdt['max_open_trades'] = 1 mocker.patch( 'freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', @@ -548,12 +604,12 @@ def test_weekly_handle(default_conf_usdt, update, ticker, fee, mocker, time_mach # /weekly 2 context = MagicMock() context.args = ["2"] - telegram._weekly(update=update, context=context) + await telegram._weekly(update=update, context=context) assert msg_mock.call_count == 1 assert "Weekly Profit over the last 2 weeks (starting from Monday):" \ in msg_mock.call_args_list[0][0][0] assert 'Monday ' in msg_mock.call_args_list[0][0][0] - today = datetime.utcnow().date() + today = datetime.now(timezone.utc).date() first_iso_day_of_current_week = today - timedelta(days=today.weekday()) assert str(first_iso_day_of_current_week) in msg_mock.call_args_list[0][0][0] assert ' 2.74 USDT' in msg_mock.call_args_list[0][0][0] @@ -564,7 +620,7 @@ def test_weekly_handle(default_conf_usdt, update, ticker, fee, mocker, time_mach # Reset msg_mock msg_mock.reset_mock() context.args = [] - telegram._weekly(update=update, context=context) + await telegram._weekly(update=update, context=context) assert msg_mock.call_count == 1 assert "Weekly Profit over the last 8 weeks (starting from Monday):" \ in msg_mock.call_args_list[0][0][0] @@ -580,7 +636,7 @@ def test_weekly_handle(default_conf_usdt, update, ticker, fee, mocker, time_mach # /weekly -3 context = MagicMock() context.args = ["-3"] - telegram._weekly(update=update, context=context) + await telegram._weekly(update=update, context=context) assert msg_mock.call_count == 1 assert 'must be an integer greater than 0' in msg_mock.call_args_list[0][0][0] @@ -590,14 +646,14 @@ def test_weekly_handle(default_conf_usdt, update, ticker, fee, mocker, time_mach # /weekly this week context = MagicMock() context.args = ["this week"] - telegram._weekly(update=update, context=context) + await telegram._weekly(update=update, context=context) assert ( 'Weekly Profit over the last 8 weeks (starting from Monday):' in msg_mock.call_args_list[0][0][0] ) -def test_monthly_handle(default_conf_usdt, update, ticker, fee, mocker, time_machine) -> None: +async def test_monthly_handle(default_conf_usdt, update, ticker, fee, mocker, time_machine) -> None: default_conf_usdt['max_open_trades'] = 1 mocker.patch( 'freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', @@ -618,11 +674,11 @@ def test_monthly_handle(default_conf_usdt, update, ticker, fee, mocker, time_mac # /monthly 2 context = MagicMock() context.args = ["2"] - telegram._monthly(update=update, context=context) + await telegram._monthly(update=update, context=context) assert msg_mock.call_count == 1 assert 'Monthly Profit over the last 2 months:' in msg_mock.call_args_list[0][0][0] assert 'Month ' in msg_mock.call_args_list[0][0][0] - today = datetime.utcnow().date() + today = datetime.now(timezone.utc).date() current_month = f"{today.year}-{today.month:02} " assert current_month in msg_mock.call_args_list[0][0][0] assert ' 2.74 USDT' in msg_mock.call_args_list[0][0][0] @@ -633,7 +689,7 @@ def test_monthly_handle(default_conf_usdt, update, ticker, fee, mocker, time_mac # Reset msg_mock msg_mock.reset_mock() context.args = [] - telegram._monthly(update=update, context=context) + await telegram._monthly(update=update, context=context) assert msg_mock.call_count == 1 # Default to 6 months assert 'Monthly Profit over the last 6 months:' in msg_mock.call_args_list[0][0][0] @@ -650,7 +706,7 @@ def test_monthly_handle(default_conf_usdt, update, ticker, fee, mocker, time_mac # /monthly 12 context = MagicMock() context.args = ["12"] - telegram._monthly(update=update, context=context) + await telegram._monthly(update=update, context=context) assert msg_mock.call_count == 1 assert 'Monthly Profit over the last 12 months:' in msg_mock.call_args_list[0][0][0] assert ' 2.74 USDT' in msg_mock.call_args_list[0][0][0] @@ -667,7 +723,7 @@ def test_monthly_handle(default_conf_usdt, update, ticker, fee, mocker, time_mac # /monthly -3 context = MagicMock() context.args = ["-3"] - telegram._monthly(update=update, context=context) + await telegram._monthly(update=update, context=context) assert msg_mock.call_count == 1 assert 'must be an integer greater than 0' in msg_mock.call_args_list[0][0][0] @@ -677,11 +733,11 @@ def test_monthly_handle(default_conf_usdt, update, ticker, fee, mocker, time_mac # /monthly february context = MagicMock() context.args = ["february"] - telegram._monthly(update=update, context=context) + await telegram._monthly(update=update, context=context) assert 'Monthly Profit over the last 6 months:' in msg_mock.call_args_list[0][0][0] -def test_telegram_profit_handle( +async def test_telegram_profit_handle( default_conf_usdt, update, ticker_usdt, ticker_sell_up, fee, limit_sell_order_usdt, mocker) -> None: mocker.patch('freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', return_value=1.1) @@ -694,7 +750,7 @@ def test_telegram_profit_handle( telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf_usdt) patch_get_signal(freqtradebot) - telegram._profit(update=update, context=MagicMock()) + await telegram._profit(update=update, context=MagicMock()) assert msg_mock.call_count == 1 assert 'No trades yet.' in msg_mock.call_args_list[0][0][0] msg_mock.reset_mock() @@ -706,7 +762,7 @@ def test_telegram_profit_handle( context = MagicMock() # Test with invalid 2nd argument (should silently pass) context.args = ["aaa"] - telegram._profit(update=update, context=context) + await telegram._profit(update=update, context=context) assert msg_mock.call_count == 1 assert 'No closed trade' in msg_mock.call_args_list[-1][0][0] assert '*ROI:* All trades' in msg_mock.call_args_list[-1][0][0] @@ -729,7 +785,7 @@ def test_telegram_profit_handle( Trade.commit() context.args = [3] - telegram._profit(update=update, context=context) + await telegram._profit(update=update, context=context) assert msg_mock.call_count == 1 assert '*ROI:* Closed trades' in msg_mock.call_args_list[-1][0][0] assert ('∙ `5.685 USDT (9.45%) (0.57 \N{GREEK CAPITAL LETTER SIGMA}%)`' @@ -749,7 +805,7 @@ def test_telegram_profit_handle( @pytest.mark.parametrize('is_short', [True, False]) -def test_telegram_stats(default_conf, update, ticker, fee, mocker, is_short) -> None: +async def test_telegram_stats(default_conf, update, ticker, fee, mocker, is_short) -> None: mocker.patch('freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', return_value=15000.0) mocker.patch.multiple( EXMS, @@ -759,7 +815,7 @@ def test_telegram_stats(default_conf, update, ticker, fee, mocker, is_short) -> telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) patch_get_signal(freqtradebot) - telegram._stats(update=update, context=MagicMock()) + await telegram._stats(update=update, context=MagicMock()) assert msg_mock.call_count == 1 assert 'No trades yet.' in msg_mock.call_args_list[0][0][0] msg_mock.reset_mock() @@ -767,15 +823,18 @@ def test_telegram_stats(default_conf, update, ticker, fee, mocker, is_short) -> # Create some test data create_mock_trades(fee, is_short=is_short) - telegram._stats(update=update, context=MagicMock()) + await telegram._stats(update=update, context=MagicMock()) assert msg_mock.call_count == 1 assert 'Exit Reason' in msg_mock.call_args_list[-1][0][0] assert 'ROI' in msg_mock.call_args_list[-1][0][0] assert 'Avg. Duration' in msg_mock.call_args_list[-1][0][0] + # Duration is not only N/A + assert '0:19:00' in msg_mock.call_args_list[-1][0][0] + assert 'N/A' in msg_mock.call_args_list[-1][0][0] msg_mock.reset_mock() -def test_telegram_balance_handle(default_conf, update, mocker, rpc_balance, tickers) -> None: +async def test_telegram_balance_handle(default_conf, update, mocker, rpc_balance, tickers) -> None: default_conf['dry_run'] = False mocker.patch(f'{EXMS}.get_balances', return_value=rpc_balance) mocker.patch(f'{EXMS}.get_tickers', tickers) @@ -784,23 +843,32 @@ def test_telegram_balance_handle(default_conf, update, mocker, rpc_balance, tick telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) patch_get_signal(freqtradebot) - telegram._balance(update=update, context=MagicMock()) + await telegram._balance(update=update, context=MagicMock()) + context = MagicMock() + context.args = ["full"] + await telegram._balance(update=update, context=context) result = msg_mock.call_args_list[0][0][0] - assert msg_mock.call_count == 1 + result_full = msg_mock.call_args_list[1][0][0] + assert msg_mock.call_count == 2 assert '*BTC:*' in result assert '*ETH:*' not in result assert '*USDT:*' not in result assert '*EUR:*' not in result - assert '*LTC:*' in result + assert '*LTC:*' not in result + + assert '*LTC:*' in result_full assert '*XRP:*' not in result assert 'Balance:' in result assert 'Est. BTC:' in result - assert 'BTC: 12' in result + assert 'BTC: 11' in result + assert 'BTC: 12' in result_full assert "*3 Other Currencies (< 0.0001 BTC):*" in result assert 'BTC: 0.00000309' in result + assert '*Estimated Value*:' in result_full + assert '*Estimated Value (Bot managed assets only)*:' in result -def test_balance_handle_empty_response(default_conf, update, mocker) -> None: +async def test_balance_handle_empty_response(default_conf, update, mocker) -> None: default_conf['dry_run'] = False mocker.patch(f'{EXMS}.get_balances', return_value={}) @@ -808,26 +876,26 @@ def test_balance_handle_empty_response(default_conf, update, mocker) -> None: patch_get_signal(freqtradebot) freqtradebot.config['dry_run'] = False - telegram._balance(update=update, context=MagicMock()) + await telegram._balance(update=update, context=MagicMock()) result = msg_mock.call_args_list[0][0][0] assert msg_mock.call_count == 1 assert 'Starting capital: `0 BTC' in result -def test_balance_handle_empty_response_dry(default_conf, update, mocker) -> None: +async def test_balance_handle_empty_response_dry(default_conf, update, mocker) -> None: mocker.patch(f'{EXMS}.get_balances', return_value={}) telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) patch_get_signal(freqtradebot) - telegram._balance(update=update, context=MagicMock()) + await telegram._balance(update=update, context=MagicMock()) result = msg_mock.call_args_list[0][0][0] assert msg_mock.call_count == 1 assert "*Warning:* Simulated balances in Dry Mode." in result assert "Starting capital: `1000 BTC`" in result -def test_balance_handle_too_large_response(default_conf, update, mocker) -> None: +async def test_balance_handle_too_large_response(default_conf, update, mocker) -> None: balances = [] for i in range(100): curr = choice(ascii_uppercase) + choice(ascii_uppercase) + choice(ascii_uppercase) @@ -836,18 +904,23 @@ def test_balance_handle_too_large_response(default_conf, update, mocker) -> None 'free': 1.0, 'used': 0.5, 'balance': i, + 'bot_owned': 0.5, 'est_stake': 1, + 'est_stake_bot': 1, 'stake': 'BTC', 'is_position': False, 'leverage': 1.0, 'position': 0.0, 'side': 'long', + 'is_bot_managed': True, }) mocker.patch('freqtrade.rpc.rpc.RPC._rpc_balance', return_value={ 'currencies': balances, 'total': 100.0, + 'total_bot': 100.0, 'symbol': 100.0, 'value': 1000.0, + 'value_bot': 1000.0, 'starting_capital': 1000, 'starting_capital_fiat': 1000, }) @@ -855,7 +928,7 @@ def test_balance_handle_too_large_response(default_conf, update, mocker) -> None telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) patch_get_signal(freqtradebot) - telegram._balance(update=update, context=MagicMock()) + await telegram._balance(update=update, context=MagicMock()) assert msg_mock.call_count > 1 # Test if wrap happens around 4000 - # and each single currency-output is around 120 characters long so we need @@ -864,79 +937,79 @@ def test_balance_handle_too_large_response(default_conf, update, mocker) -> None assert len(msg_mock.call_args_list[0][0][0]) > (4096 - 120) -def test_start_handle(default_conf, update, mocker) -> None: +async def test_start_handle(default_conf, update, mocker) -> None: telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) freqtradebot.state = State.STOPPED assert freqtradebot.state == State.STOPPED - telegram._start(update=update, context=MagicMock()) + await telegram._start(update=update, context=MagicMock()) assert freqtradebot.state == State.RUNNING assert msg_mock.call_count == 1 -def test_start_handle_already_running(default_conf, update, mocker) -> None: +async def test_start_handle_already_running(default_conf, update, mocker) -> None: telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) freqtradebot.state = State.RUNNING assert freqtradebot.state == State.RUNNING - telegram._start(update=update, context=MagicMock()) + await telegram._start(update=update, context=MagicMock()) assert freqtradebot.state == State.RUNNING assert msg_mock.call_count == 1 assert 'already running' in msg_mock.call_args_list[0][0][0] -def test_stop_handle(default_conf, update, mocker) -> None: +async def test_stop_handle(default_conf, update, mocker) -> None: telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) freqtradebot.state = State.RUNNING assert freqtradebot.state == State.RUNNING - telegram._stop(update=update, context=MagicMock()) + await telegram._stop(update=update, context=MagicMock()) assert freqtradebot.state == State.STOPPED assert msg_mock.call_count == 1 assert 'stopping trader' in msg_mock.call_args_list[0][0][0] -def test_stop_handle_already_stopped(default_conf, update, mocker) -> None: +async def test_stop_handle_already_stopped(default_conf, update, mocker) -> None: telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) freqtradebot.state = State.STOPPED assert freqtradebot.state == State.STOPPED - telegram._stop(update=update, context=MagicMock()) + await telegram._stop(update=update, context=MagicMock()) assert freqtradebot.state == State.STOPPED assert msg_mock.call_count == 1 assert 'already stopped' in msg_mock.call_args_list[0][0][0] -def test_stopbuy_handle(default_conf, update, mocker) -> None: +async def test_stopbuy_handle(default_conf, update, mocker) -> None: telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) assert freqtradebot.config['max_open_trades'] != 0 - telegram._stopentry(update=update, context=MagicMock()) + await telegram._stopentry(update=update, context=MagicMock()) assert freqtradebot.config['max_open_trades'] == 0 assert msg_mock.call_count == 1 assert 'No more entries will occur from now. Run /reload_config to reset.' \ in msg_mock.call_args_list[0][0][0] -def test_reload_config_handle(default_conf, update, mocker) -> None: +async def test_reload_config_handle(default_conf, update, mocker) -> None: telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) freqtradebot.state = State.RUNNING assert freqtradebot.state == State.RUNNING - telegram._reload_config(update=update, context=MagicMock()) + await telegram._reload_config(update=update, context=MagicMock()) assert freqtradebot.state == State.RELOAD_CONFIG assert msg_mock.call_count == 1 assert 'Reloading config' in msg_mock.call_args_list[0][0][0] -def test_telegram_forceexit_handle(default_conf, update, ticker, fee, - ticker_sell_up, mocker) -> None: +async def test_telegram_forceexit_handle(default_conf, update, ticker, fee, + ticker_sell_up, mocker) -> None: mocker.patch('freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', return_value=15000.0) msg_mock = mocker.patch('freqtrade.rpc.telegram.Telegram.send_msg', MagicMock()) mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock()) @@ -966,7 +1039,7 @@ def test_telegram_forceexit_handle(default_conf, update, ticker, fee, # /forceexit 1 context = MagicMock() context.args = ["1"] - telegram._force_exit(update=update, context=context) + await telegram._force_exit(update=update, context=context) assert msg_mock.call_count == 4 last_msg = msg_mock.call_args_list[-2][0][0] @@ -1002,8 +1075,8 @@ def test_telegram_forceexit_handle(default_conf, update, ticker, fee, } == last_msg -def test_telegram_force_exit_down_handle(default_conf, update, ticker, fee, - ticker_sell_down, mocker) -> None: +async def test_telegram_force_exit_down_handle(default_conf, update, ticker, fee, + ticker_sell_down, mocker) -> None: mocker.patch('freqtrade.rpc.fiat_convert.CryptoToFiatConverter._find_price', return_value=15000.0) msg_mock = mocker.patch('freqtrade.rpc.telegram.Telegram.send_msg', MagicMock()) @@ -1038,7 +1111,7 @@ def test_telegram_force_exit_down_handle(default_conf, update, ticker, fee, # /forceexit 1 context = MagicMock() context.args = ["1"] - telegram._force_exit(update=update, context=context) + await telegram._force_exit(update=update, context=context) assert msg_mock.call_count == 4 @@ -1075,7 +1148,7 @@ def test_telegram_force_exit_down_handle(default_conf, update, ticker, fee, } == last_msg -def test_forceexit_all_handle(default_conf, update, ticker, fee, mocker) -> None: +async def test_forceexit_all_handle(default_conf, update, ticker, fee, mocker) -> None: patch_exchange(mocker) mocker.patch('freqtrade.rpc.fiat_convert.CryptoToFiatConverter._find_price', return_value=15000.0) @@ -1101,7 +1174,7 @@ def test_forceexit_all_handle(default_conf, update, ticker, fee, mocker) -> None # /forceexit all context = MagicMock() context.args = ["all"] - telegram._force_exit(update=update, context=context) + await telegram._force_exit(update=update, context=context) # Called for each trade 2 times assert msg_mock.call_count == 8 @@ -1138,7 +1211,7 @@ def test_forceexit_all_handle(default_conf, update, ticker, fee, mocker) -> None } == msg -def test_forceexit_handle_invalid(default_conf, update, mocker) -> None: +async def test_forceexit_handle_invalid(default_conf, update, mocker) -> None: mocker.patch('freqtrade.rpc.fiat_convert.CryptoToFiatConverter._find_price', return_value=15000.0) @@ -1150,7 +1223,7 @@ def test_forceexit_handle_invalid(default_conf, update, mocker) -> None: # /forceexit 1 context = MagicMock() context.args = ["1"] - telegram._force_exit(update=update, context=context) + await telegram._force_exit(update=update, context=context) assert msg_mock.call_count == 1 assert 'not running' in msg_mock.call_args_list[0][0][0] @@ -1160,12 +1233,12 @@ def test_forceexit_handle_invalid(default_conf, update, mocker) -> None: # /forceexit 123456 context = MagicMock() context.args = ["123456"] - telegram._force_exit(update=update, context=context) + await telegram._force_exit(update=update, context=context) assert msg_mock.call_count == 1 assert 'invalid argument' in msg_mock.call_args_list[0][0][0] -def test_force_exit_no_pair(default_conf, update, ticker, fee, mocker) -> None: +async def test_force_exit_no_pair(default_conf, update, ticker, fee, mocker) -> None: default_conf['max_open_trades'] = 4 mocker.patch.multiple( EXMS, @@ -1181,7 +1254,7 @@ def test_force_exit_no_pair(default_conf, update, ticker, fee, mocker) -> None: # /forceexit context = MagicMock() context.args = [] - telegram._force_exit(update=update, context=context) + await telegram._force_exit(update=update, context=context) # No pair assert msg_mock.call_args_list[0][1]['msg'] == 'No open trade found.' @@ -1190,7 +1263,7 @@ def test_force_exit_no_pair(default_conf, update, ticker, fee, mocker) -> None: msg_mock.reset_mock() # /forceexit - telegram._force_exit(update=update, context=context) + await telegram._force_exit(update=update, context=context) keyboard = msg_mock.call_args_list[0][1]['keyboard'] # 4 pairs + cancel assert reduce(lambda acc, x: acc + len(x), keyboard, 0) == 5 @@ -1198,9 +1271,9 @@ def test_force_exit_no_pair(default_conf, update, ticker, fee, mocker) -> None: assert keyboard[1][0].callback_data == 'force_exit__2 ' update = MagicMock() - update.callback_query = MagicMock() + update.callback_query = AsyncMock() update.callback_query.data = keyboard[1][0].callback_data - telegram._force_exit_inline(update, None) + await telegram._force_exit_inline(update, None) assert update.callback_query.answer.call_count == 1 assert update.callback_query.edit_message_text.call_count == 1 assert femock.call_count == 1 @@ -1208,17 +1281,17 @@ def test_force_exit_no_pair(default_conf, update, ticker, fee, mocker) -> None: # Retry exiting - but cancel instead update.callback_query.reset_mock() - telegram._force_exit(update=update, context=context) + await telegram._force_exit(update=update, context=context) # Use cancel button update.callback_query.data = keyboard[-1][0].callback_data - telegram._force_exit_inline(update, None) + await telegram._force_exit_inline(update, None) query = update.callback_query assert query.answer.call_count == 1 assert query.edit_message_text.call_count == 1 assert query.edit_message_text.call_args_list[-1][1]['text'] == "Force exit canceled." -def test_force_enter_handle(default_conf, update, mocker) -> None: +async def test_force_enter_handle(default_conf, update, mocker) -> None: mocker.patch('freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', return_value=15000.0) fbuy_mock = MagicMock(return_value=None) @@ -1230,7 +1303,7 @@ def test_force_enter_handle(default_conf, update, mocker) -> None: # /forcelong ETH/BTC context = MagicMock() context.args = ["ETH/BTC"] - telegram._force_enter(update=update, context=context, order_side=SignalDirection.LONG) + await telegram._force_enter(update=update, context=context, order_side=SignalDirection.LONG) assert fbuy_mock.call_count == 1 assert fbuy_mock.call_args_list[0][0][0] == 'ETH/BTC' @@ -1243,7 +1316,7 @@ def test_force_enter_handle(default_conf, update, mocker) -> None: # /forcelong ETH/BTC 0.055 context = MagicMock() context.args = ["ETH/BTC", "0.055"] - telegram._force_enter(update=update, context=context, order_side=SignalDirection.LONG) + await telegram._force_enter(update=update, context=context, order_side=SignalDirection.LONG) assert fbuy_mock.call_count == 1 assert fbuy_mock.call_args_list[0][0][0] == 'ETH/BTC' @@ -1251,20 +1324,19 @@ def test_force_enter_handle(default_conf, update, mocker) -> None: assert fbuy_mock.call_args_list[0][0][1] == 0.055 -def test_force_enter_handle_exception(default_conf, update, mocker) -> None: +async def test_force_enter_handle_exception(default_conf, update, mocker) -> None: mocker.patch('freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', return_value=15000.0) telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) patch_get_signal(freqtradebot) - update.message.text = '/forcebuy ETH/Nonepair' - telegram._force_enter(update=update, context=MagicMock(), order_side=SignalDirection.LONG) + await telegram._force_enter(update=update, context=MagicMock(), order_side=SignalDirection.LONG) assert msg_mock.call_count == 1 assert msg_mock.call_args_list[0][0][0] == 'Force_entry not enabled.' -def test_force_enter_no_pair(default_conf, update, mocker) -> None: +async def test_force_enter_no_pair(default_conf, update, mocker) -> None: mocker.patch('freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', return_value=15000.0) fbuy_mock = MagicMock(return_value=None) @@ -1276,7 +1348,7 @@ def test_force_enter_no_pair(default_conf, update, mocker) -> None: context = MagicMock() context.args = [] - telegram._force_enter(update=update, context=context, order_side=SignalDirection.LONG) + await telegram._force_enter(update=update, context=context, order_side=SignalDirection.LONG) assert fbuy_mock.call_count == 0 assert msg_mock.call_count == 1 @@ -1286,13 +1358,13 @@ def test_force_enter_no_pair(default_conf, update, mocker) -> None: # One additional button - cancel assert reduce(lambda acc, x: acc + len(x), keyboard, 0) == 5 update = MagicMock() - update.callback_query = MagicMock() + update.callback_query = AsyncMock() update.callback_query.data = 'XRP/USDT_||_long' - telegram._force_enter_inline(update, None) + await telegram._force_enter_inline(update, None) assert fbuy_mock.call_count == 1 -def test_telegram_performance_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: +async def test_telegram_performance_handle(default_conf_usdt, update, ticker, fee, mocker) -> None: mocker.patch.multiple( EXMS, @@ -1304,13 +1376,13 @@ def test_telegram_performance_handle(default_conf_usdt, update, ticker, fee, moc # Create some test data create_mock_trades_usdt(fee) - telegram._performance(update=update, context=MagicMock()) + await telegram._performance(update=update, context=MagicMock()) assert msg_mock.call_count == 1 assert 'Performance' in msg_mock.call_args_list[0][0][0] assert 'XRP/USDT\t2.842 USDT (10.00%) (1)' in msg_mock.call_args_list[0][0][0] -def test_telegram_entry_tag_performance_handle( +async def test_telegram_entry_tag_performance_handle( default_conf_usdt, update, ticker, fee, mocker) -> None: mocker.patch.multiple( EXMS, @@ -1323,26 +1395,26 @@ def test_telegram_entry_tag_performance_handle( create_mock_trades_usdt(fee) context = MagicMock() - telegram._enter_tag_performance(update=update, context=context) + await telegram._enter_tag_performance(update=update, context=context) assert msg_mock.call_count == 1 assert 'Entry Tag Performance' in msg_mock.call_args_list[0][0][0] assert 'TEST1\t3.987 USDT (5.00%) (1)' in msg_mock.call_args_list[0][0][0] context.args = ['XRP/USDT'] - telegram._enter_tag_performance(update=update, context=context) + await telegram._enter_tag_performance(update=update, context=context) assert msg_mock.call_count == 2 msg_mock.reset_mock() mocker.patch('freqtrade.rpc.rpc.RPC._rpc_enter_tag_performance', side_effect=RPCException('Error')) - telegram._enter_tag_performance(update=update, context=MagicMock()) + await telegram._enter_tag_performance(update=update, context=MagicMock()) assert msg_mock.call_count == 1 assert "Error" in msg_mock.call_args_list[0][0][0] -def test_telegram_exit_reason_performance_handle(default_conf_usdt, update, ticker, fee, - mocker) -> None: +async def test_telegram_exit_reason_performance_handle( + default_conf_usdt, update, ticker, fee, mocker) -> None: mocker.patch.multiple( EXMS, fetch_ticker=ticker, @@ -1354,26 +1426,26 @@ def test_telegram_exit_reason_performance_handle(default_conf_usdt, update, tick create_mock_trades_usdt(fee) context = MagicMock() - telegram._exit_reason_performance(update=update, context=context) + await telegram._exit_reason_performance(update=update, context=context) assert msg_mock.call_count == 1 assert 'Exit Reason Performance' in msg_mock.call_args_list[0][0][0] assert 'roi\t2.842 USDT (10.00%) (1)' in msg_mock.call_args_list[0][0][0] context.args = ['XRP/USDT'] - telegram._exit_reason_performance(update=update, context=context) + await telegram._exit_reason_performance(update=update, context=context) assert msg_mock.call_count == 2 msg_mock.reset_mock() mocker.patch('freqtrade.rpc.rpc.RPC._rpc_exit_reason_performance', side_effect=RPCException('Error')) - telegram._exit_reason_performance(update=update, context=MagicMock()) + await telegram._exit_reason_performance(update=update, context=MagicMock()) assert msg_mock.call_count == 1 assert "Error" in msg_mock.call_args_list[0][0][0] -def test_telegram_mix_tag_performance_handle(default_conf_usdt, update, ticker, fee, - mocker) -> None: +async def test_telegram_mix_tag_performance_handle(default_conf_usdt, update, ticker, fee, + mocker) -> None: mocker.patch.multiple( EXMS, fetch_ticker=ticker, @@ -1386,26 +1458,26 @@ def test_telegram_mix_tag_performance_handle(default_conf_usdt, update, ticker, create_mock_trades_usdt(fee) context = MagicMock() - telegram._mix_tag_performance(update=update, context=context) + await telegram._mix_tag_performance(update=update, context=context) assert msg_mock.call_count == 1 assert 'Mix Tag Performance' in msg_mock.call_args_list[0][0][0] assert ('TEST3 roi\t2.842 USDT (10.00%) (1)' in msg_mock.call_args_list[0][0][0]) context.args = ['XRP/USDT'] - telegram._mix_tag_performance(update=update, context=context) + await telegram._mix_tag_performance(update=update, context=context) assert msg_mock.call_count == 2 msg_mock.reset_mock() mocker.patch('freqtrade.rpc.rpc.RPC._rpc_mix_tag_performance', side_effect=RPCException('Error')) - telegram._mix_tag_performance(update=update, context=MagicMock()) + await telegram._mix_tag_performance(update=update, context=MagicMock()) assert msg_mock.call_count == 1 assert "Error" in msg_mock.call_args_list[0][0][0] -def test_count_handle(default_conf, update, ticker, fee, mocker) -> None: +async def test_count_handle(default_conf, update, ticker, fee, mocker) -> None: mocker.patch.multiple( EXMS, fetch_ticker=ticker, @@ -1415,7 +1487,7 @@ def test_count_handle(default_conf, update, ticker, fee, mocker) -> None: patch_get_signal(freqtradebot) freqtradebot.state = State.STOPPED - telegram._count(update=update, context=MagicMock()) + await telegram._count(update=update, context=MagicMock()) assert msg_mock.call_count == 1 assert 'not running' in msg_mock.call_args_list[0][0][0] msg_mock.reset_mock() @@ -1424,7 +1496,7 @@ def test_count_handle(default_conf, update, ticker, fee, mocker) -> None: # Create some test data freqtradebot.enter_positions() msg_mock.reset_mock() - telegram._count(update=update, context=MagicMock()) + await telegram._count(update=update, context=MagicMock()) msg = ('
  current    max    total stake\n---------  -----  -------------\n'
            '        1      {}          {}
').format( @@ -1434,7 +1506,7 @@ def test_count_handle(default_conf, update, ticker, fee, mocker) -> None: assert msg in msg_mock.call_args_list[0][0][0] -def test_telegram_lock_handle(default_conf, update, ticker, fee, mocker) -> None: +async def test_telegram_lock_handle(default_conf, update, ticker, fee, mocker) -> None: mocker.patch.multiple( EXMS, fetch_ticker=ticker, @@ -1442,16 +1514,16 @@ def test_telegram_lock_handle(default_conf, update, ticker, fee, mocker) -> None ) telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) patch_get_signal(freqtradebot) - telegram._locks(update=update, context=MagicMock()) + await telegram._locks(update=update, context=MagicMock()) assert msg_mock.call_count == 1 assert 'No active locks.' in msg_mock.call_args_list[0][0][0] msg_mock.reset_mock() - PairLocks.lock_pair('ETH/BTC', arrow.utcnow().shift(minutes=4).datetime, 'randreason') - PairLocks.lock_pair('XRP/BTC', arrow.utcnow().shift(minutes=20).datetime, 'deadbeef') + PairLocks.lock_pair('ETH/BTC', dt_now() + timedelta(minutes=4), 'randreason') + PairLocks.lock_pair('XRP/BTC', dt_now() + timedelta(minutes=20), 'deadbeef') - telegram._locks(update=update, context=MagicMock()) + await telegram._locks(update=update, context=MagicMock()) assert 'Pair' in msg_mock.call_args_list[0][0][0] assert 'Until' in msg_mock.call_args_list[0][0][0] @@ -1464,7 +1536,7 @@ def test_telegram_lock_handle(default_conf, update, ticker, fee, mocker) -> None context = MagicMock() context.args = ['XRP/BTC'] msg_mock.reset_mock() - telegram._delete_locks(update=update, context=context) + await telegram._delete_locks(update=update, context=context) assert 'ETH/BTC' in msg_mock.call_args_list[0][0][0] assert 'randreason' in msg_mock.call_args_list[0][0][0] @@ -1472,11 +1544,11 @@ def test_telegram_lock_handle(default_conf, update, ticker, fee, mocker) -> None assert 'deadbeef' not in msg_mock.call_args_list[0][0][0] -def test_whitelist_static(default_conf, update, mocker) -> None: +async def test_whitelist_static(default_conf, update, mocker) -> None: telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) - telegram._whitelist(update=update, context=MagicMock()) + await telegram._whitelist(update=update, context=MagicMock()) assert msg_mock.call_count == 1 assert ("Using whitelist `['StaticPairList']` with 4 pairs\n" "`ETH/BTC, LTC/BTC, XRP/BTC, NEO/BTC`" in msg_mock.call_args_list[0][0][0]) @@ -1484,33 +1556,33 @@ def test_whitelist_static(default_conf, update, mocker) -> None: context = MagicMock() context.args = ['sorted'] msg_mock.reset_mock() - telegram._whitelist(update=update, context=context) + await telegram._whitelist(update=update, context=context) assert ("Using whitelist `['StaticPairList']` with 4 pairs\n" "`ETH/BTC, LTC/BTC, NEO/BTC, XRP/BTC`" in msg_mock.call_args_list[0][0][0]) context = MagicMock() context.args = ['baseonly'] msg_mock.reset_mock() - telegram._whitelist(update=update, context=context) + await telegram._whitelist(update=update, context=context) assert ("Using whitelist `['StaticPairList']` with 4 pairs\n" "`ETH, LTC, XRP, NEO`" in msg_mock.call_args_list[0][0][0]) context = MagicMock() context.args = ['baseonly', 'sorted'] msg_mock.reset_mock() - telegram._whitelist(update=update, context=context) + await telegram._whitelist(update=update, context=context) assert ("Using whitelist `['StaticPairList']` with 4 pairs\n" "`ETH, LTC, NEO, XRP`" in msg_mock.call_args_list[0][0][0]) -def test_whitelist_dynamic(default_conf, update, mocker) -> None: +async def test_whitelist_dynamic(default_conf, update, mocker) -> None: mocker.patch(f'{EXMS}.exchange_has', return_value=True) default_conf['pairlists'] = [{'method': 'VolumePairList', 'number_assets': 4 }] telegram, _, msg_mock = get_telegram_testobject(mocker, default_conf) - telegram._whitelist(update=update, context=MagicMock()) + await telegram._whitelist(update=update, context=MagicMock()) assert msg_mock.call_count == 1 assert ("Using whitelist `['VolumePairList']` with 4 pairs\n" "`ETH/BTC, LTC/BTC, XRP/BTC, NEO/BTC`" in msg_mock.call_args_list[0][0][0]) @@ -1518,30 +1590,30 @@ def test_whitelist_dynamic(default_conf, update, mocker) -> None: context = MagicMock() context.args = ['sorted'] msg_mock.reset_mock() - telegram._whitelist(update=update, context=context) + await telegram._whitelist(update=update, context=context) assert ("Using whitelist `['VolumePairList']` with 4 pairs\n" "`ETH/BTC, LTC/BTC, NEO/BTC, XRP/BTC`" in msg_mock.call_args_list[0][0][0]) context = MagicMock() context.args = ['baseonly'] msg_mock.reset_mock() - telegram._whitelist(update=update, context=context) + await telegram._whitelist(update=update, context=context) assert ("Using whitelist `['VolumePairList']` with 4 pairs\n" "`ETH, LTC, XRP, NEO`" in msg_mock.call_args_list[0][0][0]) context = MagicMock() context.args = ['baseonly', 'sorted'] msg_mock.reset_mock() - telegram._whitelist(update=update, context=context) + await telegram._whitelist(update=update, context=context) assert ("Using whitelist `['VolumePairList']` with 4 pairs\n" "`ETH, LTC, NEO, XRP`" in msg_mock.call_args_list[0][0][0]) -def test_blacklist_static(default_conf, update, mocker) -> None: +async def test_blacklist_static(default_conf, update, mocker) -> None: telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) - telegram._blacklist(update=update, context=MagicMock()) + await telegram._blacklist(update=update, context=MagicMock()) assert msg_mock.call_count == 1 assert ("Blacklist contains 2 pairs\n`DOGE/BTC, HOT/BTC`" in msg_mock.call_args_list[0][0][0]) @@ -1551,7 +1623,7 @@ def test_blacklist_static(default_conf, update, mocker) -> None: # /blacklist ETH/BTC context = MagicMock() context.args = ["ETH/BTC"] - telegram._blacklist(update=update, context=context) + await telegram._blacklist(update=update, context=context) assert msg_mock.call_count == 1 assert ("Blacklist contains 3 pairs\n`DOGE/BTC, HOT/BTC, ETH/BTC`" in msg_mock.call_args_list[0][0][0]) @@ -1560,7 +1632,7 @@ def test_blacklist_static(default_conf, update, mocker) -> None: msg_mock.reset_mock() context = MagicMock() context.args = ["XRP/.*"] - telegram._blacklist(update=update, context=context) + await telegram._blacklist(update=update, context=context) assert msg_mock.call_count == 1 assert ("Blacklist contains 4 pairs\n`DOGE/BTC, HOT/BTC, ETH/BTC, XRP/.*`" @@ -1569,13 +1641,13 @@ def test_blacklist_static(default_conf, update, mocker) -> None: msg_mock.reset_mock() context.args = ["DOGE/BTC"] - telegram._blacklist_delete(update=update, context=context) + await telegram._blacklist_delete(update=update, context=context) assert msg_mock.call_count == 1 assert ("Blacklist contains 3 pairs\n`HOT/BTC, ETH/BTC, XRP/.*`" in msg_mock.call_args_list[0][0][0]) -def test_telegram_logs(default_conf, update, mocker) -> None: +async def test_telegram_logs(default_conf, update, mocker) -> None: mocker.patch.multiple( 'freqtrade.rpc.telegram.Telegram', _init=MagicMock(), @@ -1586,13 +1658,13 @@ def test_telegram_logs(default_conf, update, mocker) -> None: context = MagicMock() context.args = [] - telegram._logs(update=update, context=context) + await telegram._logs(update=update, context=context) assert msg_mock.call_count == 1 assert "freqtrade\\.rpc\\.telegram" in msg_mock.call_args_list[0][0][0] msg_mock.reset_mock() context.args = ["1"] - telegram._logs(update=update, context=context) + await telegram._logs(update=update, context=context) assert msg_mock.call_count == 1 msg_mock.reset_mock() @@ -1600,22 +1672,22 @@ def test_telegram_logs(default_conf, update, mocker) -> None: mocker.patch('freqtrade.rpc.telegram.MAX_MESSAGE_LENGTH', 200) context = MagicMock() context.args = [] - telegram._logs(update=update, context=context) + await telegram._logs(update=update, context=context) # Called at least 2 times. Exact times will change with unrelated changes to setup messages # Therefore we don't test for this explicitly. assert msg_mock.call_count >= 2 -def test_edge_disabled(default_conf, update, mocker) -> None: +async def test_edge_disabled(default_conf, update, mocker) -> None: telegram, _, msg_mock = get_telegram_testobject(mocker, default_conf) - telegram._edge(update=update, context=MagicMock()) + await telegram._edge(update=update, context=MagicMock()) assert msg_mock.call_count == 1 assert "Edge is not enabled." in msg_mock.call_args_list[0][0][0] -def test_edge_enabled(edge_conf, update, mocker) -> None: +async def test_edge_enabled(edge_conf, update, mocker) -> None: mocker.patch('freqtrade.edge.Edge._cached_pairs', mocker.PropertyMock( return_value={ 'E/F': PairInfo(-0.01, 0.66, 3.71, 0.50, 1.71, 10, 60), @@ -1624,7 +1696,7 @@ def test_edge_enabled(edge_conf, update, mocker) -> None: telegram, _, msg_mock = get_telegram_testobject(mocker, edge_conf) - telegram._edge(update=update, context=MagicMock()) + await telegram._edge(update=update, context=MagicMock()) assert msg_mock.call_count == 1 assert 'Edge only validated following pairs:\n
' in msg_mock.call_args_list[0][0][0]
     assert 'Pair      Winrate    Expectancy    Stoploss' in msg_mock.call_args_list[0][0][0]
@@ -1633,7 +1705,7 @@ def test_edge_enabled(edge_conf, update, mocker) -> None:
 
     mocker.patch('freqtrade.edge.Edge._cached_pairs', mocker.PropertyMock(
         return_value={}))
-    telegram._edge(update=update, context=MagicMock())
+    await telegram._edge(update=update, context=MagicMock())
     assert msg_mock.call_count == 1
     assert 'Edge only validated following pairs:' in msg_mock.call_args_list[0][0][0]
     assert 'Winrate' not in msg_mock.call_args_list[0][0][0]
@@ -1642,20 +1714,20 @@ def test_edge_enabled(edge_conf, update, mocker) -> None:
 @pytest.mark.parametrize('is_short,regex_pattern',
                          [(True, r"just now[ ]*XRP\/BTC \(#3\)  -1.00% \("),
                           (False, r"just now[ ]*XRP\/BTC \(#3\)  1.00% \(")])
-def test_telegram_trades(mocker, update, default_conf, fee, is_short, regex_pattern):
+async def test_telegram_trades(mocker, update, default_conf, fee, is_short, regex_pattern):
 
     telegram, _, msg_mock = get_telegram_testobject(mocker, default_conf)
 
     context = MagicMock()
     context.args = []
 
-    telegram._trades(update=update, context=context)
+    await telegram._trades(update=update, context=context)
     assert "0 recent trades:" in msg_mock.call_args_list[0][0][0]
     assert "
" not in msg_mock.call_args_list[0][0][0]
     msg_mock.reset_mock()
 
     context.args = ['hello']
-    telegram._trades(update=update, context=context)
+    await telegram._trades(update=update, context=context)
     assert "0 recent trades:" in msg_mock.call_args_list[0][0][0]
     assert "
" not in msg_mock.call_args_list[0][0][0]
     msg_mock.reset_mock()
@@ -1664,7 +1736,7 @@ def test_telegram_trades(mocker, update, default_conf, fee, is_short, regex_patt
 
     context = MagicMock()
     context.args = [5]
-    telegram._trades(update=update, context=context)
+    await telegram._trades(update=update, context=context)
     msg_mock.call_count == 1
     assert "2 recent trades:" in msg_mock.call_args_list[0][0][0]
     assert "Profit (" in msg_mock.call_args_list[0][0][0]
@@ -1674,13 +1746,13 @@ def test_telegram_trades(mocker, update, default_conf, fee, is_short, regex_patt
 
 
 @pytest.mark.parametrize('is_short', [True, False])
-def test_telegram_delete_trade(mocker, update, default_conf, fee, is_short):
+async def test_telegram_delete_trade(mocker, update, default_conf, fee, is_short):
 
     telegram, _, msg_mock = get_telegram_testobject(mocker, default_conf)
     context = MagicMock()
     context.args = []
 
-    telegram._delete_trade(update=update, context=context)
+    await telegram._delete_trade(update=update, context=context)
     assert "Trade-id not set." in msg_mock.call_args_list[0][0][0]
 
     msg_mock.reset_mock()
@@ -1688,14 +1760,33 @@ def test_telegram_delete_trade(mocker, update, default_conf, fee, is_short):
 
     context = MagicMock()
     context.args = [1]
-    telegram._delete_trade(update=update, context=context)
+    await telegram._delete_trade(update=update, context=context)
     msg_mock.call_count == 1
     assert "Deleted trade 1." in msg_mock.call_args_list[0][0][0]
     assert "Please make sure to take care of this asset" in msg_mock.call_args_list[0][0][0]
 
 
 @pytest.mark.parametrize('is_short', [True, False])
-def test_telegram_delete_open_order(mocker, update, default_conf, fee, is_short, ticker):
+async def test_telegram_reload_trade_from_exchange(mocker, update, default_conf, fee, is_short):
+
+    telegram, _, msg_mock = get_telegram_testobject(mocker, default_conf)
+    context = MagicMock()
+    context.args = []
+
+    await telegram._reload_trade_from_exchange(update=update, context=context)
+    assert "Trade-id not set." in msg_mock.call_args_list[0][0][0]
+
+    msg_mock.reset_mock()
+    create_mock_trades(fee, is_short=is_short)
+
+    context.args = [5]
+
+    await telegram._reload_trade_from_exchange(update=update, context=context)
+    assert "Status: `Reloaded from orders from exchange`" in msg_mock.call_args_list[0][0][0]
+
+
+@pytest.mark.parametrize('is_short', [True, False])
+async def test_telegram_delete_open_order(mocker, update, default_conf, fee, is_short, ticker):
 
     mocker.patch.multiple(
         EXMS,
@@ -1705,7 +1796,7 @@ def test_telegram_delete_open_order(mocker, update, default_conf, fee, is_short,
     context = MagicMock()
     context.args = []
 
-    telegram._cancel_open_order(update=update, context=context)
+    await telegram._cancel_open_order(update=update, context=context)
     assert "Trade-id not set." in msg_mock.call_args_list[0][0][0]
 
     msg_mock.reset_mock()
@@ -1713,7 +1804,7 @@ def test_telegram_delete_open_order(mocker, update, default_conf, fee, is_short,
 
     context = MagicMock()
     context.args = [5]
-    telegram._cancel_open_order(update=update, context=context)
+    await telegram._cancel_open_order(update=update, context=context)
     assert "No open order for trade_id" in msg_mock.call_args_list[0][0][0]
 
     msg_mock.reset_mock()
@@ -1722,43 +1813,43 @@ def test_telegram_delete_open_order(mocker, update, default_conf, fee, is_short,
     mocker.patch(f'{EXMS}.fetch_order', return_value=trade.orders[-1].to_ccxt_object())
     context = MagicMock()
     context.args = [6]
-    telegram._cancel_open_order(update=update, context=context)
+    await telegram._cancel_open_order(update=update, context=context)
     assert msg_mock.call_count == 1
     assert "Open order canceled." in msg_mock.call_args_list[0][0][0]
 
 
-def test_help_handle(default_conf, update, mocker) -> None:
+async def test_help_handle(default_conf, update, mocker) -> None:
     telegram, _, msg_mock = get_telegram_testobject(mocker, default_conf)
 
-    telegram._help(update=update, context=MagicMock())
+    await telegram._help(update=update, context=MagicMock())
     assert msg_mock.call_count == 1
     assert '*/help:* `This help message`' in msg_mock.call_args_list[0][0][0]
 
 
-def test_version_handle(default_conf, update, mocker) -> None:
+async def test_version_handle(default_conf, update, mocker) -> None:
 
     telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf)
 
-    telegram._version(update=update, context=MagicMock())
+    await telegram._version(update=update, context=MagicMock())
     assert msg_mock.call_count == 1
     assert f'*Version:* `{__version__}`' in msg_mock.call_args_list[0][0][0]
 
     msg_mock.reset_mock()
     freqtradebot.strategy.version = lambda: '1.1.1'
 
-    telegram._version(update=update, context=MagicMock())
+    await telegram._version(update=update, context=MagicMock())
     assert msg_mock.call_count == 1
     assert f'*Version:* `{__version__}`' in msg_mock.call_args_list[0][0][0]
     assert '*Strategy version: * `1.1.1`' in msg_mock.call_args_list[0][0][0]
 
 
-def test_show_config_handle(default_conf, update, mocker) -> None:
+async def test_show_config_handle(default_conf, update, mocker) -> None:
 
     default_conf['runmode'] = RunMode.DRY_RUN
 
     telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf)
 
-    telegram._show_config(update=update, context=MagicMock())
+    await telegram._show_config(update=update, context=MagicMock())
     assert msg_mock.call_count == 1
     assert '*Mode:* `{}`'.format('Dry-run') in msg_mock.call_args_list[0][0][0]
     assert '*Exchange:* `binance`' in msg_mock.call_args_list[0][0][0]
@@ -1767,7 +1858,7 @@ def test_show_config_handle(default_conf, update, mocker) -> None:
 
     msg_mock.reset_mock()
     freqtradebot.config['trailing_stop'] = True
-    telegram._show_config(update=update, context=MagicMock())
+    await telegram._show_config(update=update, context=MagicMock())
     assert msg_mock.call_count == 1
     assert '*Mode:* `{}`'.format('Dry-run') in msg_mock.call_args_list[0][0][0]
     assert '*Exchange:* `binance`' in msg_mock.call_args_list[0][0][0]
@@ -1809,7 +1900,7 @@ def test_send_msg_enter_notification(default_conf, mocker, caplog, message_type,
         'current_rate': 1.099e-05,
         'amount': 1333.3333333333335,
         'analyzed_candle': {'open': 1.1, 'high': 2.2, 'low': 1.0, 'close': 1.5},
-        'open_date': arrow.utcnow().shift(hours=-1)
+        'open_date': dt_now() + timedelta(hours=-1)
     }
     telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf)
 
@@ -1870,7 +1961,7 @@ def test_send_msg_protection_notification(default_conf, mocker, time_machine) ->
 
     telegram, _, msg_mock = get_telegram_testobject(mocker, default_conf)
     time_machine.move_to("2021-09-01 05:00:00 +00:00")
-    lock = PairLocks.lock_pair('ETH/BTC', arrow.utcnow().shift(minutes=6).datetime, 'randreason')
+    lock = PairLocks.lock_pair('ETH/BTC', dt_now() + timedelta(minutes=6), 'randreason')
     msg = {
         'type': RPCMessageType.PROTECTION_TRIGGER,
     }
@@ -1885,7 +1976,7 @@ def test_send_msg_protection_notification(default_conf, mocker, time_machine) ->
     msg = {
         'type': RPCMessageType.PROTECTION_TRIGGER_GLOBAL,
     }
-    lock = PairLocks.lock_pair('*', arrow.utcnow().shift(minutes=100).datetime, 'randreason')
+    lock = PairLocks.lock_pair('*', dt_now() + timedelta(minutes=100), 'randreason')
     msg.update(lock.to_json())
     telegram.send_msg(msg)
     assert (msg_mock.call_args[0][0] == "*Protection* triggered due to randreason. "
@@ -1916,7 +2007,7 @@ def test_send_msg_entry_fill_notification(default_conf, mocker, message_type, en
         'fiat_currency': 'USD',
         'open_rate': 1.099e-05,
         'amount': 1333.3333333333335,
-        'open_date': arrow.utcnow().shift(hours=-1)
+        'open_date': dt_now() - timedelta(hours=1)
     })
     leverage_text = f'*Leverage:* `{leverage}`\n' if leverage != 1.0 else ''
     assert msg_mock.call_args[0][0] == (
@@ -1943,7 +2034,7 @@ def test_send_msg_entry_fill_notification(default_conf, mocker, message_type, en
         'fiat_currency': 'USD',
         'open_rate': 1.099e-05,
         'amount': 1333.3333333333335,
-        'open_date': arrow.utcnow().shift(hours=-1)
+        'open_date': dt_now() - timedelta(hours=1)
     })
 
     assert msg_mock.call_args[0][0] == (
@@ -1982,8 +2073,8 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None:
             'fiat_currency': 'USD',
             'enter_tag': 'buy_signal1',
             'exit_reason': ExitType.STOP_LOSS.value,
-            'open_date': arrow.utcnow().shift(hours=-1),
-            'close_date': arrow.utcnow(),
+            'open_date': dt_now() - timedelta(hours=1),
+            'close_date': dt_now(),
         })
         assert msg_mock.call_args[0][0] == (
             '\N{WARNING SIGN} *Binance (dry):* Exiting KEY/ETH (#1)\n'
@@ -2018,8 +2109,8 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None:
             'fiat_currency': 'USD',
             'enter_tag': 'buy_signal1',
             'exit_reason': ExitType.STOP_LOSS.value,
-            'open_date': arrow.utcnow().shift(days=-1, hours=-2, minutes=-30),
-            'close_date': arrow.utcnow(),
+            'open_date': dt_now() - timedelta(days=1, hours=2, minutes=30),
+            'close_date': dt_now(),
             'stake_amount': 0.01,
             'sub_trade': True,
         })
@@ -2055,8 +2146,8 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None:
             'stake_currency': 'ETH',
             'enter_tag': 'buy_signal1',
             'exit_reason': ExitType.STOP_LOSS.value,
-            'open_date': arrow.utcnow().shift(days=-1, hours=-2, minutes=-30),
-            'close_date': arrow.utcnow(),
+            'open_date': dt_now() - timedelta(days=1, hours=2, minutes=30),
+            'close_date': dt_now(),
         })
         assert msg_mock.call_args[0][0] == (
             '\N{WARNING SIGN} *Binance (dry):* Exiting KEY/ETH (#1)\n'
@@ -2074,7 +2165,7 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None:
         telegram._rpc._fiat_converter.convert_amount = old_convamount
 
 
-def test_send_msg_sell_cancel_notification(default_conf, mocker) -> None:
+async def test_send_msg_sell_cancel_notification(default_conf, mocker) -> None:
 
     telegram, _, msg_mock = get_telegram_testobject(mocker, default_conf)
 
@@ -2137,8 +2228,8 @@ def test_send_msg_sell_fill_notification(default_conf, mocker, direction,
             'stake_currency': 'ETH',
             'enter_tag': enter_signal,
             'exit_reason': ExitType.STOP_LOSS.value,
-            'open_date': arrow.utcnow().shift(days=-1, hours=-2, minutes=-30),
-            'close_date': arrow.utcnow(),
+            'open_date': dt_now() - timedelta(days=1, hours=2, minutes=30),
+            'close_date': dt_now(),
         })
 
         leverage_text = f'*Leverage:* `{leverage}`\n' if leverage and leverage != 1.0 else ''
@@ -2166,7 +2257,7 @@ def test_send_msg_status_notification(default_conf, mocker) -> None:
     assert msg_mock.call_args[0][0] == '*Status:* `running`'
 
 
-def test_warning_notification(default_conf, mocker) -> None:
+async def test_warning_notification(default_conf, mocker) -> None:
     telegram, _, msg_mock = get_telegram_testobject(mocker, default_conf)
     telegram.send_msg({
         'type': RPCMessageType.WARNING,
@@ -2228,7 +2319,7 @@ def test_send_msg_buy_notification_no_fiat(
         'fiat_currency': None,
         'current_rate': 1.099e-05,
         'amount': 1333.3333333333335,
-        'open_date': arrow.utcnow().shift(hours=-1)
+        'open_date': dt_now() - timedelta(hours=1)
     })
 
     leverage_text = f'*Leverage:* `{leverage}`\n' if leverage and leverage != 1.0 else ''
@@ -2274,8 +2365,8 @@ def test_send_msg_sell_notification_no_fiat(
         'fiat_currency': 'USD',
         'enter_tag': enter_signal,
         'exit_reason': ExitType.STOP_LOSS.value,
-        'open_date': arrow.utcnow().shift(hours=-2, minutes=-35, seconds=-3),
-        'close_date': arrow.utcnow(),
+        'open_date': dt_now() - timedelta(hours=2, minutes=35, seconds=3),
+        'close_date': dt_now(),
     })
 
     leverage_text = f'*Leverage:* `{leverage}`\n' if leverage and leverage != 1.0 else ''
@@ -2311,60 +2402,62 @@ def test__sell_emoji(default_conf, mocker, msg, expected):
     assert telegram._get_sell_emoji(msg) == expected
 
 
-def test_telegram__send_msg(default_conf, mocker, caplog) -> None:
+async def test_telegram__send_msg(default_conf, mocker, caplog) -> None:
     mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock())
     bot = MagicMock()
+    bot.send_message = AsyncMock()
+    bot.edit_message_text = AsyncMock()
     telegram, _, _ = get_telegram_testobject(mocker, default_conf, mock=False)
-    telegram._updater = MagicMock()
-    telegram._updater.bot = bot
+    telegram._app = MagicMock()
+    telegram._app.bot = bot
 
-    telegram._config['telegram']['enabled'] = True
-    telegram._send_msg('test')
+    await telegram._send_msg('test')
     assert len(bot.method_calls) == 1
 
     # Test update
     query = MagicMock()
-    telegram._send_msg('test', callback_path="DeadBeef", query=query, reload_able=True)
-    edit_message_text = telegram._updater.bot.edit_message_text
+    await telegram._send_msg('test', callback_path="DeadBeef", query=query, reload_able=True)
+    edit_message_text = telegram._app.bot.edit_message_text
     assert edit_message_text.call_count == 1
     assert "Updated: " in edit_message_text.call_args_list[0][1]['text']
 
-    telegram._updater.bot.edit_message_text = MagicMock(side_effect=BadRequest("not modified"))
-    telegram._send_msg('test', callback_path="DeadBeef", query=query)
-    assert telegram._updater.bot.edit_message_text.call_count == 1
+    telegram._app.bot.edit_message_text = AsyncMock(side_effect=BadRequest("not modified"))
+    await telegram._send_msg('test', callback_path="DeadBeef", query=query)
+    assert telegram._app.bot.edit_message_text.call_count == 1
     assert not log_has_re(r"TelegramError: .*", caplog)
 
-    telegram._updater.bot.edit_message_text = MagicMock(side_effect=BadRequest(""))
-    telegram._send_msg('test2', callback_path="DeadBeef", query=query)
-    assert telegram._updater.bot.edit_message_text.call_count == 1
+    telegram._app.bot.edit_message_text = AsyncMock(side_effect=BadRequest(""))
+    await telegram._send_msg('test2', callback_path="DeadBeef", query=query)
+    assert telegram._app.bot.edit_message_text.call_count == 1
     assert log_has_re(r"TelegramError: .*", caplog)
 
-    telegram._updater.bot.edit_message_text = MagicMock(side_effect=TelegramError("DeadBEEF"))
-    telegram._send_msg('test3', callback_path="DeadBeef", query=query)
+    telegram._app.bot.edit_message_text = AsyncMock(side_effect=TelegramError("DeadBEEF"))
+    await telegram._send_msg('test3', callback_path="DeadBeef", query=query)
 
     assert log_has_re(r"TelegramError: DeadBEEF! Giving up.*", caplog)
 
 
-def test__send_msg_network_error(default_conf, mocker, caplog) -> None:
+async def test__send_msg_network_error(default_conf, mocker, caplog) -> None:
     mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock())
     bot = MagicMock()
     bot.send_message = MagicMock(side_effect=NetworkError('Oh snap'))
     telegram, _, _ = get_telegram_testobject(mocker, default_conf, mock=False)
-    telegram._updater = MagicMock()
-    telegram._updater.bot = bot
+    telegram._app = MagicMock()
+    telegram._app.bot = bot
 
     telegram._config['telegram']['enabled'] = True
-    telegram._send_msg('test')
+    await telegram._send_msg('test')
 
     # Bot should've tried to send it twice
     assert len(bot.method_calls) == 2
     assert log_has('Telegram NetworkError: Oh snap! Trying one more time.', caplog)
 
 
-def test__send_msg_keyboard(default_conf, mocker, caplog) -> None:
+@pytest.mark.filterwarnings("ignore:.*ChatPermissions")
+async def test__send_msg_keyboard(default_conf, mocker, caplog) -> None:
     mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock())
     bot = MagicMock()
-    bot.send_message = MagicMock()
+    bot.send_message = AsyncMock()
     freqtradebot = get_patched_freqtradebot(mocker, default_conf)
     rpc = RPC(freqtradebot)
 
@@ -2380,14 +2473,14 @@ def test__send_msg_keyboard(default_conf, mocker, caplog) -> None:
 
     def init_telegram(freqtradebot):
         telegram = Telegram(rpc, default_conf)
-        telegram._updater = MagicMock()
-        telegram._updater.bot = bot
+        telegram._app = MagicMock()
+        telegram._app.bot = bot
         return telegram
 
     # no keyboard in config -> default keyboard
     freqtradebot.config['telegram']['enabled'] = True
     telegram = init_telegram(freqtradebot)
-    telegram._send_msg('test')
+    await telegram._send_msg('test')
     used_keyboard = bot.send_message.call_args[1]['reply_markup']
     assert used_keyboard == default_keyboard
 
@@ -2404,7 +2497,7 @@ def test__send_msg_keyboard(default_conf, mocker, caplog) -> None:
     freqtradebot.config['telegram']['enabled'] = True
     freqtradebot.config['telegram']['keyboard'] = custom_keys_list
     telegram = init_telegram(freqtradebot)
-    telegram._send_msg('test')
+    await telegram._send_msg('test')
     used_keyboard = bot.send_message.call_args[1]['reply_markup']
     assert used_keyboard == custom_keyboard
     assert log_has("using custom keyboard from config.json: "
@@ -2412,13 +2505,14 @@ def test__send_msg_keyboard(default_conf, mocker, caplog) -> None:
                    "'/start', '/reload_config', '/help']]", caplog)
 
 
-def test_change_market_direction(default_conf, mocker, update) -> None:
+async def test_change_market_direction(default_conf, mocker, update) -> None:
     telegram, _, msg_mock = get_telegram_testobject(mocker, default_conf)
     assert telegram._rpc._freqtrade.strategy.market_direction == MarketDirection.NONE
     context = MagicMock()
     context.args = ["long"]
-    telegram._changemarketdir(update, context)
+    await telegram._changemarketdir(update, context)
     assert telegram._rpc._freqtrade.strategy.market_direction == MarketDirection.LONG
     context = MagicMock()
     context.args = ["invalid"]
+    await telegram._changemarketdir(update, context)
     assert telegram._rpc._freqtrade.strategy.market_direction == MarketDirection.LONG
diff --git a/tests/rpc/test_rpc_webhook.py b/tests/rpc/test_rpc_webhook.py
index f55582107..d0a0f5b1e 100644
--- a/tests/rpc/test_rpc_webhook.py
+++ b/tests/rpc/test_rpc_webhook.py
@@ -17,6 +17,10 @@ def get_webhook_dict() -> dict:
         "enabled": True,
         "url": "https://maker.ifttt.com/trigger/freqtrade_test/with/key/c764udvJ5jfSlswVRukZZ2/",
         "webhookentry": {
+            # Intentionally broken, as "entry" should have priority.
+            "value1": "Buying {pair55555}",
+        },
+        "entry": {
             "value1": "Buying {pair}",
             "value2": "limit {limit:8f}",
             "value3": "{stake_amount:8f} {stake_currency}",
@@ -89,15 +93,15 @@ def test_send_msg_webhook(default_conf, mocker):
     webhook.send_msg(msg=msg)
     assert msg_mock.call_count == 1
     assert (msg_mock.call_args[0][0]["value1"] ==
-            default_conf["webhook"]["webhookentry"]["value1"].format(**msg))
+            default_conf["webhook"]["entry"]["value1"].format(**msg))
     assert (msg_mock.call_args[0][0]["value2"] ==
-            default_conf["webhook"]["webhookentry"]["value2"].format(**msg))
+            default_conf["webhook"]["entry"]["value2"].format(**msg))
     assert (msg_mock.call_args[0][0]["value3"] ==
-            default_conf["webhook"]["webhookentry"]["value3"].format(**msg))
+            default_conf["webhook"]["entry"]["value3"].format(**msg))
     assert (msg_mock.call_args[0][0]["value4"] ==
-            default_conf["webhook"]["webhookentry"]["value4"].format(**msg))
+            default_conf["webhook"]["entry"]["value4"].format(**msg))
     assert (msg_mock.call_args[0][0]["value5"] ==
-            default_conf["webhook"]["webhookentry"]["value5"].format(**msg))
+            default_conf["webhook"]["entry"]["value5"].format(**msg))
     # Test short
     msg_mock.reset_mock()
 
@@ -116,15 +120,15 @@ def test_send_msg_webhook(default_conf, mocker):
     webhook.send_msg(msg=msg)
     assert msg_mock.call_count == 1
     assert (msg_mock.call_args[0][0]["value1"] ==
-            default_conf["webhook"]["webhookentry"]["value1"].format(**msg))
+            default_conf["webhook"]["entry"]["value1"].format(**msg))
     assert (msg_mock.call_args[0][0]["value2"] ==
-            default_conf["webhook"]["webhookentry"]["value2"].format(**msg))
+            default_conf["webhook"]["entry"]["value2"].format(**msg))
     assert (msg_mock.call_args[0][0]["value3"] ==
-            default_conf["webhook"]["webhookentry"]["value3"].format(**msg))
+            default_conf["webhook"]["entry"]["value3"].format(**msg))
     assert (msg_mock.call_args[0][0]["value4"] ==
-            default_conf["webhook"]["webhookentry"]["value4"].format(**msg))
+            default_conf["webhook"]["entry"]["value4"].format(**msg))
     assert (msg_mock.call_args[0][0]["value5"] ==
-            default_conf["webhook"]["webhookentry"]["value5"].format(**msg))
+            default_conf["webhook"]["entry"]["value5"].format(**msg))
     # Test buy cancel
     msg_mock.reset_mock()
 
@@ -328,6 +332,7 @@ def test_send_msg_webhook(default_conf, mocker):
 
 def test_exception_send_msg(default_conf, mocker, caplog):
     default_conf["webhook"] = get_webhook_dict()
+    del default_conf["webhook"]["entry"]
     del default_conf["webhook"]["webhookentry"]
 
     webhook = Webhook(RPC(get_patched_freqtradebot(mocker, default_conf)), default_conf)
diff --git a/tests/strategy/strats/lookahead_bias/strategy_test_v3_with_lookahead_bias.py b/tests/strategy/strats/lookahead_bias/strategy_test_v3_with_lookahead_bias.py
new file mode 100644
index 000000000..e50d5d17b
--- /dev/null
+++ b/tests/strategy/strats/lookahead_bias/strategy_test_v3_with_lookahead_bias.py
@@ -0,0 +1,58 @@
+# pragma pylint: disable=missing-docstring, invalid-name, pointless-string-statement
+from pandas import DataFrame
+from technical.indicators import ichimoku
+
+from freqtrade.strategy import IStrategy
+from freqtrade.strategy.parameters import CategoricalParameter
+
+
+class strategy_test_v3_with_lookahead_bias(IStrategy):
+    INTERFACE_VERSION = 3
+
+    # Minimal ROI designed for the strategy
+    minimal_roi = {
+        "40": 0.0,
+        "30": 0.01,
+        "20": 0.02,
+        "0": 0.04
+    }
+
+    # Optimal stoploss designed for the strategy
+    stoploss = -0.10
+
+    # Optimal timeframe for the strategy
+    timeframe = '5m'
+    scenario = CategoricalParameter(['no_bias', 'bias1'], default='bias1', space="buy")
+
+    # Number of candles the strategy requires before producing valid signals
+    startup_candle_count: int = 20
+
+    def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
+        # bias is introduced here
+        if self.scenario.value != 'no_bias':
+            ichi = ichimoku(dataframe,
+                            conversion_line_period=20,
+                            base_line_periods=60,
+                            laggin_span=120,
+                            displacement=30)
+            dataframe['chikou_span'] = ichi['chikou_span']
+
+        return dataframe
+
+    def populate_entry_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
+        if self.scenario.value == 'no_bias':
+            dataframe.loc[dataframe['close'].shift(10) < dataframe['close'], 'enter_long'] = 1
+        else:
+            dataframe.loc[dataframe['close'].shift(-10) > dataframe['close'], 'enter_long'] = 1
+
+        return dataframe
+
+    def populate_exit_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
+        if self.scenario.value == 'no_bias':
+            dataframe.loc[
+                dataframe['close'].shift(10) < dataframe['close'], 'exit'] = 1
+        else:
+            dataframe.loc[
+                dataframe['close'].shift(-10) > dataframe['close'], 'exit'] = 1
+
+        return dataframe
diff --git a/tests/strategy/test_default_strategy.py b/tests/strategy/test_default_strategy.py
index cb3d61e89..5f41177eb 100644
--- a/tests/strategy/test_default_strategy.py
+++ b/tests/strategy/test_default_strategy.py
@@ -1,4 +1,4 @@
-from datetime import datetime
+from datetime import datetime, timezone
 
 import pytest
 from pandas import DataFrame
@@ -43,12 +43,12 @@ def test_strategy_test_v3(dataframe_1m, fee, is_short, side):
 
     assert strategy.confirm_trade_entry(pair='ETH/BTC', order_type='limit', amount=0.1,
                                         rate=20000, time_in_force='gtc',
-                                        current_time=datetime.utcnow(),
+                                        current_time=datetime.now(timezone.utc),
                                         side=side, entry_tag=None) is True
     assert strategy.confirm_trade_exit(pair='ETH/BTC', trade=trade, order_type='limit', amount=0.1,
                                        rate=20000, time_in_force='gtc', exit_reason='roi',
                                        sell_reason='roi',
-                                       current_time=datetime.utcnow(),
+                                       current_time=datetime.now(timezone.utc),
                                        side=side) is True
 
     assert strategy.custom_stoploss(pair='ETH/BTC', trade=trade, current_time=datetime.now(),
diff --git a/tests/strategy/test_interface.py b/tests/strategy/test_interface.py
index 204fa996d..8a609cf30 100644
--- a/tests/strategy/test_interface.py
+++ b/tests/strategy/test_interface.py
@@ -4,7 +4,6 @@ from datetime import datetime, timedelta, timezone
 from pathlib import Path
 from unittest.mock import MagicMock
 
-import arrow
 import pytest
 from pandas import DataFrame
 
@@ -22,6 +21,7 @@ from freqtrade.strategy.hyper import detect_parameters
 from freqtrade.strategy.parameters import (BaseParameter, BooleanParameter, CategoricalParameter,
                                            DecimalParameter, IntParameter, RealParameter)
 from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
+from freqtrade.util import dt_now
 from tests.conftest import (CURRENT_TEST_STRATEGY, TRADE_SIDES, create_mock_trades, log_has,
                             log_has_re)
 
@@ -34,7 +34,7 @@ _STRATEGY.dp = DataProvider({}, None, None)
 
 
 def test_returns_latest_signal(ohlcv_history):
-    ohlcv_history.loc[1, 'date'] = arrow.utcnow()
+    ohlcv_history.loc[1, 'date'] = dt_now()
     # Take a copy to correctly modify the call
     mocked_history = ohlcv_history.copy()
     mocked_history['enter_long'] = 0
@@ -159,7 +159,7 @@ def test_get_signal_exception_valueerror(mocker, caplog, ohlcv_history):
 def test_get_signal_old_dataframe(default_conf, mocker, caplog, ohlcv_history):
     # default_conf defines a 5m interval. we check interval * 2 + 5m
     # this is necessary as the last candle is removed (partial candles) by default
-    ohlcv_history.loc[1, 'date'] = arrow.utcnow().shift(minutes=-16)
+    ohlcv_history.loc[1, 'date'] = dt_now() - timedelta(minutes=16)
     # Take a copy to correctly modify the call
     mocked_history = ohlcv_history.copy()
     mocked_history['exit_long'] = 0
@@ -180,7 +180,7 @@ def test_get_signal_old_dataframe(default_conf, mocker, caplog, ohlcv_history):
 def test_get_signal_no_sell_column(default_conf, mocker, caplog, ohlcv_history):
     # default_conf defines a 5m interval. we check interval * 2 + 5m
     # this is necessary as the last candle is removed (partial candles) by default
-    ohlcv_history.loc[1, 'date'] = arrow.utcnow()
+    ohlcv_history.loc[1, 'date'] = dt_now()
     # Take a copy to correctly modify the call
     mocked_history = ohlcv_history.copy()
     # Intentionally don't set sell column
@@ -224,7 +224,7 @@ def test_ignore_expired_candle(default_conf):
 
 
 def test_assert_df_raise(mocker, caplog, ohlcv_history):
-    ohlcv_history.loc[1, 'date'] = arrow.utcnow().shift(minutes=-16)
+    ohlcv_history.loc[1, 'date'] = dt_now() - timedelta(minutes=16)
     # Take a copy to correctly modify the call
     mocked_history = ohlcv_history.copy()
     mocked_history['sell'] = 0
@@ -323,21 +323,21 @@ def test_min_roi_reached(default_conf, fee) -> None:
             pair='ETH/BTC',
             stake_amount=0.001,
             amount=5,
-            open_date=arrow.utcnow().shift(hours=-1).datetime,
+            open_date=dt_now() - timedelta(hours=1),
             fee_open=fee.return_value,
             fee_close=fee.return_value,
             exchange='binance',
             open_rate=1,
         )
 
-        assert not strategy.min_roi_reached(trade, 0.02, arrow.utcnow().shift(minutes=-56).datetime)
-        assert strategy.min_roi_reached(trade, 0.12, arrow.utcnow().shift(minutes=-56).datetime)
+        assert not strategy.min_roi_reached(trade, 0.02, dt_now() - timedelta(minutes=56))
+        assert strategy.min_roi_reached(trade, 0.12, dt_now() - timedelta(minutes=56))
 
-        assert not strategy.min_roi_reached(trade, 0.04, arrow.utcnow().shift(minutes=-39).datetime)
-        assert strategy.min_roi_reached(trade, 0.06, arrow.utcnow().shift(minutes=-39).datetime)
+        assert not strategy.min_roi_reached(trade, 0.04, dt_now() - timedelta(minutes=39))
+        assert strategy.min_roi_reached(trade, 0.06, dt_now() - timedelta(minutes=39))
 
-        assert not strategy.min_roi_reached(trade, -0.01, arrow.utcnow().shift(minutes=-1).datetime)
-        assert strategy.min_roi_reached(trade, 0.02, arrow.utcnow().shift(minutes=-1).datetime)
+        assert not strategy.min_roi_reached(trade, -0.01, dt_now() - timedelta(minutes=1))
+        assert strategy.min_roi_reached(trade, 0.02, dt_now() - timedelta(minutes=1))
 
 
 def test_min_roi_reached2(default_conf, fee) -> None:
@@ -361,25 +361,25 @@ def test_min_roi_reached2(default_conf, fee) -> None:
             pair='ETH/BTC',
             stake_amount=0.001,
             amount=5,
-            open_date=arrow.utcnow().shift(hours=-1).datetime,
+            open_date=dt_now() - timedelta(hours=1),
             fee_open=fee.return_value,
             fee_close=fee.return_value,
             exchange='binance',
             open_rate=1,
         )
 
-        assert not strategy.min_roi_reached(trade, 0.02, arrow.utcnow().shift(minutes=-56).datetime)
-        assert strategy.min_roi_reached(trade, 0.12, arrow.utcnow().shift(minutes=-56).datetime)
+        assert not strategy.min_roi_reached(trade, 0.02, dt_now() - timedelta(minutes=56))
+        assert strategy.min_roi_reached(trade, 0.12, dt_now() - timedelta(minutes=56))
 
-        assert not strategy.min_roi_reached(trade, 0.04, arrow.utcnow().shift(minutes=-39).datetime)
-        assert strategy.min_roi_reached(trade, 0.071, arrow.utcnow().shift(minutes=-39).datetime)
+        assert not strategy.min_roi_reached(trade, 0.04, dt_now() - timedelta(minutes=39))
+        assert strategy.min_roi_reached(trade, 0.071, dt_now() - timedelta(minutes=39))
 
-        assert not strategy.min_roi_reached(trade, 0.04, arrow.utcnow().shift(minutes=-26).datetime)
-        assert strategy.min_roi_reached(trade, 0.06, arrow.utcnow().shift(minutes=-26).datetime)
+        assert not strategy.min_roi_reached(trade, 0.04, dt_now() - timedelta(minutes=26))
+        assert strategy.min_roi_reached(trade, 0.06, dt_now() - timedelta(minutes=26))
 
         # Should not trigger with 20% profit since after 55 minutes only 30% is active.
-        assert not strategy.min_roi_reached(trade, 0.20, arrow.utcnow().shift(minutes=-2).datetime)
-        assert strategy.min_roi_reached(trade, 0.31, arrow.utcnow().shift(minutes=-2).datetime)
+        assert not strategy.min_roi_reached(trade, 0.20, dt_now() - timedelta(minutes=2))
+        assert strategy.min_roi_reached(trade, 0.31, dt_now() - timedelta(minutes=2))
 
 
 def test_min_roi_reached3(default_conf, fee) -> None:
@@ -395,25 +395,25 @@ def test_min_roi_reached3(default_conf, fee) -> None:
         pair='ETH/BTC',
         stake_amount=0.001,
         amount=5,
-        open_date=arrow.utcnow().shift(hours=-1).datetime,
+        open_date=dt_now() - timedelta(hours=1),
         fee_open=fee.return_value,
         fee_close=fee.return_value,
         exchange='binance',
         open_rate=1,
     )
 
-    assert not strategy.min_roi_reached(trade, 0.02, arrow.utcnow().shift(minutes=-56).datetime)
-    assert not strategy.min_roi_reached(trade, 0.12, arrow.utcnow().shift(minutes=-56).datetime)
+    assert not strategy.min_roi_reached(trade, 0.02, dt_now() - timedelta(minutes=56))
+    assert not strategy.min_roi_reached(trade, 0.12, dt_now() - timedelta(minutes=56))
 
-    assert not strategy.min_roi_reached(trade, 0.04, arrow.utcnow().shift(minutes=-39).datetime)
-    assert strategy.min_roi_reached(trade, 0.071, arrow.utcnow().shift(minutes=-39).datetime)
+    assert not strategy.min_roi_reached(trade, 0.04, dt_now() - timedelta(minutes=39))
+    assert strategy.min_roi_reached(trade, 0.071, dt_now() - timedelta(minutes=39))
 
-    assert not strategy.min_roi_reached(trade, 0.04, arrow.utcnow().shift(minutes=-26).datetime)
-    assert strategy.min_roi_reached(trade, 0.06, arrow.utcnow().shift(minutes=-26).datetime)
+    assert not strategy.min_roi_reached(trade, 0.04, dt_now() - timedelta(minutes=26))
+    assert strategy.min_roi_reached(trade, 0.06, dt_now() - timedelta(minutes=26))
 
     # Should not trigger with 20% profit since after 55 minutes only 30% is active.
-    assert not strategy.min_roi_reached(trade, 0.20, arrow.utcnow().shift(minutes=-2).datetime)
-    assert strategy.min_roi_reached(trade, 0.31, arrow.utcnow().shift(minutes=-2).datetime)
+    assert not strategy.min_roi_reached(trade, 0.20, dt_now() - timedelta(minutes=2))
+    assert strategy.min_roi_reached(trade, 0.31, dt_now() - timedelta(minutes=2))
 
 
 @pytest.mark.parametrize(
@@ -449,7 +449,7 @@ def test_ft_stoploss_reached(default_conf, fee, profit, adjusted, expected, liq,
         pair='ETH/BTC',
         stake_amount=0.01,
         amount=1,
-        open_date=arrow.utcnow().shift(hours=-1).datetime,
+        open_date=dt_now() - timedelta(hours=1),
         fee_open=fee.return_value,
         fee_close=fee.return_value,
         exchange='binance',
@@ -464,7 +464,7 @@ def test_ft_stoploss_reached(default_conf, fee, profit, adjusted, expected, liq,
     if custom_stop:
         strategy.custom_stoploss = custom_stop
 
-    now = arrow.utcnow().datetime
+    now = dt_now()
     current_rate = trade.open_rate * (1 + profit)
     sl_flag = strategy.ft_stoploss_reached(current_rate=current_rate, trade=trade,
                                            current_time=now, current_profit=profit,
@@ -498,14 +498,14 @@ def test_custom_exit(default_conf, fee, caplog) -> None:
         pair='ETH/BTC',
         stake_amount=0.01,
         amount=1,
-        open_date=arrow.utcnow().shift(hours=-1).datetime,
+        open_date=dt_now() - timedelta(hours=1),
         fee_open=fee.return_value,
         fee_close=fee.return_value,
         exchange='binance',
         open_rate=1,
     )
 
-    now = arrow.utcnow().datetime
+    now = dt_now()
     res = strategy.should_exit(trade, 1, now,
                                enter=False, exit_=False,
                                low=None, high=None)
@@ -547,13 +547,13 @@ def test_should_sell(default_conf, fee) -> None:
         pair='ETH/BTC',
         stake_amount=0.01,
         amount=1,
-        open_date=arrow.utcnow().shift(hours=-1).datetime,
+        open_date=dt_now() - timedelta(hours=1),
         fee_open=fee.return_value,
         fee_close=fee.return_value,
         exchange='binance',
         open_rate=1,
     )
-    now = arrow.utcnow().datetime
+    now = dt_now()
     res = strategy.should_exit(trade, 1, now,
                                enter=False, exit_=False,
                                low=None, high=None)
@@ -728,7 +728,7 @@ def test_is_pair_locked(default_conf):
 
     pair = 'ETH/BTC'
     assert not strategy.is_pair_locked(pair)
-    strategy.lock_pair(pair, arrow.now(timezone.utc).shift(minutes=4).datetime)
+    strategy.lock_pair(pair, dt_now() + timedelta(minutes=4))
     # ETH/BTC locked for 4 minutes
     assert strategy.is_pair_locked(pair)
 
@@ -746,7 +746,7 @@ def test_is_pair_locked(default_conf):
 
     # Lock with reason
     reason = "TestLockR"
-    strategy.lock_pair(pair, arrow.now(timezone.utc).shift(minutes=4).datetime, reason)
+    strategy.lock_pair(pair, dt_now() + timedelta(minutes=4), reason)
     assert strategy.is_pair_locked(pair)
     strategy.unlock_reason(reason)
     assert not strategy.is_pair_locked(pair)
diff --git a/tests/test_configuration.py b/tests/test_configuration.py
index c445b989d..7808fb5c8 100644
--- a/tests/test_configuration.py
+++ b/tests/test_configuration.py
@@ -1,7 +1,5 @@
 # pragma pylint: disable=missing-docstring, protected-access, invalid-name
 import json
-import logging
-import sys
 import warnings
 from copy import deepcopy
 from pathlib import Path
@@ -23,8 +21,6 @@ from freqtrade.configuration.load_config import (load_config_file, load_file, lo
 from freqtrade.constants import DEFAULT_DB_DRYRUN_URL, DEFAULT_DB_PROD_URL, ENV_VAR_PREFIX
 from freqtrade.enums import RunMode
 from freqtrade.exceptions import OperationalException
-from freqtrade.loggers import (FTBufferingHandler, FTStdErrStreamHandler, _set_loggers,
-                               setup_logging, setup_logging_pre)
 from tests.conftest import (CURRENT_TEST_STRATEGY, log_has, log_has_re,
                             patched_configuration_load_config_file)
 
@@ -594,7 +590,7 @@ def test_cli_verbose_with_params(default_conf, mocker, caplog) -> None:
     patched_configuration_load_config_file(mocker, default_conf)
 
     # Prevent setting loggers
-    mocker.patch('freqtrade.loggers._set_loggers', MagicMock)
+    mocker.patch('freqtrade.loggers.set_loggers', MagicMock)
     arglist = ['trade', '-vvv']
     args = Arguments(arglist).get_parsed_arg()
 
@@ -605,127 +601,6 @@ def test_cli_verbose_with_params(default_conf, mocker, caplog) -> None:
     assert log_has('Verbosity set to 3', caplog)
 
 
-def test_set_loggers() -> None:
-    # Reset Logging to Debug, otherwise this fails randomly as it's set globally
-    logging.getLogger('requests').setLevel(logging.DEBUG)
-    logging.getLogger("urllib3").setLevel(logging.DEBUG)
-    logging.getLogger('ccxt.base.exchange').setLevel(logging.DEBUG)
-    logging.getLogger('telegram').setLevel(logging.DEBUG)
-
-    previous_value1 = logging.getLogger('requests').level
-    previous_value2 = logging.getLogger('ccxt.base.exchange').level
-    previous_value3 = logging.getLogger('telegram').level
-
-    _set_loggers()
-
-    value1 = logging.getLogger('requests').level
-    assert previous_value1 is not value1
-    assert value1 is logging.INFO
-
-    value2 = logging.getLogger('ccxt.base.exchange').level
-    assert previous_value2 is not value2
-    assert value2 is logging.INFO
-
-    value3 = logging.getLogger('telegram').level
-    assert previous_value3 is not value3
-    assert value3 is logging.INFO
-
-    _set_loggers(verbosity=2)
-
-    assert logging.getLogger('requests').level is logging.DEBUG
-    assert logging.getLogger('ccxt.base.exchange').level is logging.INFO
-    assert logging.getLogger('telegram').level is logging.INFO
-    assert logging.getLogger('werkzeug').level is logging.INFO
-
-    _set_loggers(verbosity=3, api_verbosity='error')
-
-    assert logging.getLogger('requests').level is logging.DEBUG
-    assert logging.getLogger('ccxt.base.exchange').level is logging.DEBUG
-    assert logging.getLogger('telegram').level is logging.INFO
-    assert logging.getLogger('werkzeug').level is logging.ERROR
-
-
-@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows")
-def test_set_loggers_syslog():
-    logger = logging.getLogger()
-    orig_handlers = logger.handlers
-    logger.handlers = []
-
-    config = {'verbosity': 2,
-              'logfile': 'syslog:/dev/log',
-              }
-
-    setup_logging_pre()
-    setup_logging(config)
-    assert len(logger.handlers) == 3
-    assert [x for x in logger.handlers if type(x) == logging.handlers.SysLogHandler]
-    assert [x for x in logger.handlers if type(x) == FTStdErrStreamHandler]
-    assert [x for x in logger.handlers if type(x) == FTBufferingHandler]
-    # setting up logging again should NOT cause the loggers to be added a second time.
-    setup_logging(config)
-    assert len(logger.handlers) == 3
-    # reset handlers to not break pytest
-    logger.handlers = orig_handlers
-
-
-@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows")
-def test_set_loggers_Filehandler(tmpdir):
-    logger = logging.getLogger()
-    orig_handlers = logger.handlers
-    logger.handlers = []
-    logfile = Path(tmpdir) / 'ft_logfile.log'
-    config = {'verbosity': 2,
-              'logfile': str(logfile),
-              }
-
-    setup_logging_pre()
-    setup_logging(config)
-    assert len(logger.handlers) == 3
-    assert [x for x in logger.handlers if type(x) == logging.handlers.RotatingFileHandler]
-    assert [x for x in logger.handlers if type(x) == FTStdErrStreamHandler]
-    assert [x for x in logger.handlers if type(x) == FTBufferingHandler]
-    # setting up logging again should NOT cause the loggers to be added a second time.
-    setup_logging(config)
-    assert len(logger.handlers) == 3
-    # reset handlers to not break pytest
-    if logfile.exists:
-        logfile.unlink()
-    logger.handlers = orig_handlers
-
-
-@pytest.mark.skip(reason="systemd is not installed on every system, so we're not testing this.")
-def test_set_loggers_journald(mocker):
-    logger = logging.getLogger()
-    orig_handlers = logger.handlers
-    logger.handlers = []
-
-    config = {'verbosity': 2,
-              'logfile': 'journald',
-              }
-
-    setup_logging_pre()
-    setup_logging(config)
-    assert len(logger.handlers) == 3
-    assert [x for x in logger.handlers if type(x).__name__ == "JournaldLogHandler"]
-    assert [x for x in logger.handlers if type(x) == FTStdErrStreamHandler]
-    # reset handlers to not break pytest
-    logger.handlers = orig_handlers
-
-
-def test_set_loggers_journald_importerror(import_fails):
-    logger = logging.getLogger()
-    orig_handlers = logger.handlers
-    logger.handlers = []
-
-    config = {'verbosity': 2,
-              'logfile': 'journald',
-              }
-    with pytest.raises(OperationalException,
-                       match=r'You need the cysystemd python package.*'):
-        setup_logging(config)
-    logger.handlers = orig_handlers
-
-
 def test_set_logfile(default_conf, mocker, tmpdir):
     patched_configuration_load_config_file(mocker, default_conf)
     f = Path(tmpdir / "test_file.log")
@@ -1271,7 +1146,7 @@ def test_pairlist_resolving_with_config_pl_not_exists(mocker, default_conf):
         configuration.get_config()
 
 
-def test_pairlist_resolving_fallback(mocker):
+def test_pairlist_resolving_fallback(mocker, tmpdir):
     mocker.patch.object(Path, "exists", MagicMock(return_value=True))
     mocker.patch.object(Path, "open", MagicMock(return_value=MagicMock()))
     mocker.patch("freqtrade.configuration.configuration.load_file",
@@ -1290,7 +1165,7 @@ def test_pairlist_resolving_fallback(mocker):
 
     assert config['pairs'] == ['ETH/BTC', 'XRP/BTC']
     assert config['exchange']['name'] == 'binance'
-    assert config['datadir'] == Path.cwd() / "user_data/data/binance"
+    assert config['datadir'] == Path(tmpdir) / "user_data/data/binance"
 
 
 @pytest.mark.parametrize("setting", [
diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py
index 7bded0f82..742fdcace 100644
--- a/tests/test_freqtradebot.py
+++ b/tests/test_freqtradebot.py
@@ -4,10 +4,10 @@
 import logging
 import time
 from copy import deepcopy
+from datetime import timedelta
 from typing import List
 from unittest.mock import ANY, MagicMock, PropertyMock, patch
 
-import arrow
 import pytest
 from pandas import DataFrame
 from sqlalchemy import select
@@ -22,6 +22,7 @@ from freqtrade.freqtradebot import FreqtradeBot
 from freqtrade.persistence import Order, PairLocks, Trade
 from freqtrade.persistence.models import PairLock
 from freqtrade.plugins.protections.iprotection import ProtectionReturn
+from freqtrade.util.datetime_helpers import dt_now, dt_utc
 from freqtrade.worker import Worker
 from tests.conftest import (EXMS, create_mock_trades, create_mock_trades_usdt,
                             get_patched_freqtradebot, get_patched_worker, log_has, log_has_re,
@@ -121,7 +122,7 @@ def test_order_dict(default_conf_usdt, mocker, runmode, caplog) -> None:
 
     freqtrade = FreqtradeBot(conf)
     if runmode == RunMode.LIVE:
-        assert not log_has_re(".*stoploss_on_exchange .* dry-run", caplog)
+        assert not log_has_re(r".*stoploss_on_exchange .* dry-run", caplog)
     assert freqtrade.strategy.order_types['stoploss_on_exchange']
 
     caplog.clear()
@@ -136,7 +137,7 @@ def test_order_dict(default_conf_usdt, mocker, runmode, caplog) -> None:
     }
     freqtrade = FreqtradeBot(conf)
     assert not freqtrade.strategy.order_types['stoploss_on_exchange']
-    assert not log_has_re(".*stoploss_on_exchange .* dry-run", caplog)
+    assert not log_has_re(r".*stoploss_on_exchange .* dry-run", caplog)
 
 
 def test_get_trade_stake_amount(default_conf_usdt, mocker) -> None:
@@ -149,6 +150,34 @@ def test_get_trade_stake_amount(default_conf_usdt, mocker) -> None:
     assert result == default_conf_usdt['stake_amount']
 
 
+@pytest.mark.parametrize('runmode', [
+    RunMode.DRY_RUN,
+    RunMode.LIVE
+])
+def test_load_strategy_no_keys(default_conf_usdt, mocker, runmode, caplog) -> None:
+    patch_RPCManager(mocker)
+    patch_exchange(mocker)
+    conf = deepcopy(default_conf_usdt)
+    conf['runmode'] = runmode
+    erm = mocker.patch('freqtrade.freqtradebot.ExchangeResolver.load_exchange')
+
+    freqtrade = FreqtradeBot(conf)
+    strategy_config = freqtrade.strategy.config
+    assert id(strategy_config['exchange']) == id(conf['exchange'])
+    # Keys have been removed and are not passed to the exchange
+    assert strategy_config['exchange']['key'] == ''
+    assert strategy_config['exchange']['secret'] == ''
+
+    assert erm.call_count == 1
+    ex_conf = erm.call_args_list[0][1]['exchange_config']
+    assert id(ex_conf) != id(conf['exchange'])
+    # Keys are still present
+    assert ex_conf['key'] != ''
+    assert ex_conf['key'] == default_conf_usdt['exchange']['key']
+    assert ex_conf['secret'] != ''
+    assert ex_conf['secret'] == default_conf_usdt['exchange']['secret']
+
+
 @pytest.mark.parametrize("amend_last,wallet,max_open,lsamr,expected", [
                         (False, 120, 2, 0.5, [60, None]),
                         (True, 120, 2, 0.5, [60, 58.8]),
@@ -445,7 +474,7 @@ def test_enter_positions_global_pairlock(default_conf_usdt, ticker_usdt, limit_b
     assert not log_has_re(message, caplog)
     caplog.clear()
 
-    PairLocks.lock_pair('*', arrow.utcnow().shift(minutes=20).datetime, 'Just because', side='*')
+    PairLocks.lock_pair('*', dt_now() + timedelta(minutes=20), 'Just because', side='*')
     n = freqtrade.enter_positions()
     assert n == 0
     assert log_has_re(message, caplog)
@@ -466,7 +495,7 @@ def test_handle_protections(mocker, default_conf_usdt, fee, is_short):
 
     freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
     freqtrade.protections._protection_handlers[1].global_stop = MagicMock(
-        return_value=ProtectionReturn(True, arrow.utcnow().shift(hours=1).datetime, "asdf"))
+        return_value=ProtectionReturn(True, dt_now() + timedelta(hours=1), "asdf"))
     create_mock_trades(fee, is_short)
     freqtrade.handle_protections('ETC/BTC', '*')
     send_msg_mock = freqtrade.rpc.send_msg
@@ -1212,6 +1241,8 @@ def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, is_
         'type': 'stop_loss_limit',
         'price': 3,
         'average': 2,
+        'filled': enter_order['amount'],
+        'remaining': 0,
         'amount': enter_order['amount'],
     })
     mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hit)
@@ -1262,7 +1293,7 @@ def test_handle_stoploss_on_exchange(mocker, default_conf_usdt, fee, caplog, is_
     }])
     trade.stoploss_order_id = "107"
     trade.is_open = True
-    trade.stoploss_last_update = arrow.utcnow().shift(hours=-1).datetime
+    trade.stoploss_last_update = dt_now() - timedelta(hours=1)
     trade.stop_loss = 24
     trade.exit_reason = None
     trade.orders.append(
@@ -1411,7 +1442,7 @@ def test_handle_stoploss_on_exchange_partial_cancel_here(
     })
     mocker.patch(f'{EXMS}.fetch_stoploss_order', stoploss_order_hit)
     mocker.patch(f'{EXMS}.cancel_stoploss_order_with_result', stoploss_order_cancel)
-    trade.stoploss_last_update = arrow.utcnow().shift(minutes=-10).datetime
+    trade.stoploss_last_update = dt_now() - timedelta(minutes=10)
 
     assert freqtrade.handle_stoploss_on_exchange(trade) is False
     # Canceled Stoploss filled partially ...
@@ -1574,13 +1605,13 @@ def test_create_stoploss_order_insufficient_funds(
     assert mock_insuf.call_count == 1
 
 
-@pytest.mark.parametrize("is_short,bid,ask,stop_price,amt,hang_price", [
-    (False, [4.38, 4.16], [4.4, 4.17], ['2.0805', 4.4 * 0.95], 27.39726027, 3),
-    (True, [1.09, 1.21], [1.1, 1.22], ['2.321', 1.09 * 1.05], 27.27272727, 1.5),
+@pytest.mark.parametrize("is_short,bid,ask,stop_price,hang_price", [
+    (False, [4.38, 4.16], [4.4, 4.17], ['2.0805', 4.4 * 0.95], 3),
+    (True, [1.09, 1.21], [1.1, 1.22], ['2.321', 1.09 * 1.05], 1.5),
 ])
 @pytest.mark.usefixtures("init_persistence")
 def test_handle_stoploss_on_exchange_trailing(
-    mocker, default_conf_usdt, fee, is_short, bid, ask, limit_order, stop_price, amt, hang_price
+    mocker, default_conf_usdt, fee, is_short, bid, ask, limit_order, stop_price, hang_price
 ) -> None:
     # When trailing stoploss is set
     enter_order = limit_order[entry_side(is_short)]
@@ -1595,8 +1626,8 @@ def test_handle_stoploss_on_exchange_trailing(
             'last': 2.19,
         }),
         create_order=MagicMock(side_effect=[
-            {'id': enter_order['id']},
-            {'id': exit_order['id']},
+            enter_order,
+            exit_order,
         ]),
         get_fee=fee,
     )
@@ -1631,7 +1662,7 @@ def test_handle_stoploss_on_exchange_trailing(
     trade.is_open = True
     trade.open_order_id = None
     trade.stoploss_order_id = '100'
-    trade.stoploss_last_update = arrow.utcnow().shift(minutes=-20).datetime
+    trade.stoploss_last_update = dt_now() - timedelta(minutes=20)
     trade.orders.append(
         Order(
             ft_order_side='stoploss',
@@ -1692,7 +1723,7 @@ def test_handle_stoploss_on_exchange_trailing(
 
     cancel_order_mock.assert_called_once_with('100', 'ETH/USDT')
     stoploss_order_mock.assert_called_once_with(
-        amount=pytest.approx(amt),
+        amount=30,
         pair='ETH/USDT',
         order_types=freqtrade.strategy.order_types,
         stop_price=stop_price[1],
@@ -1762,7 +1793,7 @@ def test_handle_stoploss_on_exchange_trailing_error(
     trade.open_order_id = None
     trade.stoploss_order_id = "abcd"
     trade.stop_loss = 0.2
-    trade.stoploss_last_update = arrow.utcnow().shift(minutes=-601).datetime.replace(tzinfo=None)
+    trade.stoploss_last_update = (dt_now() - timedelta(minutes=601)).replace(tzinfo=None)
     trade.is_short = is_short
 
     stoploss_order_hanging = {
@@ -1786,7 +1817,7 @@ def test_handle_stoploss_on_exchange_trailing_error(
     assert stoploss.call_count == 1
 
     # Fail creating stoploss order
-    trade.stoploss_last_update = arrow.utcnow().shift(minutes=-601).datetime
+    trade.stoploss_last_update = dt_now() - timedelta(minutes=601)
     caplog.clear()
     cancel_mock = mocker.patch(f'{EXMS}.cancel_stoploss_order')
     mocker.patch(f'{EXMS}.create_stoploss', side_effect=ExchangeError())
@@ -1875,7 +1906,7 @@ def test_handle_stoploss_on_exchange_custom_stop(
     trade.is_open = True
     trade.open_order_id = None
     trade.stoploss_order_id = '100'
-    trade.stoploss_last_update = arrow.utcnow().shift(minutes=-601).datetime
+    trade.stoploss_last_update = dt_now() - timedelta(minutes=601)
     trade.orders.append(
         Order(
             ft_order_side='stoploss',
@@ -1961,7 +1992,7 @@ def test_tsl_on_exchange_compatible_with_edge(mocker, edge_conf, fee, limit_orde
 
     enter_order = limit_order['buy']
     exit_order = limit_order['sell']
-
+    enter_order['average'] = 2.19
     # When trailing stoploss is set
     stoploss = MagicMock(return_value={'id': '13434334', 'status': 'open'})
     patch_RPCManager(mocker)
@@ -1978,8 +2009,8 @@ def test_tsl_on_exchange_compatible_with_edge(mocker, edge_conf, fee, limit_orde
             'last': 2.19
         }),
         create_order=MagicMock(side_effect=[
-            {'id': enter_order['id']},
-            {'id': exit_order['id']},
+            enter_order,
+            exit_order,
         ]),
         get_fee=fee,
         create_stoploss=stoploss,
@@ -2013,7 +2044,7 @@ def test_tsl_on_exchange_compatible_with_edge(mocker, edge_conf, fee, limit_orde
     trade.is_open = True
     trade.open_order_id = None
     trade.stoploss_order_id = '100'
-    trade.stoploss_last_update = arrow.utcnow().datetime
+    trade.stoploss_last_update = dt_now()
     trade.orders.append(
         Order(
             ft_order_side='stoploss',
@@ -2075,7 +2106,7 @@ def test_tsl_on_exchange_compatible_with_edge(mocker, edge_conf, fee, limit_orde
     assert trade.stop_loss == 4.4 * 0.99
     cancel_order_mock.assert_called_once_with('100', 'NEO/BTC')
     stoploss_order_mock.assert_called_once_with(
-        amount=pytest.approx(11.41438356),
+        amount=30,
         pair='NEO/BTC',
         order_types=freqtrade.strategy.order_types,
         stop_price=4.4 * 0.99,
@@ -2107,6 +2138,7 @@ def test_enter_positions(mocker, default_conf_usdt, return_value, side_effect,
     assert mock_ct.call_count == len(default_conf_usdt['exchange']['pair_whitelist'])
 
 
+@pytest.mark.usefixtures("init_persistence")
 @pytest.mark.parametrize("is_short", [False, True])
 def test_exit_positions(mocker, default_conf_usdt, limit_order, is_short, caplog) -> None:
     freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
@@ -2115,12 +2147,33 @@ def test_exit_positions(mocker, default_conf_usdt, limit_order, is_short, caplog
     mocker.patch(f'{EXMS}.fetch_order', return_value=limit_order[entry_side(is_short)])
     mocker.patch(f'{EXMS}.get_trades_for_order', return_value=[])
 
-    # TODO: should not be magicmock
-    trade = MagicMock()
-    trade.is_short = is_short
-    trade.open_order_id = '123'
-    trade.open_fee = 0.001
+    order_id = '123'
+    trade = Trade(
+            open_order_id=order_id,
+            pair='ETH/USDT',
+            fee_open=0.001,
+            fee_close=0.001,
+            open_rate=0.01,
+            open_date=dt_now(),
+            stake_amount=0.01,
+            amount=11,
+            exchange="binance",
+            is_short=is_short,
+            leverage=1,
+            )
+    trade.orders.append(Order(
+        ft_order_side=entry_side(is_short),
+        price=0.01,
+        ft_pair=trade.pair,
+        ft_amount=trade.amount,
+        ft_price=trade.open_rate,
+        order_id=order_id,
+
+    ))
+    Trade.session.add(trade)
+    Trade.commit()
     trades = [trade]
+    freqtrade.wallets.update()
     n = freqtrade.exit_positions(trades)
     assert n == 0
     # Test amount not modified by fee-logic
@@ -2133,17 +2186,40 @@ def test_exit_positions(mocker, default_conf_usdt, limit_order, is_short, caplog
     assert gra.call_count == 0
 
 
+@pytest.mark.usefixtures("init_persistence")
 @pytest.mark.parametrize("is_short", [False, True])
 def test_exit_positions_exception(mocker, default_conf_usdt, limit_order, caplog, is_short) -> None:
     freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
     order = limit_order[entry_side(is_short)]
     mocker.patch(f'{EXMS}.fetch_order', return_value=order)
 
-    # TODO: should not be magicmock
-    trade = MagicMock()
-    trade.is_short = is_short
+    order_id = '123'
+    trade = Trade(
+        open_order_id=order_id,
+        pair='ETH/USDT',
+        fee_open=0.001,
+        fee_close=0.001,
+        open_rate=0.01,
+        open_date=dt_now(),
+        stake_amount=0.01,
+        amount=11,
+        exchange="binance",
+        is_short=is_short,
+        leverage=1,
+    )
+    trade.orders.append(Order(
+        ft_order_side=entry_side(is_short),
+        price=0.01,
+        ft_pair=trade.pair,
+        ft_amount=trade.amount,
+        ft_price=trade.open_rate,
+        order_id=order_id,
+
+    ))
     trade.open_order_id = None
-    trade.pair = 'ETH/USDT'
+    Trade.session.add(trade)
+    Trade.commit()
+    freqtrade.wallets.update()
     trades = [trade]
 
     # Test raise of DependencyException exception
@@ -2173,7 +2249,7 @@ def test_update_trade_state(mocker, default_conf_usdt, limit_order, is_short, ca
         fee_open=0.001,
         fee_close=0.001,
         open_rate=0.01,
-        open_date=arrow.utcnow().datetime,
+        open_date=dt_now(),
         amount=11,
         exchange="binance",
         is_short=is_short,
@@ -2246,7 +2322,7 @@ def test_update_trade_state_withorderdict(
         amount=amount,
         exchange='binance',
         open_rate=2.0,
-        open_date=arrow.utcnow().datetime,
+        open_date=dt_now(),
         fee_open=fee.return_value,
         fee_close=fee.return_value,
         open_order_id=order_id,
@@ -2333,7 +2409,7 @@ def test_update_trade_state_sell(
         open_rate=0.245441,
         fee_open=0.0025,
         fee_close=0.0025,
-        open_date=arrow.utcnow().datetime,
+        open_date=dt_now(),
         open_order_id=open_order['id'],
         is_open=True,
         interest_rate=0.0005,
@@ -2900,11 +2976,12 @@ def test_manage_open_orders_exit_usercustom(
 ) -> None:
     default_conf_usdt["unfilledtimeout"] = {"entry": 1440, "exit": 1440, "exit_timeout_count": 1}
     open_trade_usdt.open_order_id = limit_sell_order_old['id']
-    order = Order.parse_from_ccxt_object(limit_sell_order_old, 'mocked', 'sell')
-    open_trade_usdt.orders[0] = order
     if is_short:
         limit_sell_order_old['side'] = 'buy'
         open_trade_usdt.is_short = is_short
+    open_exit_order = Order.parse_from_ccxt_object(limit_sell_order_old, 'mocked',
+                                                   'buy' if is_short else 'sell')
+    open_trade_usdt.orders[-1] = open_exit_order
 
     rpc_mock = patch_RPCManager(mocker)
     cancel_order_mock = MagicMock()
@@ -2919,8 +2996,8 @@ def test_manage_open_orders_exit_usercustom(
     )
     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.open_date = dt_now() - timedelta(hours=5)
+    open_trade_usdt.close_date = dt_now() - timedelta(minutes=601)
     open_trade_usdt.close_profit_abs = 0.001
 
     Trade.session.add(open_trade_usdt)
@@ -3001,8 +3078,8 @@ def test_manage_open_orders_exit(
     )
     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.open_date = dt_now() - timedelta(hours=5)
+    open_trade_usdt.close_date = dt_now() - timedelta(minutes=601)
     open_trade_usdt.close_profit_abs = 0.001
     open_trade_usdt.is_short = is_short
 
@@ -3042,8 +3119,8 @@ def test_check_handle_cancelled_exit(
     )
     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.open_date = dt_now() - timedelta(hours=5)
+    open_trade_usdt.close_date = dt_now() - timedelta(minutes=601)
     open_trade_usdt.is_short = is_short
 
     Trade.session.add(open_trade_usdt)
@@ -3371,11 +3448,11 @@ def test_handle_cancel_exit_limit(mocker, default_conf_usdt, fee) -> None:
         exchange='binance',
         open_rate=0.245441,
         open_order_id="sell_123456",
-        open_date=arrow.utcnow().shift(days=-2).datetime,
+        open_date=dt_now() - timedelta(days=2),
         fee_open=fee.return_value,
         fee_close=fee.return_value,
         close_rate=0.555,
-        close_date=arrow.utcnow().datetime,
+        close_date=dt_now(),
         exit_reason="sell_reason_whatever",
         stake_amount=0.245441 * 2,
     )
@@ -4946,7 +5023,7 @@ def test_get_real_amount_in_point(default_conf_usdt, buy_order_fee, fee, mocker,
     (8.0, 0.1, 8.0, None),
     (8.0, 0.1, 7.9, 0.1),
 ])
-def test_apply_fee_conditional(default_conf_usdt, fee, mocker,
+def test_apply_fee_conditional(default_conf_usdt, fee, mocker, caplog,
                                amount, fee_abs, wallet, amount_exp):
     walletmock = mocker.patch('freqtrade.wallets.Wallets.update')
     mocker.patch('freqtrade.wallets.Wallets.get_free', return_value=wallet)
@@ -4971,6 +5048,60 @@ def test_apply_fee_conditional(default_conf_usdt, fee, mocker,
     # Amount is kept as is
     assert freqtrade.apply_fee_conditional(trade, 'LTC', amount, fee_abs, order) == amount_exp
     assert walletmock.call_count == 1
+    if fee_abs != 0 and amount_exp is None:
+        assert log_has_re(r"Fee amount.*Eating.*dust\.", caplog)
+
+
+@pytest.mark.parametrize('amount,fee_abs,wallet,amount_exp', [
+    (8.0, 0.0, 16, None),
+    (8.0, 0.0, 0, None),
+    (8.0, 0.1, 8, 0.1),
+    (8.0, 0.1, 20, None),
+    (8.0, 0.1, 16.0, None),
+    (8.0, 0.1, 7.9, 0.1),
+    (8.0, 0.1, 12, 0.1),
+    (8.0, 0.1, 15.9, 0.1),
+])
+def test_apply_fee_conditional_multibuy(default_conf_usdt, fee, mocker, caplog,
+                                        amount, fee_abs, wallet, amount_exp):
+    walletmock = mocker.patch('freqtrade.wallets.Wallets.update')
+    mocker.patch('freqtrade.wallets.Wallets.get_free', return_value=wallet)
+    trade = Trade(
+        pair='LTC/ETH',
+        amount=amount,
+        exchange='binance',
+        open_rate=0.245441,
+        fee_open=fee.return_value,
+        fee_close=fee.return_value,
+        open_order_id="123456"
+    )
+    # One closed order
+    order = Order(
+        ft_order_side='buy',
+        order_id='10',
+        ft_pair=trade.pair,
+        ft_is_open=False,
+        filled=amount,
+        status="closed"
+    )
+    trade.orders.append(order)
+    # Add additional order - this should NOT eat into dust unless the wallet was bigger already.
+    order1 = Order(
+        ft_order_side='buy',
+        order_id='100',
+        ft_pair=trade.pair,
+        ft_is_open=True,
+    )
+    trade.orders.append(order1)
+
+    freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
+
+    walletmock.reset_mock()
+    # The new trade amount will be 2x amount - fee / wallet will have to be adapted to this.
+    assert freqtrade.apply_fee_conditional(trade, 'LTC', amount, fee_abs, order1) == amount_exp
+    assert walletmock.call_count == 1
+    if fee_abs != 0 and amount_exp is None:
+        assert log_has_re(r"Fee amount.*Eating.*dust\.", caplog)
 
 
 @pytest.mark.parametrize("delta, is_high_delta", [
@@ -5392,7 +5523,7 @@ def test_reupdate_enter_order_fees(mocker, default_conf_usdt, fee, caplog, is_sh
         stake_amount=60.0,
         fee_open=fee.return_value,
         fee_close=fee.return_value,
-        open_date=arrow.utcnow().datetime,
+        open_date=dt_now(),
         is_open=True,
         amount=30,
         open_rate=2.0,
@@ -5507,6 +5638,51 @@ def test_handle_insufficient_funds(mocker, default_conf_usdt, fee, is_short, cap
     assert log_has(f"Error updating {order['id']}.", caplog)
 
 
+@pytest.mark.usefixtures("init_persistence")
+@pytest.mark.parametrize("is_short", [False, True])
+def test_handle_onexchange_order(mocker, default_conf_usdt, limit_order, is_short, caplog):
+    freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt)
+    mock_uts = mocker.spy(freqtrade, 'update_trade_state')
+
+    entry_order = limit_order[entry_side(is_short)]
+    exit_order = limit_order[exit_side(is_short)]
+    mock_fo = mocker.patch(f'{EXMS}.fetch_orders', return_value=[
+        entry_order,
+        exit_order,
+    ])
+
+    order_id = entry_order['id']
+
+    trade = Trade(
+            open_order_id=order_id,
+            pair='ETH/USDT',
+            fee_open=0.001,
+            fee_close=0.001,
+            open_rate=entry_order['price'],
+            open_date=dt_now(),
+            stake_amount=entry_order['cost'],
+            amount=entry_order['amount'],
+            exchange="binance",
+            is_short=is_short,
+            leverage=1,
+            )
+
+    trade.orders.append(Order.parse_from_ccxt_object(
+        entry_order, 'ADA/USDT', entry_side(is_short))
+    )
+    Trade.session.add(trade)
+    freqtrade.handle_onexchange_order(trade)
+    assert log_has_re(r"Found previously unknown order .*", caplog)
+    assert mock_uts.call_count == 1
+    assert mock_fo.call_count == 1
+
+    trade = Trade.session.scalars(select(Trade)).first()
+
+    assert len(trade.orders) == 2
+    assert trade.is_open is False
+    assert trade.exit_reason == ExitType.SOLD_ON_EXCHANGE.value
+
+
 def test_get_valid_price(mocker, default_conf_usdt) -> None:
     patch_RPCManager(mocker)
     patch_exchange(mocker)
@@ -5621,9 +5797,9 @@ def test_update_funding_fees(
     default_conf['trading_mode'] = 'futures'
     default_conf['margin_mode'] = 'isolated'
 
-    date_midnight = arrow.get('2021-09-01 00:00:00').datetime
-    date_eight = arrow.get('2021-09-01 08:00:00').datetime
-    date_sixteen = arrow.get('2021-09-01 16:00:00').datetime
+    date_midnight = dt_utc(2021, 9, 1)
+    date_eight = dt_utc(2021, 9, 1, 8)
+    date_sixteen = dt_utc(2021, 9, 1, 16)
     columns = ['date', 'open', 'high', 'low', 'close', 'volume']
     # 16:00 entry is actually never used
     # But should be kept in the test to ensure we're filtering correctly.
@@ -5912,7 +6088,7 @@ def test_position_adjust(mocker, default_conf_usdt, fee) -> None:
         'ft_is_open': False,
         'id': '651',
         'order_id': '651',
-        'datetime': arrow.utcnow().isoformat(),
+        'datetime': dt_now().isoformat(),
     }
 
     mocker.patch(f'{EXMS}.create_order', MagicMock(return_value=closed_dca_order_1))
diff --git a/tests/test_integration.py b/tests/test_integration.py
index 9fb9fd8b3..2949f1ef2 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -75,8 +75,9 @@ def test_may_execute_exit_stoploss_on_exchange_multi(default_conf, ticker, fee,
         _notify_exit=MagicMock(),
     )
     mocker.patch("freqtrade.strategy.interface.IStrategy.should_exit", should_sell_mock)
-    wallets_mock = mocker.patch("freqtrade.wallets.Wallets.update", MagicMock())
-    mocker.patch("freqtrade.wallets.Wallets.get_free", MagicMock(return_value=1000))
+    wallets_mock = mocker.patch("freqtrade.wallets.Wallets.update")
+    mocker.patch("freqtrade.wallets.Wallets.get_free", return_value=1000)
+    mocker.patch("freqtrade.wallets.Wallets.check_exit_amount", return_value=True)
 
     freqtrade = get_patched_freqtradebot(mocker, default_conf)
     freqtrade.strategy.order_types['stoploss_on_exchange'] = True
diff --git a/tests/test_log_setup.py b/tests/test_log_setup.py
new file mode 100644
index 000000000..bd3399615
--- /dev/null
+++ b/tests/test_log_setup.py
@@ -0,0 +1,150 @@
+import logging
+import sys
+from pathlib import Path
+
+import pytest
+
+from freqtrade.exceptions import OperationalException
+from freqtrade.loggers import (FTBufferingHandler, FTStdErrStreamHandler, set_loggers,
+                               setup_logging, setup_logging_pre)
+from freqtrade.loggers.set_log_levels import (reduce_verbosity_for_bias_tester,
+                                              restore_verbosity_for_bias_tester)
+
+
+def test_set_loggers() -> None:
+    # Reset Logging to Debug, otherwise this fails randomly as it's set globally
+    logging.getLogger('requests').setLevel(logging.DEBUG)
+    logging.getLogger("urllib3").setLevel(logging.DEBUG)
+    logging.getLogger('ccxt.base.exchange').setLevel(logging.DEBUG)
+    logging.getLogger('telegram').setLevel(logging.DEBUG)
+
+    previous_value1 = logging.getLogger('requests').level
+    previous_value2 = logging.getLogger('ccxt.base.exchange').level
+    previous_value3 = logging.getLogger('telegram').level
+
+    set_loggers()
+
+    value1 = logging.getLogger('requests').level
+    assert previous_value1 is not value1
+    assert value1 is logging.INFO
+
+    value2 = logging.getLogger('ccxt.base.exchange').level
+    assert previous_value2 is not value2
+    assert value2 is logging.INFO
+
+    value3 = logging.getLogger('telegram').level
+    assert previous_value3 is not value3
+    assert value3 is logging.INFO
+
+    set_loggers(verbosity=2)
+
+    assert logging.getLogger('requests').level is logging.DEBUG
+    assert logging.getLogger('ccxt.base.exchange').level is logging.INFO
+    assert logging.getLogger('telegram').level is logging.INFO
+    assert logging.getLogger('werkzeug').level is logging.INFO
+
+    set_loggers(verbosity=3, api_verbosity='error')
+
+    assert logging.getLogger('requests').level is logging.DEBUG
+    assert logging.getLogger('ccxt.base.exchange').level is logging.DEBUG
+    assert logging.getLogger('telegram').level is logging.INFO
+    assert logging.getLogger('werkzeug').level is logging.ERROR
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows")
+def test_set_loggers_syslog():
+    logger = logging.getLogger()
+    orig_handlers = logger.handlers
+    logger.handlers = []
+
+    config = {'verbosity': 2,
+              'logfile': 'syslog:/dev/log',
+              }
+
+    setup_logging_pre()
+    setup_logging(config)
+    assert len(logger.handlers) == 3
+    assert [x for x in logger.handlers if type(x) == logging.handlers.SysLogHandler]
+    assert [x for x in logger.handlers if type(x) == FTStdErrStreamHandler]
+    assert [x for x in logger.handlers if type(x) == FTBufferingHandler]
+    # setting up logging again should NOT cause the loggers to be added a second time.
+    setup_logging(config)
+    assert len(logger.handlers) == 3
+    # reset handlers to not break pytest
+    logger.handlers = orig_handlers
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="does not run on windows")
+def test_set_loggers_Filehandler(tmpdir):
+    logger = logging.getLogger()
+    orig_handlers = logger.handlers
+    logger.handlers = []
+    logfile = Path(tmpdir) / 'ft_logfile.log'
+    config = {'verbosity': 2,
+              'logfile': str(logfile),
+              }
+
+    setup_logging_pre()
+    setup_logging(config)
+    assert len(logger.handlers) == 3
+    assert [x for x in logger.handlers if type(x) == logging.handlers.RotatingFileHandler]
+    assert [x for x in logger.handlers if type(x) == FTStdErrStreamHandler]
+    assert [x for x in logger.handlers if type(x) == FTBufferingHandler]
+    # setting up logging again should NOT cause the loggers to be added a second time.
+    setup_logging(config)
+    assert len(logger.handlers) == 3
+    # reset handlers to not break pytest
+    if logfile.exists:
+        logfile.unlink()
+    logger.handlers = orig_handlers
+
+
+@pytest.mark.skip(reason="systemd is not installed on every system, so we're not testing this.")
+def test_set_loggers_journald(mocker):
+    logger = logging.getLogger()
+    orig_handlers = logger.handlers
+    logger.handlers = []
+
+    config = {'verbosity': 2,
+              'logfile': 'journald',
+              }
+
+    setup_logging_pre()
+    setup_logging(config)
+    assert len(logger.handlers) == 3
+    assert [x for x in logger.handlers if type(x).__name__ == "JournaldLogHandler"]
+    assert [x for x in logger.handlers if type(x) == FTStdErrStreamHandler]
+    # reset handlers to not break pytest
+    logger.handlers = orig_handlers
+
+
+def test_set_loggers_journald_importerror(import_fails):
+    logger = logging.getLogger()
+    orig_handlers = logger.handlers
+    logger.handlers = []
+
+    config = {'verbosity': 2,
+              'logfile': 'journald',
+              }
+    with pytest.raises(OperationalException,
+                       match=r'You need the cysystemd python package.*'):
+        setup_logging(config)
+    logger.handlers = orig_handlers
+
+
+def test_reduce_verbosity():
+    setup_logging_pre()
+    reduce_verbosity_for_bias_tester()
+    prior_level = logging.getLogger('freqtrade').getEffectiveLevel()
+
+    assert logging.getLogger('freqtrade.resolvers').getEffectiveLevel() == logging.WARNING
+    assert logging.getLogger('freqtrade.strategy.hyper').getEffectiveLevel() == logging.WARNING
+    # base level wasn't changed
+    assert logging.getLogger('freqtrade').getEffectiveLevel() == prior_level
+
+    restore_verbosity_for_bias_tester()
+
+    assert logging.getLogger('freqtrade.resolvers').getEffectiveLevel() == prior_level
+    assert logging.getLogger('freqtrade.strategy.hyper').getEffectiveLevel() == prior_level
+    assert logging.getLogger('freqtrade').getEffectiveLevel() == prior_level
+    # base level wasn't changed
diff --git a/tests/test_main.py b/tests/test_main.py
index 59a5bb0f7..bdb3c2bba 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -118,7 +118,7 @@ def test_main_operational_exception(mocker, default_conf, caplog) -> None:
 def test_main_operational_exception1(mocker, default_conf, caplog) -> None:
     patch_exchange(mocker)
     mocker.patch(
-        'freqtrade.commands.list_commands.validate_exchanges',
+        'freqtrade.commands.list_commands.list_available_exchanges',
         MagicMock(side_effect=ValueError('Oh snap!'))
     )
     patched_configuration_load_config_file(mocker, default_conf)
@@ -132,7 +132,7 @@ def test_main_operational_exception1(mocker, default_conf, caplog) -> None:
     assert log_has('Fatal exception!', caplog)
     assert not log_has_re(r'SIGINT.*', caplog)
     mocker.patch(
-        'freqtrade.commands.list_commands.validate_exchanges',
+        'freqtrade.commands.list_commands.list_available_exchanges',
         MagicMock(side_effect=KeyboardInterrupt)
     )
     with pytest.raises(SystemExit):
diff --git a/tests/test_misc.py b/tests/test_misc.py
index 6b4343ab2..21c832c2c 100644
--- a/tests/test_misc.py
+++ b/tests/test_misc.py
@@ -1,6 +1,5 @@
 # pragma pylint: disable=missing-docstring,C0103
 
-import datetime
 from copy import deepcopy
 from pathlib import Path
 from unittest.mock import MagicMock
@@ -9,10 +8,10 @@ import pandas as pd
 import pytest
 
 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,
+                            file_load_json, 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)
+                            safe_value_fallback2)
 
 
 def test_decimals_per_coin():
@@ -39,12 +38,6 @@ def test_round_coin_value():
     assert round_coin_value(222.2, 'USDT', False, True) == '222.200'
 
 
-def test_shorten_date() -> None:
-    str_data = '1 day, 2 hours, 3 minutes, 4 seconds ago'
-    str_shorten_data = '1 d, 2 h, 3 min, 4 sec ago'
-    assert shorten_date(str_data) == str_shorten_data
-
-
 def test_file_dump_json(mocker) -> None:
     file_open = mocker.patch('freqtrade.misc.Path.open', MagicMock())
     json_dump = mocker.patch('rapidjson.dump', MagicMock())
@@ -97,19 +90,6 @@ def test_pair_to_filename(pair, expected_result):
     assert pair_s == expected_result
 
 
-def test_format_ms_time() -> None:
-    # Date 2018-04-10 18:02:01
-    date_in_epoch_ms = 1523383321000
-    date = format_ms_time(date_in_epoch_ms)
-    assert type(date) is str
-    res = datetime.datetime(2018, 4, 10, 18, 2, 1, tzinfo=datetime.timezone.utc)
-    assert date == res.astimezone(None).strftime('%Y-%m-%dT%H:%M:%S')
-    res = datetime.datetime(2017, 12, 13, 8, 2, 1, tzinfo=datetime.timezone.utc)
-    # Date 2017-12-13 08:02:01
-    date_in_epoch_ms = 1513152121000
-    assert format_ms_time(date_in_epoch_ms) == res.astimezone(None).strftime('%Y-%m-%dT%H:%M:%S')
-
-
 def test_safe_value_fallback():
     dict1 = {'keya': None, 'keyb': 2, 'keyc': 5, 'keyd': None}
     assert safe_value_fallback(dict1, 'keya', 'keyb') == 2
diff --git a/tests/test_plotting.py b/tests/test_plotting.py
index 9f04ba20a..377caf59c 100644
--- a/tests/test_plotting.py
+++ b/tests/test_plotting.py
@@ -1,5 +1,4 @@
 from copy import deepcopy
-from pathlib import Path
 from unittest.mock import MagicMock
 
 import pandas as pd
@@ -282,13 +281,13 @@ def test_generate_Plot_filename():
     assert fn == "freqtrade-plot-UNITTEST_BTC-5m.html"
 
 
-def test_generate_plot_file(mocker, caplog):
+def test_generate_plot_file(mocker, caplog, user_dir):
     fig = generate_empty_figure()
     plot_mock = mocker.patch("freqtrade.plot.plotting.plot", MagicMock())
     store_plot_file(fig, filename="freqtrade-plot-UNITTEST_BTC-5m.html",
-                    directory=Path("user_data/plot"))
+                    directory=user_dir / "plot")
 
-    expected_fn = str(Path("user_data/plot/freqtrade-plot-UNITTEST_BTC-5m.html"))
+    expected_fn = str(user_dir / "plot/freqtrade-plot-UNITTEST_BTC-5m.html")
     assert plot_mock.call_count == 1
     assert plot_mock.call_args[0][0] == fig
     assert (plot_mock.call_args_list[0][1]['filename']
diff --git a/tests/test_strategy_updater.py b/tests/test_strategy_updater.py
index 597d49fda..3b48c952c 100644
--- a/tests/test_strategy_updater.py
+++ b/tests/test_strategy_updater.py
@@ -16,18 +16,18 @@ if sys.version_info < (3, 9):
     pytest.skip("StrategyUpdater is not compatible with Python 3.8", allow_module_level=True)
 
 
-def test_strategy_updater_start(tmpdir, capsys) -> None:
+def test_strategy_updater_start(user_dir, capsys) -> None:
     # Effective test without mocks.
     teststrats = Path(__file__).parent / 'strategy/strats'
-    tmpdirp = Path(tmpdir) / "strategies"
-    tmpdirp.mkdir()
+    tmpdirp = Path(user_dir) / "strategies"
+    tmpdirp.mkdir(parents=True, exist_ok=True)
     shutil.copy(teststrats / 'strategy_test_v2.py', tmpdirp)
     old_code = (teststrats / 'strategy_test_v2.py').read_text()
 
     args = [
         "strategy-updater",
         "--userdir",
-        str(tmpdir),
+        str(user_dir),
         "--strategy-list",
         "StrategyTestV2"
          ]
@@ -36,9 +36,9 @@ def test_strategy_updater_start(tmpdir, capsys) -> None:
 
     start_strategy_update(pargs)
 
-    assert Path(tmpdir / "strategies_orig_updater").exists()
+    assert Path(user_dir / "strategies_orig_updater").exists()
     # Backup file exists
-    assert Path(tmpdir / "strategies_orig_updater" / 'strategy_test_v2.py').exists()
+    assert Path(user_dir / "strategies_orig_updater" / 'strategy_test_v2.py').exists()
     # updated file exists
     new_file = Path(tmpdirp / 'strategy_test_v2.py')
     assert new_file.exists()
diff --git a/tests/test_timerange.py b/tests/test_timerange.py
index 993b24d95..d1c61704f 100644
--- a/tests/test_timerange.py
+++ b/tests/test_timerange.py
@@ -1,7 +1,6 @@
 # pragma pylint: disable=missing-docstring, C0103
 from datetime import datetime, timezone
 
-import arrow
 import pytest
 
 from freqtrade.configuration import TimeRange
@@ -69,7 +68,7 @@ def test_subtract_start():
 
 
 def test_adjust_start_if_necessary():
-    min_date = arrow.Arrow(2017, 11, 14, 21, 15, 00)
+    min_date = datetime(2017, 11, 14, 21, 15, 00, tzinfo=timezone.utc)
 
     x = TimeRange('date', 'date', 1510694100, 1510780500)
     # Adjust by 20 candles - min_date == startts
diff --git a/tests/test_wallets.py b/tests/test_wallets.py
index 7ccc8d0f5..c3ff4ccd0 100644
--- a/tests/test_wallets.py
+++ b/tests/test_wallets.py
@@ -3,9 +3,11 @@ from copy import deepcopy
 from unittest.mock import MagicMock
 
 import pytest
+from sqlalchemy import select
 
 from freqtrade.constants import UNLIMITED_STAKE_AMOUNT
 from freqtrade.exceptions import DependencyException
+from freqtrade.persistence import Trade
 from tests.conftest import EXMS, create_mock_trades, get_patched_freqtradebot, patch_wallet
 
 
@@ -43,7 +45,7 @@ def test_sync_wallet_at_boot(mocker, default_conf):
     assert freqtrade.wallets._wallets['GAS'].total == 0.260739
     assert freqtrade.wallets.get_free('BNT') == 1.0
     assert 'USDT' in freqtrade.wallets._wallets
-    assert freqtrade.wallets._last_wallet_refresh > 0
+    assert freqtrade.wallets._last_wallet_refresh is not None
     mocker.patch.multiple(
         EXMS,
         get_balances=MagicMock(return_value={
@@ -330,7 +332,7 @@ def test_sync_wallet_futures_live(mocker, default_conf):
 
     assert 'USDT' in freqtrade.wallets._wallets
     assert 'ETH/USDT:USDT' in freqtrade.wallets._positions
-    assert freqtrade.wallets._last_wallet_refresh > 0
+    assert freqtrade.wallets._last_wallet_refresh is not None
 
     # Remove ETH/USDT:USDT position
     del mock_result[0]
@@ -364,3 +366,48 @@ def test_sync_wallet_futures_dry(mocker, default_conf, fee):
     free = freqtrade.wallets.get_free('BTC')
     used = freqtrade.wallets.get_used('BTC')
     assert free + used == total
+
+
+def test_check_exit_amount(mocker, default_conf, fee):
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
+    update_mock = mocker.patch("freqtrade.wallets.Wallets.update")
+    total_mock = mocker.patch("freqtrade.wallets.Wallets.get_total", return_value=123)
+
+    create_mock_trades(fee, is_short=None)
+    trade = Trade.session.scalars(select(Trade)).first()
+    assert trade.amount == 123
+
+    assert freqtrade.wallets.check_exit_amount(trade) is True
+    assert update_mock.call_count == 0
+    assert total_mock.call_count == 1
+
+    update_mock.reset_mock()
+    # Reduce returned amount to below the trade amount - which should
+    # trigger a wallet update and return False, triggering "order refinding"
+    total_mock = mocker.patch("freqtrade.wallets.Wallets.get_total", return_value=100)
+    assert freqtrade.wallets.check_exit_amount(trade) is False
+    assert update_mock.call_count == 1
+    assert total_mock.call_count == 2
+
+
+def test_check_exit_amount_futures(mocker, default_conf, fee):
+    default_conf['trading_mode'] = 'futures'
+    default_conf['margin_mode'] = 'isolated'
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
+    total_mock = mocker.patch("freqtrade.wallets.Wallets.get_total", return_value=123)
+
+    create_mock_trades(fee, is_short=None)
+    trade = Trade.session.scalars(select(Trade)).first()
+    trade.trading_mode = 'futures'
+    assert trade.amount == 123
+
+    assert freqtrade.wallets.check_exit_amount(trade) is True
+    assert total_mock.call_count == 0
+
+    update_mock = mocker.patch("freqtrade.wallets.Wallets.update")
+    trade.amount = 150
+    # Reduce returned amount to below the trade amount - which should
+    # trigger a wallet update and return False, triggering "order refinding"
+    assert freqtrade.wallets.check_exit_amount(trade) is False
+    assert total_mock.call_count == 0
+    assert update_mock.call_count == 1
diff --git a/tests/exchange/test_ccxt_precise.py b/tests/utils/test_ccxt_precise.py
similarity index 100%
rename from tests/exchange/test_ccxt_precise.py
rename to tests/utils/test_ccxt_precise.py
diff --git a/tests/utils/test_datetime_helpers.py b/tests/utils/test_datetime_helpers.py
new file mode 100644
index 000000000..222410027
--- /dev/null
+++ b/tests/utils/test_datetime_helpers.py
@@ -0,0 +1,72 @@
+from datetime import datetime, timedelta, timezone
+
+import pytest
+import time_machine
+
+from freqtrade.util import (dt_floor_day, dt_from_ts, dt_humanize, dt_now, dt_ts, dt_utc,
+                            format_ms_time, shorten_date)
+
+
+def test_dt_now():
+    with time_machine.travel("2021-09-01 05:01:00 +00:00", tick=False) as t:
+        now = datetime.now(timezone.utc)
+        assert dt_now() == now
+        assert dt_ts() == int(now.timestamp() * 1000)
+        assert dt_ts(now) == int(now.timestamp() * 1000)
+
+        t.shift(timedelta(hours=5))
+        assert dt_now() >= now
+        assert dt_now() == datetime.now(timezone.utc)
+        assert dt_ts() == int(dt_now().timestamp() * 1000)
+        # Test with different time than now
+        assert dt_ts(now) == int(now.timestamp() * 1000)
+
+
+def test_dt_utc():
+    assert dt_utc(2023, 5, 5) == datetime(2023, 5, 5, tzinfo=timezone.utc)
+    assert dt_utc(2023, 5, 5, 0, 0, 0, 555500) == datetime(2023, 5, 5, 0, 0, 0, 555500,
+                                                           tzinfo=timezone.utc)
+
+
+@pytest.mark.parametrize('as_ms', [True, False])
+def test_dt_from_ts(as_ms):
+    multi = 1000 if as_ms else 1
+    assert dt_from_ts(1683244800.0 * multi) == datetime(2023, 5, 5, tzinfo=timezone.utc)
+    assert dt_from_ts(1683244800.5555 * multi) == datetime(2023, 5, 5, 0, 0, 0, 555500,
+                                                           tzinfo=timezone.utc)
+    # As int
+    assert dt_from_ts(1683244800 * multi) == datetime(2023, 5, 5, tzinfo=timezone.utc)
+    # As milliseconds
+    assert dt_from_ts(1683244800 * multi) == datetime(2023, 5, 5, tzinfo=timezone.utc)
+    assert dt_from_ts(1683242400 * multi) == datetime(2023, 5, 4, 23, 20, tzinfo=timezone.utc)
+
+
+def test_dt_floor_day():
+    now = datetime(2023, 9, 1, 5, 2, 3, 455555, tzinfo=timezone.utc)
+
+    assert dt_floor_day(now) == datetime(2023, 9, 1, tzinfo=timezone.utc)
+
+
+def test_shorten_date() -> None:
+    str_data = '1 day, 2 hours, 3 minutes, 4 seconds ago'
+    str_shorten_data = '1 d, 2 h, 3 min, 4 sec ago'
+    assert shorten_date(str_data) == str_shorten_data
+
+
+def test_dt_humanize() -> None:
+    assert dt_humanize(dt_now()) == 'just now'
+    assert dt_humanize(dt_now(), only_distance=True) == 'instantly'
+    assert dt_humanize(dt_now() - timedelta(hours=16), only_distance=True) == '16 hours'
+
+
+def test_format_ms_time() -> None:
+    # Date 2018-04-10 18:02:01
+    date_in_epoch_ms = 1523383321000
+    date = format_ms_time(date_in_epoch_ms)
+    assert type(date) is str
+    res = datetime(2018, 4, 10, 18, 2, 1, tzinfo=timezone.utc)
+    assert date == res.astimezone(None).strftime('%Y-%m-%dT%H:%M:%S')
+    res = datetime(2017, 12, 13, 8, 2, 1, tzinfo=timezone.utc)
+    # Date 2017-12-13 08:02:01
+    date_in_epoch_ms = 1513152121000
+    assert format_ms_time(date_in_epoch_ms) == res.astimezone(None).strftime('%Y-%m-%dT%H:%M:%S')
diff --git a/tests/test_periodiccache.py b/tests/utils/test_periodiccache.py
similarity index 100%
rename from tests/test_periodiccache.py
rename to tests/utils/test_periodiccache.py