diff --git a/.dependabot/config.yml b/.dependabot/config.yml deleted file mode 100644 index 66b91e99f..000000000 --- a/.dependabot/config.yml +++ /dev/null @@ -1,17 +0,0 @@ -version: 1 - -update_configs: - - package_manager: "python" - directory: "/" - update_schedule: "weekly" - allowed_updates: - - match: - update_type: "all" - target_branch: "develop" - - - package_manager: "docker" - directory: "/" - update_schedule: "daily" - allowed_updates: - - match: - update_type: "all" diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md deleted file mode 100644 index ae5375f43..000000000 --- a/.github/ISSUE_TEMPLATE.md +++ /dev/null @@ -1,33 +0,0 @@ -## Step 1: Have you search for this issue before posting it? - -If you have discovered a bug in the bot, please [search our issue tracker](https://github.com/freqtrade/freqtrade/issues?q=is%3Aissue). -If it hasn't been reported, please create a new issue. - -## Step 2: Describe your environment - - * Operating system: ____ - * Python Version: _____ (`python -V`) - * CCXT version: _____ (`pip freeze | grep ccxt`) - * Branch: Master | Develop - * Last Commit ID: _____ (`git log --format="%H" -n 1`) - -## Step 3: Describe the problem: - -*Explain the problem you have encountered* - -### Steps to reproduce: - - 1. _____ - 2. _____ - 3. _____ - -### Observed Results: - - * What happened? - * What did you expect to happen? - -### Relevant code exceptions or logs: - - ``` - // paste your log here - ``` diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..dacd9e673 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,48 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: "Triage Needed" +assignees: '' + +--- + + +## Describe your environment + + * Operating system: ____ + * Python Version: _____ (`python -V`) + * CCXT version: _____ (`pip freeze | grep ccxt`) + * Freqtrade Version: ____ (`freqtrade -V` or `docker-compose run --rm freqtrade -V` for Freqtrade running in docker) + +Note: All issues other than enhancement requests will be closed without further comment if the above template is deleted or not filled out. + +## Describe the problem: + +*Explain the problem you have encountered* + +### Steps to reproduce: + + 1. _____ + 2. _____ + 3. _____ + +### Observed Results: + + * What happened? + * What did you expect to happen? + +### Relevant code exceptions or logs + +Note: Please copy/paste text of the messages, no screenshots of logs please. + + ``` + // paste your log here + ``` diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..c32fb33c2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,27 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + + + +## Describe your environment +(if applicable) + + * Operating system: ____ + * Python Version: _____ (`python -V`) + * CCXT version: _____ (`pip freeze | grep ccxt`) + * Freqtrade Version: ____ (`freqtrade -V` or `docker-compose run --rm freqtrade -V` for Freqtrade running in docker) + + +## Describe the enhancement + +*Explain the enhancement you would like* + diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 000000000..f87b78f29 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,25 @@ +--- +name: BQuestion +about: Ask a question you could not find an answer in the docs +title: '' +labels: "Question" +assignees: '' + +--- + + +## Describe your environment + + * Operating system: ____ + * Python Version: _____ (`python -V`) + * CCXT version: _____ (`pip freeze | grep ccxt`) + * Freqtrade Version: ____ (`freqtrade -V` or `docker-compose run --rm freqtrade -V` for Freqtrade running in docker) + +## Your question + +*Ask the question you have not been able to find an answer in our [Documentation](https://www.freqtrade.io/en/latest/)* diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..44ff606b4 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: +- package-ecosystem: docker + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 +- package-ecosystem: pip + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 10 + target-branch: develop diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42668e46f..239576c61 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -100,7 +100,7 @@ jobs: - name: Slack Notification uses: homoluctus/slatify@v1.8.0 - if: always() && ( github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) + if: failure() && ( github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) with: type: ${{ job.status }} job_name: '*Freqtrade CI ${{ matrix.os }}*' @@ -162,7 +162,7 @@ jobs: - name: Slack Notification uses: homoluctus/slatify@v1.8.0 - if: always() && ( github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) + if: failure() && ( github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) with: type: ${{ job.status }} job_name: '*Freqtrade CI windows*' @@ -189,6 +189,29 @@ jobs: channel: '#notifications' url: ${{ secrets.SLACK_WEBHOOK }} + cleanup-prior-runs: + runs-on: ubuntu-latest + steps: + - name: Cleanup previous runs on this branch + uses: rokroskar/workflow-run-cleanup-action@v0.2.2 + if: "!startsWith(github.ref, 'refs/tags/') && github.ref != 'refs/heads/master' && github.repository == 'freqtrade/freqtrade'" + env: + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + + # Notify on slack only once - when CI completes (and after deploy) in case it's successfull + notify-complete: + needs: [ build, build_windows, docs_check ] + runs-on: ubuntu-latest + steps: + - name: Slack Notification + uses: homoluctus/slatify@v1.8.0 + if: always() && ( github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false) + with: + type: ${{ job.status }} + job_name: '*Freqtrade CI*' + channel: '#notifications' + url: ${{ secrets.SLACK_WEBHOOK }} + deploy: needs: [ build, build_windows, docs_check ] runs-on: ubuntu-18.04 @@ -226,25 +249,45 @@ jobs: user: __token__ password: ${{ secrets.pypi_password }} + - name: Dockerhub login + env: + DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} + DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} + run: | + echo "${DOCKER_PASSWORD}" | docker login --username ${DOCKER_USERNAME} --password-stdin + - name: Build and test and push docker image env: IMAGE_NAME: freqtradeorg/freqtrade - DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} - DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} BRANCH_NAME: ${{ steps.extract_branch.outputs.branch }} run: | build_helpers/publish_docker.sh - - name: Build raspberry image for ${{ steps.extract_branch.outputs.branch }}_pi - uses: elgohr/Publish-Docker-Github-Action@2.7 + # We need docker experimental to pull the ARM image. + - name: Switch docker to experimental + run: | + docker version -f '{{.Server.Experimental}}' + echo $'{\n "experimental": true\n}' | sudo tee /etc/docker/daemon.json + sudo systemctl restart docker + docker version -f '{{.Server.Experimental}}' + + - name: Set up Docker Buildx + id: buildx + uses: crazy-max/ghaction-docker-buildx@v1 with: - name: freqtradeorg/freqtrade:${{ steps.extract_branch.outputs.branch }}_pi - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - dockerfile: Dockerfile.pi - # cache: true - cache: ${{ github.event_name != 'schedule' }} - tag_names: true + buildx-version: latest + qemu-version: latest + + - name: Available platforms + run: echo ${{ steps.buildx.outputs.platforms }} + + - name: Build Raspberry docker image + env: + IMAGE_NAME: freqtradeorg/freqtrade + BRANCH_NAME: ${{ steps.extract_branch.outputs.branch }}_pi + run: | + build_helpers/publish_docker_pi.sh + - name: Slack Notification uses: homoluctus/slatify@v1.8.0 diff --git a/Dockerfile b/Dockerfile index d986f20ae..e1220e3b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ -FROM python:3.8.2-slim-buster +FROM python:3.8.5-slim-buster RUN apt-get update \ - && apt-get -y install curl build-essential libssl-dev \ + && apt-get -y install curl build-essential libssl-dev sqlite3 \ && apt-get clean \ && pip install --upgrade pip diff --git a/Dockerfile.armhf b/Dockerfile.armhf new file mode 100644 index 000000000..45ed2dac9 --- /dev/null +++ b/Dockerfile.armhf @@ -0,0 +1,29 @@ +FROM --platform=linux/arm/v7 python:3.7.7-slim-buster + +RUN apt-get update \ + && apt-get -y install curl build-essential libssl-dev libatlas3-base libgfortran5 sqlite3 \ + && apt-get clean \ + && pip install --upgrade pip \ + && echo "[global]\nextra-index-url=https://www.piwheels.org/simple" > /etc/pip.conf + +# Prepare environment +RUN mkdir /freqtrade +WORKDIR /freqtrade + +# Install TA-lib +COPY build_helpers/* /tmp/ +RUN cd /tmp && /tmp/install_ta-lib.sh && rm -r /tmp/*ta-lib* + +ENV LD_LIBRARY_PATH /usr/local/lib + +# Install dependencies +COPY requirements.txt requirements-common.txt /freqtrade/ +RUN pip install numpy --no-cache-dir \ + && pip install -r requirements.txt --no-cache-dir + +# Install and execute +COPY . /freqtrade/ +RUN pip install -e . --no-cache-dir +ENTRYPOINT ["freqtrade"] +# Default to trade mode +CMD [ "trade" ] diff --git a/Dockerfile.pi b/Dockerfile.pi deleted file mode 100644 index 279f85a04..000000000 --- a/Dockerfile.pi +++ /dev/null @@ -1,41 +0,0 @@ -FROM balenalib/raspberrypi3-debian:stretch - -RUN [ "cross-build-start" ] - -RUN apt-get update \ - && apt-get -y install wget curl build-essential libssl-dev libffi-dev \ - && apt-get clean - -# Prepare environment -RUN mkdir /freqtrade -WORKDIR /freqtrade - -# Install TA-lib -COPY build_helpers/ta-lib-0.4.0-src.tar.gz /freqtrade/ -RUN tar -xzf /freqtrade/ta-lib-0.4.0-src.tar.gz \ - && cd /freqtrade/ta-lib/ \ - && ./configure \ - && make \ - && make install \ - && rm /freqtrade/ta-lib-0.4.0-src.tar.gz - -ENV LD_LIBRARY_PATH /usr/local/lib - -# Install berryconda -RUN wget -q https://github.com/jjhelmus/berryconda/releases/download/v2.0.0/Berryconda3-2.0.0-Linux-armv7l.sh \ - && bash ./Berryconda3-2.0.0-Linux-armv7l.sh -b \ - && rm Berryconda3-2.0.0-Linux-armv7l.sh - -# Install dependencies -COPY requirements-common.txt /freqtrade/ -RUN ~/berryconda3/bin/conda install -y numpy pandas \ - && ~/berryconda3/bin/pip install -r requirements-common.txt --no-cache-dir - -# Install and execute -COPY . /freqtrade/ -RUN ~/berryconda3/bin/pip install -e . --no-cache-dir - -RUN [ "cross-build-end" ] - -ENTRYPOINT ["/root/berryconda3/bin/python","./freqtrade/main.py"] -CMD [ "trade" ] diff --git a/README.md b/README.md index 88070d45e..7e0acde46 100644 --- a/README.md +++ b/README.md @@ -68,40 +68,43 @@ For any other type of installation please refer to [Installation doc](https://ww ### Bot commands ``` -usage: freqtrade [-h] [-v] [--logfile FILE] [--version] [-c PATH] [-d PATH] - [-s NAME] [--strategy-path PATH] [--dynamic-whitelist [INT]] - [--db-url PATH] [--sd-notify] - {backtesting,edge,hyperopt} ... +usage: freqtrade [-h] [-V] + {trade,create-userdir,new-config,new-hyperopt,new-strategy,download-data,convert-data,convert-trade-data,backtesting,edge,hyperopt,hyperopt-list,hyperopt-show,list-exchanges,list-hyperopts,list-markets,list-pairs,list-strategies,list-timeframes,show-trades,test-pairlist,plot-dataframe,plot-profit} + ... Free, open source crypto trading bot positional arguments: - {backtesting,edge,hyperopt} + {trade,create-userdir,new-config,new-hyperopt,new-strategy,download-data,convert-data,convert-trade-data,backtesting,edge,hyperopt,hyperopt-list,hyperopt-show,list-exchanges,list-hyperopts,list-markets,list-pairs,list-strategies,list-timeframes,show-trades,test-pairlist,plot-dataframe,plot-profit} + trade Trade module. + create-userdir Create user-data directory. + new-config Create new config + new-hyperopt Create new hyperopt + new-strategy Create new strategy + download-data Download backtesting data. + convert-data Convert candle (OHLCV) data from one format to + another. + convert-trade-data Convert trade data from one format to another. backtesting Backtesting module. edge Edge module. hyperopt Hyperopt module. + hyperopt-list List Hyperopt results + hyperopt-show Show details of Hyperopt results + list-exchanges Print available exchanges. + list-hyperopts Print available hyperopt classes. + list-markets Print markets on exchange. + list-pairs Print pairs on exchange. + list-strategies Print available strategies. + list-timeframes Print available timeframes for the exchange. + show-trades Show trades. + test-pairlist Test your pairlist configuration. + plot-dataframe Plot candles with indicators. + plot-profit Generate plot showing profits. optional arguments: -h, --help show this help message and exit - -v, --verbose Verbose mode (-vv for more, -vvv to get all messages). - --logfile FILE Log to the file specified - --version show program's version number and exit - -c PATH, --config PATH - Specify configuration file (default: None). Multiple - --config options may be used. - -d PATH, --datadir PATH - Path to backtest data. - -s NAME, --strategy NAME - Specify strategy class name (default: - DefaultStrategy). - --strategy-path PATH Specify additional strategy lookup path. - --dynamic-whitelist [INT] - Dynamically generate and update whitelist based on 24h - BaseVolume (default: 20). DEPRECATED. - --db-url PATH Override trades database URL, this is useful if - dry_run is enabled or in custom deployments (default: - None). - --sd-notify Notify systemd service manager. + -V, --version show program's version number and exit + ``` ### Telegram RPC commands diff --git a/bin/freqtrade b/bin/freqtrade deleted file mode 100755 index eee7cbef4..000000000 --- a/bin/freqtrade +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python3 - -import sys -import logging - -logger = logging.getLogger(__name__) - - -logger.error("DEPRECATED installation detected, please run `pip install -e .` again.") - -sys.exit(2) diff --git a/build_helpers/TA_Lib-0.4.17-cp37-cp37m-win_amd64.whl b/build_helpers/TA_Lib-0.4.17-cp37-cp37m-win_amd64.whl deleted file mode 100644 index 87469a199..000000000 Binary files a/build_helpers/TA_Lib-0.4.17-cp37-cp37m-win_amd64.whl and /dev/null differ diff --git a/build_helpers/TA_Lib-0.4.17-cp38-cp38-win_amd64.whl b/build_helpers/TA_Lib-0.4.17-cp38-cp38-win_amd64.whl deleted file mode 100644 index 90626b183..000000000 Binary files a/build_helpers/TA_Lib-0.4.17-cp38-cp38-win_amd64.whl and /dev/null differ diff --git a/build_helpers/TA_Lib-0.4.18-cp37-cp37m-win_amd64.whl b/build_helpers/TA_Lib-0.4.18-cp37-cp37m-win_amd64.whl new file mode 100644 index 000000000..bd61e812b Binary files /dev/null and b/build_helpers/TA_Lib-0.4.18-cp37-cp37m-win_amd64.whl differ diff --git a/build_helpers/TA_Lib-0.4.18-cp38-cp38-win_amd64.whl b/build_helpers/TA_Lib-0.4.18-cp38-cp38-win_amd64.whl new file mode 100644 index 000000000..f81addb44 Binary files /dev/null and b/build_helpers/TA_Lib-0.4.18-cp38-cp38-win_amd64.whl differ diff --git a/build_helpers/install_windows.ps1 b/build_helpers/install_windows.ps1 index 7dbdd77dd..0a55b6ddd 100644 --- a/build_helpers/install_windows.ps1 +++ b/build_helpers/install_windows.ps1 @@ -7,10 +7,10 @@ python -m pip install --upgrade pip $pyv = python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" if ($pyv -eq '3.7') { - pip install build_helpers\TA_Lib-0.4.17-cp37-cp37m-win_amd64.whl + pip install build_helpers\TA_Lib-0.4.18-cp37-cp37m-win_amd64.whl } if ($pyv -eq '3.8') { - pip install build_helpers\TA_Lib-0.4.17-cp38-cp38-win_amd64.whl + pip install build_helpers\TA_Lib-0.4.18-cp38-cp38-win_amd64.whl } pip install -r requirements-dev.txt diff --git a/build_helpers/publish_docker.sh b/build_helpers/publish_docker.sh index 013644563..03a95161b 100755 --- a/build_helpers/publish_docker.sh +++ b/build_helpers/publish_docker.sh @@ -42,14 +42,6 @@ if [ "${TAG}" = "develop" ]; then docker tag freqtrade:$TAG ${IMAGE_NAME}:latest fi -# Login -docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD - -if [ $? -ne 0 ]; then - echo "failed login" - return 1 -fi - # Show all available images docker images diff --git a/build_helpers/publish_docker_pi.sh b/build_helpers/publish_docker_pi.sh new file mode 100755 index 000000000..060b1deaf --- /dev/null +++ b/build_helpers/publish_docker_pi.sh @@ -0,0 +1,36 @@ +#!/bin/sh + +# The below assumes a correctly setup docker buildx environment + +# Replace / with _ to create a valid tag +TAG=$(echo "${BRANCH_NAME}" | sed -e "s/\//_/g") +PI_PLATFORM="linux/arm/v7" +echo "Running for ${TAG}" +CACHE_TAG=freqtradeorg/freqtrade_cache:${TAG}_cache + +# Add commit and commit_message to docker container +echo "${GITHUB_SHA}" > freqtrade_commit + +if [ "${GITHUB_EVENT_NAME}" = "schedule" ]; then + echo "event ${GITHUB_EVENT_NAME}: full rebuild - skipping cache" + docker buildx build \ + --cache-to=type=registry,ref=${CACHE_TAG} \ + -f Dockerfile.armhf \ + --platform ${PI_PLATFORM} \ + -t ${IMAGE_NAME}:${TAG} --push . +else + echo "event ${GITHUB_EVENT_NAME}: building with cache" + # Pull last build to avoid rebuilding the whole image + # docker pull --platform ${PI_PLATFORM} ${IMAGE_NAME}:${TAG} + docker buildx build \ + --cache-from=type=registry,ref=${CACHE_TAG} \ + --cache-to=type=registry,ref=${CACHE_TAG} \ + -f Dockerfile.armhf \ + --platform ${PI_PLATFORM} \ + -t ${IMAGE_NAME}:${TAG} --push . +fi + +if [ $? -ne 0 ]; then + echo "failed building image" + return 1 +fi diff --git a/config.json.example b/config.json.example index 8ebb092e1..77a147d0c 100644 --- a/config.json.example +++ b/config.json.example @@ -4,8 +4,9 @@ "stake_amount": 0.05, "tradable_balance_ratio": 0.99, "fiat_display_currency": "USD", - "ticker_interval": "5m", + "timeframe": "5m", "dry_run": false, + "cancel_open_orders_on_exit": false, "trailing_stop": false, "unfilledtimeout": { "buy": 10, @@ -75,6 +76,16 @@ "token": "your_telegram_token", "chat_id": "your_telegram_chat_id" }, + "api_server": { + "enabled": false, + "listen_ip_address": "127.0.0.1", + "listen_port": 8080, + "verbosity": "info", + "jwt_secret_key": "somethingrandom", + "CORS_origins": [], + "username": "", + "password": "" + }, "initial_state": "running", "forcebuy_enable": false, "internals": { diff --git a/config_binance.json.example b/config_binance.json.example index d324ce883..82943749d 100644 --- a/config_binance.json.example +++ b/config_binance.json.example @@ -4,8 +4,9 @@ "stake_amount": 0.05, "tradable_balance_ratio": 0.99, "fiat_display_currency": "USD", - "ticker_interval": "5m", + "timeframe": "5m", "dry_run": true, + "cancel_open_orders_on_exit": false, "trailing_stop": false, "unfilledtimeout": { "buy": 10, @@ -80,6 +81,16 @@ "token": "your_telegram_token", "chat_id": "your_telegram_chat_id" }, + "api_server": { + "enabled": false, + "listen_ip_address": "127.0.0.1", + "listen_port": 8080, + "verbosity": "info", + "jwt_secret_key": "somethingrandom", + "CORS_origins": [], + "username": "", + "password": "" + }, "initial_state": "running", "forcebuy_enable": false, "internals": { diff --git a/config_full.json.example b/config_full.json.example index 181740b9a..d5bfd3fe1 100644 --- a/config_full.json.example +++ b/config_full.json.example @@ -8,7 +8,8 @@ "amend_last_stake_amount": false, "last_stake_amount_min_ratio": 0.5, "dry_run": false, - "ticker_interval": "5m", + "cancel_open_orders_on_exit": false, + "timeframe": "5m", "trailing_stop": false, "trailing_stop_positive": 0.005, "trailing_stop_positive_offset": 0.0051, @@ -63,8 +64,9 @@ "sort_key": "quoteVolume", "refresh_period": 1800 }, + {"method": "AgeFilter", "min_days_listed": 10}, {"method": "PrecisionFilter"}, - {"method": "PriceFilter", "low_price_ratio": 0.01}, + {"method": "PriceFilter", "low_price_ratio": 0.01, "min_price": 0.00000010}, {"method": "SpreadFilter", "max_spread_ratio": 0.005} ], "exchange": { @@ -120,6 +122,9 @@ "enabled": false, "listen_ip_address": "127.0.0.1", "listen_port": 8080, + "verbosity": "info", + "jwt_secret_key": "somethingrandom", + "CORS_origins": [], "username": "freqtrader", "password": "SuperSecurePassword" }, @@ -130,6 +135,7 @@ "process_throttle_secs": 5, "heartbeat_interval": 60 }, + "disable_dataframe_checks": false, "strategy": "DefaultStrategy", "strategy_path": "user_data/strategies/", "dataformat_ohlcv": "json", diff --git a/config_kraken.json.example b/config_kraken.json.example index dcf4c552a..fb983a4a3 100644 --- a/config_kraken.json.example +++ b/config_kraken.json.example @@ -4,8 +4,9 @@ "stake_amount": 10, "tradable_balance_ratio": 0.99, "fiat_display_currency": "EUR", - "ticker_interval": "5m", + "timeframe": "5m", "dry_run": true, + "cancel_open_orders_on_exit": false, "trailing_stop": false, "unfilledtimeout": { "buy": 10, @@ -86,6 +87,16 @@ "token": "your_telegram_token", "chat_id": "your_telegram_chat_id" }, + "api_server": { + "enabled": false, + "listen_ip_address": "127.0.0.1", + "listen_port": 8080, + "verbosity": "info", + "jwt_secret_key": "somethingrandom", + "CORS_origins": [], + "username": "", + "password": "" + }, "initial_state": "running", "forcebuy_enable": false, "internals": { diff --git a/docs/advanced-hyperopt.md b/docs/advanced-hyperopt.md index 25b4bd900..5fc674b03 100644 --- a/docs/advanced-hyperopt.md +++ b/docs/advanced-hyperopt.md @@ -63,8 +63,8 @@ class SuperDuperHyperOptLoss(IHyperOptLoss): * 0.25: Avoiding trade loss * 1.0 to total profit, compared to the expected value (`EXPECTED_MAX_PROFIT`) defined above """ - total_profit = results.profit_percent.sum() - trade_duration = results.trade_duration.mean() + total_profit = results['profit_percent'].sum() + trade_duration = results['trade_duration'].mean() trade_loss = 1 - 0.25 * exp(-(trade_count - TARGET_TRADES) ** 2 / 10 ** 5.8) profit_loss = max(0, 1 - total_profit / EXPECTED_MAX_PROFIT) diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 95480a2c6..f03bc10c0 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -4,6 +4,54 @@ This page explains some advanced tasks and configuration options that can be per If you do not know what things mentioned here mean, you probably do not need it. +## Running multiple instances of Freqtrade + +This section will show you how to run multiple bots at the same time, on the same machine. + +### Things to consider + +* Use different database files. +* Use different Telegram bots (requires multiple different configuration files; applies only when Telegram is enabled). +* Use different ports (applies only when Freqtrade REST API webserver is enabled). + +### Different database files + +In order to keep track of your trades, profits, etc., freqtrade is using a SQLite database where it stores various types of information such as the trades you performed in the past and the current position(s) you are holding at any time. This allows you to keep track of your profits, but most importantly, keep track of ongoing activity if the bot process would be restarted or would be terminated unexpectedly. + +Freqtrade will, by default, use separate database files for dry-run and live bots (this assumes no database-url is given in either configuration nor via command line argument). +For live trading mode, the default database will be `tradesv3.sqlite` and for dry-run it will be `tradesv3.dryrun.sqlite`. + +The optional argument to the trade command used to specify the path of these files is `--db-url`, which requires a valid SQLAlchemy url. +So when you are starting a bot with only the config and strategy arguments in dry-run mode, the following 2 commands would have the same outcome. + +``` bash +freqtrade trade -c MyConfig.json -s MyStrategy +# is equivalent to +freqtrade trade -c MyConfig.json -s MyStrategy --db-url sqlite:///tradesv3.dryrun.sqlite +``` + +It means that if you are running the trade command in two different terminals, for example to test your strategy both for trades in USDT and in another instance for trades in BTC, you will have to run them with different databases. + +If you specify the URL of a database which does not exist, freqtrade will create one with the name you specified. So to test your custom strategy with BTC and USDT stake currencies, you could use the following commands (in 2 separate terminals): + +``` bash +# Terminal 1: +freqtrade trade -c MyConfigBTC.json -s MyCustomStrategy --db-url sqlite:///user_data/tradesBTC.dryrun.sqlite +# Terminal 2: +freqtrade trade -c MyConfigUSDT.json -s MyCustomStrategy --db-url sqlite:///user_data/tradesUSDT.dryrun.sqlite +``` + +Conversely, if you wish to do the same thing in production mode, you will also have to create at least one new database (in addition to the default one) and specify the path to the "live" databases, for example: + +``` bash +# Terminal 1: +freqtrade trade -c MyConfigBTC.json -s MyCustomStrategy --db-url sqlite:///user_data/tradesBTC.live.sqlite +# Terminal 2: +freqtrade trade -c MyConfigUSDT.json -s MyCustomStrategy --db-url sqlite:///user_data/tradesUSDT.live.sqlite +``` + +For more information regarding usage of the sqlite databases, for example to manually enter or remove trades, please refer to the [SQL Cheatsheet](sql_cheatsheet.md). + ## Configure the bot running as a systemd service Copy the `freqtrade.service` file to your systemd user directory (usually `~/.config/systemd/user`) and update `WorkingDirectory` and `ExecStart` to match your setup. diff --git a/docs/backtesting.md b/docs/backtesting.md index 3d08d5332..ecd48bdc9 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -12,13 +12,17 @@ real data. This is what we call [backtesting](https://en.wikipedia.org/wiki/Backtesting). Backtesting will use the crypto-currencies (pairs) from your config file and load historical candle (OHCLV) data from `user_data/data/` by default. -If no data is available for the exchange / pair / timeframe (ticker interval) combination, backtesting will ask you to download them first using `freqtrade download-data`. +If no data is available for the exchange / pair / timeframe combination, backtesting will ask you to download them first using `freqtrade download-data`. For details on downloading, please refer to the [Data Downloading](data-download.md) section in the documentation. The result of backtesting will confirm if your bot has better odds of making a profit than a loss. -!!! Tip "Using dynamic pairlists for backtesting" - While using dynamic pairlists during backtesting is not possible, a dynamic pairlist using current data can be generated via the [`test-pairlist`](utils.md#test-pairlist) command, and needs to be specified as `"pair_whitelist"` attribute in the configuration. +!!! Warning "Using dynamic pairlists for backtesting" + Using dynamic pairlists is possible, however it relies on the current market conditions - which will not reflect the historic status of the pairlist. + Also, when using pairlists other than StaticPairlist, reproducability of backtesting-results cannot be guaranteed. + Please read the [pairlists documentation](configuration.md#pairlists) for more information. + + To achieve reproducible results, best generate a pairlist via the [`test-pairlist`](utils.md#test-pairlist) command and use that as static pairlist. ### Run a backtesting against the currencies listed in your config file @@ -31,7 +35,7 @@ freqtrade backtesting #### With 1 min candle (OHLCV) data ```bash -freqtrade backtesting --ticker-interval 1m +freqtrade backtesting --timeframe 1m ``` #### Using a different on-disk historical candle (OHLCV) data source @@ -54,7 +58,7 @@ Where `-s SampleStrategy` refers to the class name within the strategy file `sam #### Comparing multiple Strategies ```bash -freqtrade backtesting --strategy-list SampleStrategy1 AwesomeStrategy --ticker-interval 5m +freqtrade backtesting --strategy-list SampleStrategy1 AwesomeStrategy --timeframe 5m ``` Where `SampleStrategy1` and `AwesomeStrategy` refer to class names of strategies. @@ -62,7 +66,7 @@ Where `SampleStrategy1` and `AwesomeStrategy` refer to class names of strategies #### Exporting trades to file ```bash -freqtrade backtesting --export trades +freqtrade backtesting --export trades --config config.json --strategy SampleStrategy ``` The exported trades can be used for [further analysis](#further-backtest-result-analysis), or can be used by the plotting script `plot_dataframe.py` in the scripts directory. @@ -198,7 +202,7 @@ Since backtesting lacks some detailed information about what happens within a ca - Buys happen at open-price - Sell signal sells happen at open-price of the following candle -- Low happens before high for stoploss, protecting capital first. +- Low happens before high for stoploss, protecting capital first - ROI - sells are compared to high - but the ROI value is used (e.g. ROI = 2%, high=5% - so the sell will be at 2%) - sells are never "below the candle", so a ROI of 2% may result in a sell at 2.4% if low was at 2.4% profit @@ -208,6 +212,7 @@ Since backtesting lacks some detailed information about what happens within a ca - High happens first - adjusting stoploss - Low uses the adjusted stoploss (so sells with large high-low difference are backtested correctly) - Sell-reason does not explain if a trade was positive or negative, just what triggered the sell (this can look odd if negative ROI values are used) +- Stoploss (and trailing stoploss) is evaluated before ROI within one candle. So you can often see more trades with the `stoploss` and/or `trailing_stop` sell reason comparing to the results obtained with the same strategy in the Dry Run/Live Trade modes. Taking these assumptions, backtesting tries to mirror real trading as closely as possible. However, backtesting will **never** replace running a strategy in dry-run mode. Also, keep in mind that past results don't guarantee future success. @@ -223,13 +228,13 @@ You can then load the trades to perform further analysis as shown in our [data a To compare multiple strategies, a list of Strategies can be provided to backtesting. -This is limited to 1 timeframe (ticker interval) value per run. However, data is only loaded once from disk so if you have multiple +This is limited to 1 timeframe value per run. However, data is only loaded once from disk so if you have multiple strategies you'd like to compare, this will give a nice runtime boost. All listed Strategies need to be in the same directory. ``` bash -freqtrade backtesting --timerange 20180401-20180410 --ticker-interval 5m --strategy-list Strategy001 Strategy002 --export trades +freqtrade backtesting --timerange 20180401-20180410 --timeframe 5m --strategy-list Strategy001 Strategy002 --export trades ``` This will save the results to `user_data/backtest_results/backtest-result-.json`, injecting the strategy-name into the target filename. diff --git a/docs/bot-basics.md b/docs/bot-basics.md new file mode 100644 index 000000000..44f493456 --- /dev/null +++ b/docs/bot-basics.md @@ -0,0 +1,58 @@ +# Freqtrade basics + +This page provides you some basic concepts on how Freqtrade works and operates. + +## Freqtrade terminology + +* Trade: Open position. +* Open Order: Order which is currently placed on the exchange, and is not yet complete. +* Pair: Tradable pair, usually in the format of Quote/Base (e.g. XRP/USDT). +* Timeframe: Candle length to use (e.g. `"5m"`, `"1h"`, ...). +* Indicators: Technical indicators (SMA, EMA, RSI, ...). +* Limit order: Limit orders which execute at the defined limit price or better. +* Market order: Guaranteed to fill, may move price depending on the order size. + +## Fee handling + +All profit calculations of Freqtrade include fees. For Backtesting / Hyperopt / Dry-run modes, the exchange default fee is used (lowest tier on the exchange). For live operations, fees are used as applied by the exchange (this includes BNB rebates etc.). + +## Bot execution logic + +Starting freqtrade in dry-run or live mode (using `freqtrade trade`) will start the bot and start the bot iteration loop. +By default, loop runs every few seconds (`internals.process_throttle_secs`) and does roughly the following in the following sequence: + +* Fetch open trades from persistence. +* Calculate current list of tradable pairs. +* Download ohlcv data for the pairlist including all [informative pairs](strategy-customization.md#get-data-for-non-tradeable-pairs) + This step is only executed once per Candle to avoid unnecessary network traffic. +* Call `bot_loop_start()` strategy callback. +* Analyze strategy per pair. + * Call `populate_indicators()` + * Call `populate_buy_trend()` + * Call `populate_sell_trend()` +* Check timeouts for open orders. + * Calls `check_buy_timeout()` strategy callback for open buy orders. + * Calls `check_sell_timeout()` strategy callback for open sell orders. +* Verifies existing positions and eventually places sell orders. + * Considers stoploss, ROI and sell-signal. + * Determine sell-price based on `ask_strategy` configuration setting. + * Before a sell order is placed, `confirm_trade_exit()` strategy callback is called. +* Check if trade-slots are still available (if `max_open_trades` is reached). +* Verifies buy signal trying to enter new positions. + * Determine buy-price based on `bid_strategy` configuration setting. + * Before a buy order is placed, `confirm_trade_entry()` strategy callback is called. + +This loop will be repeated again and again until the bot is stopped. + +## Backtesting / Hyperopt execution logic + +[backtesting](backtesting.md) or [hyperopt](hyperopt.md) do only part of the above logic, since most of the trading operations are fully simulated. + +* Load historic data for configured pairlist. +* Calculate indicators (calls `populate_indicators()`). +* Calls `populate_buy_trend()` and `populate_sell_trend()` +* Loops per candle simulating entry and exit points. +* Generate backtest report output + +!!! Note + Both Backtesting and Hyperopt include exchange default Fees in the calculation. Custom fees can be passed to backtesting / hyperopt by specifying the `--fee` argument. diff --git a/docs/bot-usage.md b/docs/bot-usage.md index b1649374a..40ff3d82b 100644 --- a/docs/bot-usage.md +++ b/docs/bot-usage.md @@ -9,22 +9,35 @@ This page explains the different parameters of the bot and how to run it. ``` usage: freqtrade [-h] [-V] - {trade,backtesting,edge,hyperopt,create-userdir,list-exchanges,list-timeframes,download-data,plot-dataframe,plot-profit} + {trade,create-userdir,new-config,new-hyperopt,new-strategy,download-data,convert-data,convert-trade-data,backtesting,edge,hyperopt,hyperopt-list,hyperopt-show,list-exchanges,list-hyperopts,list-markets,list-pairs,list-strategies,list-timeframes,show-trades,test-pairlist,plot-dataframe,plot-profit} ... Free, open source crypto trading bot positional arguments: - {trade,backtesting,edge,hyperopt,create-userdir,list-exchanges,list-timeframes,download-data,plot-dataframe,plot-profit} + {trade,create-userdir,new-config,new-hyperopt,new-strategy,download-data,convert-data,convert-trade-data,backtesting,edge,hyperopt,hyperopt-list,hyperopt-show,list-exchanges,list-hyperopts,list-markets,list-pairs,list-strategies,list-timeframes,show-trades,test-pairlist,plot-dataframe,plot-profit} trade Trade module. + create-userdir Create user-data directory. + new-config Create new config + new-hyperopt Create new hyperopt + new-strategy Create new strategy + download-data Download backtesting data. + convert-data Convert candle (OHLCV) data from one format to + another. + convert-trade-data Convert trade data from one format to another. backtesting Backtesting module. edge Edge module. hyperopt Hyperopt module. - create-userdir Create user-data directory. + hyperopt-list List Hyperopt results + hyperopt-show Show details of Hyperopt results list-exchanges Print available exchanges. - list-timeframes Print available ticker intervals (timeframes) for the - exchange. - download-data Download backtesting data. + list-hyperopts Print available hyperopt classes. + list-markets Print markets on exchange. + list-pairs Print pairs on exchange. + list-strategies Print available strategies. + list-timeframes Print available timeframes for the exchange. + show-trades Show trades. + test-pairlist Test your pairlist configuration. plot-dataframe Plot candles with indicators. plot-profit Generate plot showing profits. @@ -72,7 +85,6 @@ Strategy arguments: Specify strategy class name which will be used by the bot. --strategy-path PATH Specify additional strategy lookup path. -. ``` @@ -197,7 +209,7 @@ Backtesting also uses the config specified via `-c/--config`. ``` usage: freqtrade backtesting [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] [--userdir PATH] [-s NAME] - [--strategy-path PATH] [-i TICKER_INTERVAL] + [--strategy-path PATH] [-i TIMEFRAME] [--timerange TIMERANGE] [--max-open-trades INT] [--stake-amount STAKE_AMOUNT] [--fee FLOAT] [--eps] [--dmmp] @@ -206,7 +218,7 @@ usage: freqtrade backtesting [-h] [-v] [--logfile FILE] [-V] [-c PATH] optional arguments: -h, --help show this help message and exit - -i TICKER_INTERVAL, --ticker-interval TICKER_INTERVAL + -i TIMEFRAME, --timeframe TIMEFRAME, --ticker-interval TIMEFRAME Specify ticker interval (`1m`, `5m`, `30m`, `1h`, `1d`). --timerange TIMERANGE @@ -280,7 +292,7 @@ to find optimal parameter values for your strategy. ``` usage: freqtrade hyperopt [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] [--userdir PATH] [-s NAME] [--strategy-path PATH] - [-i TICKER_INTERVAL] [--timerange TIMERANGE] + [-i TIMEFRAME] [--timerange TIMERANGE] [--max-open-trades INT] [--stake-amount STAKE_AMOUNT] [--fee FLOAT] [--hyperopt NAME] [--hyperopt-path PATH] [--eps] @@ -292,7 +304,7 @@ usage: freqtrade hyperopt [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] optional arguments: -h, --help show this help message and exit - -i TICKER_INTERVAL, --ticker-interval TICKER_INTERVAL + -i TIMEFRAME, --timeframe TIMEFRAME, --ticker-interval TIMEFRAME Specify ticker interval (`1m`, `5m`, `30m`, `1h`, `1d`). --timerange TIMERANGE @@ -323,7 +335,7 @@ optional arguments: --print-all Print all results, not only the best ones. --no-color Disable colorization of hyperopt results. May be useful if you are redirecting output to a file. - --print-json Print best results in JSON format. + --print-json Print output in JSON format. -j JOBS, --job-workers JOBS The number of concurrently running jobs for hyperoptimization (hyperopt worker processes). If -1 @@ -341,11 +353,11 @@ optional arguments: class (IHyperOptLoss). Different functions can generate completely different results, since the target for optimization is different. Built-in - Hyperopt-loss-functions are: - DefaultHyperOptLoss, OnlyProfitHyperOptLoss, - SharpeHyperOptLoss, SharpeHyperOptLossDaily, - SortinoHyperOptLoss, SortinoHyperOptLossDaily. - (default: `DefaultHyperOptLoss`). + Hyperopt-loss-functions are: DefaultHyperOptLoss, + OnlyProfitHyperOptLoss, SharpeHyperOptLoss, + SharpeHyperOptLossDaily, SortinoHyperOptLoss, + SortinoHyperOptLossDaily.(default: + `DefaultHyperOptLoss`). Common arguments: -v, --verbose Verbose mode (-vv for more, -vvv to get all messages). @@ -378,13 +390,13 @@ To know your trade expectancy and winrate against historical data, you can use E ``` usage: freqtrade edge [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] [--userdir PATH] [-s NAME] [--strategy-path PATH] - [-i TICKER_INTERVAL] [--timerange TIMERANGE] + [-i TIMEFRAME] [--timerange TIMERANGE] [--max-open-trades INT] [--stake-amount STAKE_AMOUNT] [--fee FLOAT] [--stoplosses STOPLOSS_RANGE] optional arguments: -h, --help show this help message and exit - -i TICKER_INTERVAL, --ticker-interval TICKER_INTERVAL + -i TIMEFRAME, --timeframe TIMEFRAME, --ticker-interval TIMEFRAME Specify ticker interval (`1m`, `5m`, `30m`, `1h`, `1d`). --timerange TIMERANGE diff --git a/docs/configuration.md b/docs/configuration.md index 338299781..a200d6411 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -47,13 +47,14 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `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. -| `ticker_interval` | The timeframe (ticker interval) to use (e.g `1m`, `5m`, `15m`, `30m`, `1h` ...). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** String +| `timeframe` | The timeframe (former ticker interval) to use (e.g `1m`, `5m`, `15m`, `30m`, `1h` ...). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** String | `fiat_display_currency` | Fiat currency used to show your profits. [More information below](#what-values-can-be-used-for-fiat_display_currency).
**Datatype:** String | `dry_run` | **Required.** Define if the bot must be in Dry Run or production mode.
*Defaults to `true`.*
**Datatype:** Boolean | `dry_run_wallet` | Define the starting amount in stake currency for the simulated wallet used by the bot running in the Dry Run mode.
*Defaults to `1000`.*
**Datatype:** Float +| `cancel_open_orders_on_exit` | Cancel open orders when the `/stop` RPC command is issued, `Ctrl+C` is pressed or the bot dies unexpectedly. When set to `true`, this allows you to use `/stop` to cancel unfilled and partially filled orders in the event of a market crash. It does not impact open positions.
*Defaults to `false`.*
**Datatype:** Boolean | `process_only_new_candles` | Enable processing of indicators only when new candles arrive. If false each loop populates the indicators, this will mean the same candle is processed many times creating system load but can be useful of your strategy depends on tick data not only candle. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `false`.*
**Datatype:** Boolean -| `minimal_roi` | **Required.** Set the threshold in percent the bot will use to sell a trade. [More information below](#understand-minimal_roi). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Dict -| `stoploss` | **Required.** Value of the stoploss in percent used by the bot. More details in the [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Float (as ratio) +| `minimal_roi` | **Required.** Set the threshold as ratio the bot will use to sell a trade. [More information below](#understand-minimal_roi). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Dict +| `stoploss` | **Required.** Value as ratio of the stoploss used by the bot. More details in the [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Float (as ratio) | `trailing_stop` | Enables trailing stoploss (based on `stoploss` in either configuration or strategy file). More details in the [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Boolean | `trailing_stop_positive` | Changes stoploss once profit has been reached. More details in the [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Float | `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). [Strategy Override](#parameters-in-the-strategy).
*Defaults to `0.0` (no offset).*
**Datatype:** Float @@ -80,14 +81,15 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `exchange.key` | API key to use for the exchange. Only required when you are in production mode.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `exchange.secret` | API secret to use for the exchange. Only required when you are in production mode.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `exchange.password` | API password to use for the exchange. Only required when you are in production mode and for exchanges that use password for API requests.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String -| `exchange.pair_whitelist` | List of pairs to use by the bot for trading and to check for potential trades during backtesting. Not used by VolumePairList (see [below](#dynamic-pairlists)).
**Datatype:** List -| `exchange.pair_blacklist` | List of pairs the bot must absolutely avoid for trading and backtesting (see [below](#dynamic-pairlists)).
**Datatype:** List -| `exchange.ccxt_config` | Additional CCXT parameters passed to the regular 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.pair_whitelist` | List of pairs to use by the bot for trading and to check for potential trades during backtesting. Not used by VolumePairList (see [below](#pairlists-and-pairlist-handlers)).
**Datatype:** List +| `exchange.pair_blacklist` | List of pairs the bot must absolutely avoid for trading and backtesting (see [below](#pairlists-and-pairlist-handlers)).
**Datatype:** List +| `exchange.ccxt_config` | Additional CCXT parameters passed to both ccxt instances (sync and async). This is usually the correct place for ccxt configurations. 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_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 | `edge.*` | Please refer to [edge configuration document](edge.md) for detailed explanation. | `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 -| `pairlists` | Define one or more pairlists to be used. [More information below](#dynamic-pairlists).
*Defaults to `StaticPairList`.*
**Datatype:** List of Dicts +| `pairlists` | Define one or more pairlists to be used. [More information below](#pairlists-and-pairlist-handlers).
*Defaults to `StaticPairList`.*
**Datatype:** List of Dicts | `telegram.enabled` | Enable the usage of Telegram.
**Datatype:** Boolean | `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 @@ -101,14 +103,16 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `api_server.enabled` | Enable usage of API Server. See the [API Server documentation](rest-api.md) for more details.
**Datatype:** Boolean | `api_server.listen_ip_address` | Bind IP address. See the [API Server documentation](rest-api.md) for more details.
**Datatype:** IPv4 | `api_server.listen_port` | Bind Port. See the [API Server documentation](rest-api.md) for more details.
**Datatype:** Integer between 1024 and 65535 +| `api_server.verbosity` | Logging verbosity. `info` will print all RPC Calls, while "error" will only display errors.
**Datatype:** Enum, either `info` or `error`. Defaults to `info`. | `api_server.username` | Username for API server. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `api_server.password` | Password for API server. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `db_url` | Declares database URL to use. NOTE: This defaults to `sqlite:///tradesv3.dryrun.sqlite` if `dry_run` is `true`, and to `sqlite:///tradesv3.sqlite` for production instances.
**Datatype:** String, SQLAlchemy connect string | `initial_state` | Defines the initial application state. More information below.
*Defaults to `stopped`.*
**Datatype:** Enum, either `stopped` or `running` | `forcebuy_enable` | Enables the RPC Commands to force a buy. More information below.
**Datatype:** Boolean +| `disable_dataframe_checks` | Disable checking the OHLCV dataframe returned from the strategy methods for correctness. Only use when intentionally changing the dataframe and understand what you are doing. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `False`*.
**Datatype:** Boolean | `strategy` | **Required** Defines Strategy class to use. Recommended to be set via `--strategy NAME`.
**Datatype:** ClassName | `strategy_path` | Adds an additional strategy lookup path (must be a directory).
**Datatype:** String -| `internals.process_throttle_secs` | Set the process throttle. Value in second.
*Defaults to `5` seconds.*
**Datatype:** Positive Intege +| `internals.process_throttle_secs` | Set the process throttle. Value in second.
*Defaults to `5` seconds.*
**Datatype:** Positive Integer | `internals.heartbeat_interval` | Print heartbeat message every N seconds. Set to 0 to disable heartbeat messages.
*Defaults to `60` seconds.*
**Datatype:** Positive Integer or 0 | `internals.sd_notify` | Enables use of the sd_notify protocol to tell systemd service manager about changes in the bot state and issue keep-alive pings. See [here](installation.md#7-optional-configure-freqtrade-as-a-systemd-service) for more details.
**Datatype:** Boolean | `logfile` | Specifies logfile name. Uses a rolling strategy for log file rotation for 10 files with the 1MB limit per file.
**Datatype:** String @@ -122,7 +126,7 @@ The following parameters can be set in either configuration file or strategy. Values set in the configuration file always overwrite values set in the strategy. * `minimal_roi` -* `ticker_interval` +* `timeframe` * `stoploss` * `trailing_stop` * `trailing_stop_positive` @@ -134,6 +138,7 @@ Values set in the configuration file always overwrite values set in the strategy * `stake_currency` * `stake_amount` * `unfilledtimeout` +* `disable_dataframe_checks` * `use_sell_signal` (ask_strategy) * `sell_profit_only` (ask_strategy) * `ignore_roi_if_buy_signal` (ask_strategy) @@ -213,7 +218,7 @@ To allow the bot to trade all the available `stake_currency` in your account (mi ### Understand minimal_roi The `minimal_roi` configuration parameter is a JSON object where the key is a duration -in minutes and the value is the minimum ROI in percent. +in minutes and the value is the minimum ROI as ratio. See the example below: ```json @@ -267,10 +272,10 @@ the static list of pairs) if we should buy. ### Understand order_types -The `order_types` configuration parameter maps actions (`buy`, `sell`, `stoploss`) to order-types (`market`, `limit`, ...) as well as configures stoploss to be on the exchange and defines stoploss on exchange update interval in seconds. +The `order_types` configuration parameter maps actions (`buy`, `sell`, `stoploss`, `emergencysell`) to order-types (`market`, `limit`, ...) as well as configures stoploss to be on the exchange and defines stoploss on exchange update interval in seconds. This allows to buy using limit orders, sell using -limit-orders, and create stoplosses using using market orders. It also allows to set the +limit-orders, and create stoplosses using market orders. It also allows to set the stoploss "on exchange" which means stoploss order would be placed immediately once the buy order is fulfilled. If `stoploss_on_exchange` and `trailing_stop` are both set, then the bot will use `stoploss_on_exchange_interval` to check and update the stoploss on exchange periodically. @@ -283,8 +288,12 @@ If this is configured, the following 4 values (`buy`, `sell`, `stoploss` and `emergencysell` is an optional value, which defaults to `market` and is used when creating stoploss on exchange orders fails. The below is the default which is used if this is not configured in either strategy or configuration file. -Since `stoploss_on_exchange` uses limit orders, the exchange needs 2 prices, the stoploss_price and the Limit price. -`stoploss` defines the stop-price - and limit should be slightly below this. This defaults to 0.99 / 1% (configurable via `stoploss_on_exchange_limit_ratio`). +Not all Exchanges support `stoploss_on_exchange`. If an exchange supports both limit and market stoploss orders, then the value of `stoploss` will be used to determine the stoploss type. + +If `stoploss_on_exchange` uses limit orders, the exchange needs 2 prices, the stoploss_price and the Limit price. +`stoploss` defines the stop-price - and limit should be slightly below this. + +This defaults to 0.99 / 1% (configurable via `stoploss_on_exchange_limit_ratio`). Calculation example: we bought the asset at 100$. Stop-price is 95$, then limit would be `95 * 0.99 = 94.05$` - so the stoploss will happen between 95$ and 94.05$. @@ -326,7 +335,10 @@ Configuration: refer to [the stoploss documentation](stoploss.md). !!! Note - If `stoploss_on_exchange` is enabled and the stoploss is cancelled manually on the exchange, then the bot will create a new order. + If `stoploss_on_exchange` is enabled and the stoploss is cancelled manually on the exchange, then the bot will create a new stoploss order. + +!!! Warning "Using market orders" + Please read the section [Market order pricing](#market-order-pricing) section when using market orders. !!! Warning "Warning: stoploss_on_exchange failures" If stoploss on exchange creation fails for some reason, then an "emergency sell" is initiated. By default, this will sell the asset using a market order. The order-type for the emergency-sell can be changed by setting the `emergencysell` value in the `order_types` dictionary - however this is not advised. @@ -454,6 +466,9 @@ Prices are always retrieved right before an order is placed, either by querying !!! Note Orderbook data used by Freqtrade are the data retrieved from exchange by the ccxt's function `fetch_order_book()`, i.e. are usually data from the L2-aggregated orderbook, while the ticker data are the structures returned by the ccxt's `fetch_ticker()`/`fetch_tickers()` functions. Refer to the ccxt library [documentation](https://github.com/ccxt/ccxt/wiki/Manual#market-data) for more details. +!!! Warning "Using market orders" + Please read the section [Market order pricing](#market-order-pricing) section when using market orders. + ### Buy price #### Check depth of market @@ -544,32 +559,57 @@ A fixed slot (mirroring `bid_strategy.order_book_top`) can be defined by setting Using `ask_strategy.order_book_max` higher than 1 will result in improper dry-run results (significantly better than real orders executed on exchange), since dry-run assumes orders to be filled almost instantly. It is therefore advised to not use this setting for dry-runs. - #### Sell price without Orderbook enabled When not using orderbook (`ask_strategy.use_order_book=False`), the price at the `ask_strategy.price_side` side (defaults to `"ask"`) from the ticker will be used as the sell price. -## Pairlists +### Market order pricing -Pairlists define the list of pairs that the bot should trade. -There are [`StaticPairList`](#static-pair-list) and dynamic Whitelists available. +When using market orders, prices should be configured to use the "correct" side of the orderbook to allow realistic pricing detection. +Assuming both buy and sell are using market orders, a configuration similar to the following might be used -[`PrecisionFilter`](#precision-filter) and [`PriceFilter`](#price-pair-filter) act as filters, removing low-value pairs. +``` jsonc + "order_types": { + "buy": "market", + "sell": "market" + // ... + }, + "bid_strategy": { + "price_side": "ask", + // ... + }, + "ask_strategy":{ + "price_side": "bid", + // ... + }, +``` -All pairlists can be chained, and a combination of all pairlists will become your new whitelist. Pairlists are executed in the sequence they are configured. You should always configure either `StaticPairList` or `DynamicPairList` as starting pairlists. +Obviously, if only one side is using limit orders, different pricing combinations can be used. -Inactive markets and blacklisted pairs are always removed from the resulting `pair_whitelist`. +## Pairlists and Pairlist Handlers -### Available Pairlists +Pairlist Handlers define the list of pairs (pairlist) that the bot should trade. They are configured in the `pairlists` section of the configuration settings. + +In your configuration, you can use Static Pairlist (defined by the [`StaticPairList`](#static-pair-list) Pairlist Handler) and Dynamic Pairlist (defined by the [`VolumePairList`](#volume-pair-list) Pairlist Handler). + +Additionaly, [`AgeFilter`](#agefilter), [`PrecisionFilter`](#precisionfilter), [`PriceFilter`](#pricefilter), [`ShuffleFilter`](#shufflefilter) and [`SpreadFilter`](#spreadfilter) act as Pairlist Filters, removing certain pairs and/or moving their positions in the pairlist. + +If multiple Pairlist Handlers are used, they are chained and a combination of all Pairlist Handlers forms the resulting pairlist the bot uses for trading and backtesting. Pairlist Handlers are executed in the sequence they are configured. You should always configure either `StaticPairList` or `VolumePairList` as the starting Pairlist Handler. + +Inactive markets are always removed from the resulting pairlist. Explicitly blacklisted pairs (those in the `pair_blacklist` configuration setting) are also always removed from the resulting pairlist. + +### Available Pairlist Handlers * [`StaticPairList`](#static-pair-list) (default, if not configured differently) * [`VolumePairList`](#volume-pair-list) -* [`PrecisionFilter`](#precision-filter) -* [`PriceFilter`](#price-pair-filter) -* [`SpreadFilter`](#spread-filter) +* [`AgeFilter`](#agefilter) +* [`PrecisionFilter`](#precisionfilter) +* [`PriceFilter`](#pricefilter) +* [`ShuffleFilter`](#shufflefilter) +* [`SpreadFilter`](#spreadfilter) !!! Tip "Testing pairlists" - Pairlist configurations can be quite tricky to get right. Best use the [`test-pairlist`](utils.md#test-pairlist) subcommand to test your configuration quickly. + Pairlist configurations can be quite tricky to get right. Best use the [`test-pairlist`](utils.md#test-pairlist) utility subcommand to test your configuration quickly. #### Static Pair List @@ -585,16 +625,16 @@ It uses configuration from `exchange.pair_whitelist` and `exchange.pair_blacklis #### Volume Pair List -`VolumePairList` selects `number_assets` top pairs based on `sort_key`, which can be one of `askVolume`, `bidVolume` and `quoteVolume` and defaults to `quoteVolume`. +`VolumePairList` employs sorting/filtering of pairs by their trading volume. It selects `number_assets` top pairs with sorting based on the `sort_key` (which can only be `quoteVolume`). -`VolumePairList` considers outputs of previous pairlists unless it's the first configured pairlist, it does not consider `pair_whitelist`, but selects the top assets from all available markets (with matching stake-currency) on the exchange. +When used in the chain of Pairlist Handlers in a non-leading position (after StaticPairList and other Pairlist Filters), `VolumePairList` considers outputs of previous Pairlist Handlers, adding its sorting/selection of the pairs by the trading volume. -`refresh_period` allows setting the period (in seconds), at which the pairlist will be refreshed. Defaults to 1800s (30 minutes). +When used on the leading position of the chain of Pairlist Handlers, it does not consider `pair_whitelist` configuration setting, but selects the top assets from all available markets (with matching stake-currency) on the exchange. -`VolumePairList` is based on the ticker data, as reported by the ccxt library: +The `refresh_period` setting allows to define the period (in seconds), at which the pairlist will be refreshed. Defaults to 1800s (30 minutes). + +`VolumePairList` is based on the ticker data from exchange, as reported by the ccxt library: -* The `bidVolume` is the volume (amount) of current best bid in the orderbook. -* The `askVolume` is the volume (amount) of current best ask in the orderbook. * The `quoteVolume` is the amount of quote (stake) currency traded (bought or sold) in last 24 hours. ```json @@ -603,32 +643,63 @@ It uses configuration from `exchange.pair_whitelist` and `exchange.pair_blacklis "number_assets": 20, "sort_key": "quoteVolume", "refresh_period": 1800, -], +}], ``` -#### Precision Filter +#### AgeFilter -Filters low-value coins which would not allow setting a stoploss. +Removes pairs that have been listed on the exchange for less than `min_days_listed` days (defaults to `10`). -#### Price Pair Filter +When pairs are first listed on an exchange they can suffer huge price drops and volatility +in the first few days while the pair goes through its price-discovery period. Bots can often +be caught out buying before the pair has finished dropping in price. -The `PriceFilter` allows filtering of pairs by price. -Currently, only `low_price_ratio` is implemented, where a raise of 1 price unit (pip) is below the `low_price_ratio` ratio. +This filter allows freqtrade to ignore pairs until they have been listed for at least `min_days_listed` days. + +#### PrecisionFilter + +Filters low-value coins which would not allow setting stoplosses. + +#### PriceFilter + +The `PriceFilter` allows filtering of pairs by price. Currently the following price filters are supported: +* `min_price` +* `max_price` +* `low_price_ratio` + +The `min_price` setting removes pairs where the price is below the specified price. This is useful if you wish to avoid trading very low-priced pairs. This option is disabled by default, and will only apply if set to <> 0. -Calculation example: -Min price precision is 8 decimals. If price is 0.00000011 - one step would be 0.00000012 - which is almost 10% higher than the previous value. +The `max_price` setting removes pairs where the price is above the specified price. This is useful if you wish to trade only low-priced pairs. +This option is disabled by default, and will only apply if set to <> 0. + +The `low_price_ratio` setting removes pairs where a raise of 1 price unit (pip) is above the `low_price_ratio` ratio. +This option is disabled by default, and will only apply if set to <> 0. + +Calculation example: + +Min price precision is 8 decimals. If price is 0.00000011 - one step would be 0.00000012 - which is almost 10% higher than the previous value. These pairs are dangerous since it may be impossible to place the desired stoploss - and often result in high losses. -#### Spread Filter -Removes pairs that have a difference between asks and bids above the specified ratio (default `0.005`). +#### ShuffleFilter + +Shuffles (randomizes) pairs in the pairlist. It can be used for preventing the bot from trading some of the pairs more frequently then others when you want all pairs be treated with the same priority. + +!!! Tip + You may set the `seed` value for this Pairlist to obtain reproducible results, which can be useful for repeated backtesting sessions. If `seed` is not set, the pairs are shuffled in the non-repeatable random order. + +#### SpreadFilter + +Removes pairs that have a difference between asks and bids above the specified ratio, `max_spread_ratio` (defaults to `0.005`). + Example: -If `DOGE/BTC` maximum bid is 0.00000026 and minimum ask is 0.00000027 the ratio is calculated as: `1 - bid/ask ~= 0.037` which is `> 0.005` -### Full Pairlist example +If `DOGE/BTC` maximum bid is 0.00000026 and minimum ask is 0.00000027, the ratio is calculated as: `1 - bid/ask ~= 0.037` which is `> 0.005` and this pair will be filtered out. -The below example blacklists `BNB/BTC`, uses `VolumePairList` with `20` assets, sorting by `quoteVolume` and applies both [`PrecisionFilter`](#precision-filter) and [`PriceFilter`](#price-pair-filter), filtering all assets where 1 priceunit is > 1%. +### Full example of Pairlist Handlers + +The below example blacklists `BNB/BTC`, uses `VolumePairList` with `20` assets, sorting pairs by `quoteVolume` and applies both [`PrecisionFilter`](#precisionfilter) and [`PriceFilter`](#price-filter), filtering all assets where 1 priceunit is > 1%. Then the `SpreadFilter` is applied and pairs are finally shuffled with the random seed set to some predefined value. ```json "exchange": { @@ -641,8 +712,11 @@ The below example blacklists `BNB/BTC`, uses `VolumePairList` with `20` assets, "number_assets": 20, "sort_key": "quoteVolume", }, + {"method": "AgeFilter", "min_days_listed": 10}, {"method": "PrecisionFilter"}, - {"method": "PriceFilter", "low_price_ratio": 0.01} + {"method": "PriceFilter", "low_price_ratio": 0.01}, + {"method": "SpreadFilter", "max_spread_ratio": 0.005}, + {"method": "ShuffleFilter", "seed": 42} ], ``` diff --git a/docs/data-download.md b/docs/data-download.md index 903d62854..a2bbec837 100644 --- a/docs/data-download.md +++ b/docs/data-download.md @@ -109,7 +109,7 @@ The following command will convert all candle (OHLCV) data available in `~/.freq It'll also remove original json data files (`--erase` parameter). ``` bash -freqtrade convert-data --format-from json --format-to jsongz --data-dir ~/.freqtrade/data/binance -t 5m 15m --erase +freqtrade convert-data --format-from json --format-to jsongz --datadir ~/.freqtrade/data/binance -t 5m 15m --erase ``` #### Subcommand convert-trade data @@ -155,7 +155,59 @@ The following command will convert all available trade-data in `~/.freqtrade/dat It'll also remove original jsongz data files (`--erase` parameter). ``` bash -freqtrade convert-trade-data --format-from jsongz --format-to json --data-dir ~/.freqtrade/data/kraken --erase +freqtrade convert-trade-data --format-from jsongz --format-to json --datadir ~/.freqtrade/data/kraken --erase +``` + +### Subcommand list-data + +You can get a list of downloaded data using the `list-data` subcommand. + +``` +usage: freqtrade list-data [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] + [--userdir PATH] [--exchange EXCHANGE] + [--data-format-ohlcv {json,jsongz}] + [-p PAIRS [PAIRS ...]] + +optional arguments: + -h, --help show this help message and exit + --exchange EXCHANGE Exchange name (default: `bittrex`). Only valid if no + config is provided. + --data-format-ohlcv {json,jsongz} + Storage format for downloaded candle (OHLCV) data. + (default: `json`). + -p PAIRS [PAIRS ...], --pairs PAIRS [PAIRS ...] + Show profits for only these pairs. Pairs are space- + separated. + +Common arguments: + -v, --verbose Verbose mode (-vv for more, -vvv to get all messages). + --logfile FILE Log to the file specified. Special values are: + 'syslog', 'journald'. See the documentation for more + details. + -V, --version show program's version number and exit + -c PATH, --config PATH + Specify configuration file (default: + `userdir/config.json` or `config.json` whichever + exists). Multiple --config options may be used. Can be + set to `-` to read config from stdin. + -d PATH, --datadir PATH + Path to directory with historical backtesting data. + --userdir PATH, --user-data-dir PATH + Path to userdata directory. +``` + +#### Example list-data + +```bash +> freqtrade list-data --userdir ~/.freqtrade/user_data/ + +Found 33 pair / timeframe combinations. +pairs timeframe +---------- ----------------------------------------- +ADA/BTC 5m, 15m, 30m, 1h, 2h, 4h, 6h, 12h, 1d +ADA/ETH 5m, 15m, 30m, 1h, 2h, 4h, 6h, 12h, 1d +ETH/BTC 5m, 15m, 30m, 1h, 2h, 4h, 6h, 12h, 1d +ETH/USDT 5m, 15m, 30m, 1h, 2h, 4h ``` ### Pairs file diff --git a/docs/deprecated.md b/docs/deprecated.md index 349d41a09..a7b57b10e 100644 --- a/docs/deprecated.md +++ b/docs/deprecated.md @@ -24,3 +24,13 @@ and in freqtrade 2019.7 (master branch). `--live` in the context of backtesting allowed to download the latest tick data for backtesting. Did only download the latest 500 candles, so was ineffective in getting good backtest data. Removed in 2019-7-dev (develop branch) and in freqtrade 2019-8 (master branch) + +### Allow running multiple pairlists in sequence + +The former `"pairlist"` section in the configuration has been removed, and is replaced by `"pairlists"` - being a list to specify a sequence of pairlists. + +The old section of configuration parameters (`"pairlist"`) has been deprecated in 2019.11 and has been removed in 2020.4. + +### deprecation of bidVolume and askVolume from volumepairlist + +Since only quoteVolume can be compared between assets, the other options (bidVolume, askVolume) have been deprecated in 2020.4. diff --git a/docs/developer.md b/docs/developer.md index 34b2f1ba5..036109d5b 100644 --- a/docs/developer.md +++ b/docs/developer.md @@ -92,13 +92,13 @@ docker-compose exec freqtrade_develop /bin/bash You have a great idea for a new pair selection algorithm you would like to try out? Great. Hopefully you also want to contribute this back upstream. -Whatever your motivations are - This should get you off the ground in trying to develop a new Pairlist provider. +Whatever your motivations are - This should get you off the ground in trying to develop a new Pairlist Handler. -First of all, have a look at the [VolumePairList](https://github.com/freqtrade/freqtrade/blob/develop/freqtrade/pairlist/VolumePairList.py) provider, and best copy this file with a name of your new Pairlist Provider. +First of all, have a look at the [VolumePairList](https://github.com/freqtrade/freqtrade/blob/develop/freqtrade/pairlist/VolumePairList.py) Handler, and best copy this file with a name of your new Pairlist Handler. -This is a simple provider, which however serves as a good example on how to start developing. +This is a simple Handler, which however serves as a good example on how to start developing. -Next, modify the classname of the provider (ideally align this with the Filename). +Next, modify the classname of the Handler (ideally align this with the module filename). The base-class provides an instance of the exchange (`self._exchange`) the pairlist manager (`self._pairlistmanager`), as well as the main configuration (`self._config`), the pairlist dedicated configuration (`self._pairlistconfig`) and the absolute position within the list of pairlists. @@ -114,28 +114,44 @@ Now, let's step through the methods which require actions: #### Pairlist configuration -Configuration for PairListProvider is done in the bot configuration file in the element `"pairlist"`. -This Pairlist-object may contain configurations with additional configurations for the configured pairlist. -By convention, `"number_assets"` is used to specify the maximum number of pairs to keep in the whitelist. Please follow this to ensure a consistent user experience. +Configuration for the chain of Pairlist Handlers is done in the bot configuration file in the element `"pairlists"`, an array of configuration parameters for each Pairlist Handlers in the chain. -Additional elements can be configured as needed. `VolumePairList` uses `"sort_key"` to specify the sorting value - however feel free to specify whatever is necessary for your great algorithm to be successfull and dynamic. +By convention, `"number_assets"` is used to specify the maximum number of pairs to keep in the pairlist. Please follow this to ensure a consistent user experience. + +Additional parameters can be configured as needed. For instance, `VolumePairList` uses `"sort_key"` to specify the sorting value - however feel free to specify whatever is necessary for your great algorithm to be successfull and dynamic. #### short_desc Returns a description used for Telegram messages. -This should contain the name of the Provider, as well as a short description containing the number of assets. Please follow the format `"PairlistName - top/bottom X pairs"`. + +This should contain the name of the Pairlist Handler, as well as a short description containing the number of assets. Please follow the format `"PairlistName - top/bottom X pairs"`. + +#### gen_pairlist + +Override this method if the Pairlist Handler can be used as the leading Pairlist Handler in the chain, defining the initial pairlist which is then handled by all Pairlist Handlers in the chain. Examples are `StaticPairList` and `VolumePairList`. + +This is called with each iteration of the bot (only if the Pairlist Handler is at the first location) - so consider implementing caching for compute/network heavy calculations. + +It must return the resulting pairlist (which may then be passed into the chain of Pairlist Handlers). + +Validations are optional, the parent class exposes a `_verify_blacklist(pairlist)` and `_whitelist_for_active_markets(pairlist)` to do default filtering. Use this if you limit your result to a certain number of pairs - so the endresult is not shorter than expected. #### filter_pairlist -Override this method and run all calculations needed in this method. +This method is called for each Pairlist Handler in the chain by the pairlist manager. + This is called with each iteration of the bot - so consider implementing caching for compute/network heavy calculations. It get's passed a pairlist (which can be the result of previous pairlists) as well as `tickers`, a pre-fetched version of `get_tickers()`. -It must return the resulting pairlist (which may then be passed into the next pairlist filter). +The default implementation in the base class simply calls the `_validate_pair()` method for each pair in the pairlist, but you may override it. So you should either implement the `_validate_pair()` in your Pairlist Handler or override `filter_pairlist()` to do something else. + +If overridden, it must return the resulting pairlist (which may then be passed into the next Pairlist Handler in the chain). Validations are optional, the parent class exposes a `_verify_blacklist(pairlist)` and `_whitelist_for_active_markets(pairlist)` to do default filters. Use this if you limit your result to a certain number of pairs - so the endresult is not shorter than expected. +In `VolumePairList`, this implements different methods of sorting, does early validation so only the expected number of pairs is returned. + ##### sample ``` python @@ -145,11 +161,6 @@ Validations are optional, the parent class exposes a `_verify_blacklist(pairlist return pairs ``` -#### _gen_pair_whitelist - -This is a simple method used by `VolumePairList` - however serves as a good example. -In VolumePairList, this implements different methods of sorting, does early validation so only the expected number of pairs is returned. - ## Implement a new Exchange (WIP) !!! Note diff --git a/docs/docker.md b/docs/docker.md index cd24994bc..92478088a 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -22,6 +22,9 @@ Freqtrade provides an official Docker image on [Dockerhub](https://hub.docker.co !!! Note All below comands use relative directories and will have to be executed from the directory containing the `docker-compose.yml` file. +!!! Note "Docker on Raspberry" + If you're running freqtrade on a Raspberry PI, you must change the image from `freqtradeorg/freqtrade:master` to `freqtradeorg/freqtrade:master_pi` or `freqtradeorg/freqtrade:develop_pi`, otherwise the image will not work. + ### Docker quick start Create a new directory and place the [docker-compose file](https://github.com/freqtrade/freqtrade/blob/develop/docker-compose.yml) in this directory. @@ -65,7 +68,7 @@ docker-compose up -d #### Docker-compose logs -Logs will be written to `user_data/freqtrade.log`. +Logs will be written to `user_data/logs/freqtrade.log`. Alternatively, you can check the latest logs using `docker-compose logs -f`. #### Database diff --git a/docs/edge.md b/docs/edge.md index 029844c0b..c91e72a3a 100644 --- a/docs/edge.md +++ b/docs/edge.md @@ -148,7 +148,6 @@ Edge module has following configuration options: | `enabled` | If true, then Edge will run periodically.
*Defaults to `false`.*
**Datatype:** Boolean | `process_throttle_secs` | How often should Edge run in seconds.
*Defaults to `3600` (once per hour).*
**Datatype:** Integer | `calculate_since_number_of_days` | Number of days of data against which Edge calculates Win Rate, Risk Reward and Expectancy.
**Note** that it downloads historical data so increasing this number would lead to slowing down the bot.
*Defaults to `7`.*
**Datatype:** Integer -| `capital_available_percentage` | **DEPRECATED - [replaced with `tradable_balance_ratio`](configuration.md#Available balance)** This is the percentage of the total capital on exchange in stake currency.
As an example if you have 10 ETH available in your wallet on the exchange and this value is 0.5 (which is 50%), then the bot will use a maximum amount of 5 ETH for trading and considers it as available capital.
*Defaults to `0.5`.*
**Datatype:** Float | `allowed_risk` | Ratio of allowed risk per trade.
*Defaults to `0.01` (1%)).*
**Datatype:** Float | `stoploss_range_min` | Minimum stoploss.
*Defaults to `-0.01`.*
**Datatype:** Float | `stoploss_range_max` | Maximum stoploss.
*Defaults to `-0.10`.*
**Datatype:** Float @@ -156,7 +155,7 @@ Edge module has following configuration options: | `minimum_winrate` | It filters out pairs which don't have at least minimum_winrate.
This comes handy if you want to be conservative and don't comprise win rate in favour of risk reward ratio.
*Defaults to `0.60`.*
**Datatype:** Float | `minimum_expectancy` | It filters out pairs which have the expectancy lower than this number.
Having an expectancy of 0.20 means if you put 10$ on a trade you expect a 12$ return.
*Defaults to `0.20`.*
**Datatype:** Float | `min_trade_number` | When calculating *W*, *R* and *E* (expectancy) against historical data, you always want to have a minimum number of trades. The more this number is the more Edge is reliable.
Having a win rate of 100% on a single trade doesn't mean anything at all. But having a win rate of 70% over past 100 trades means clearly something.
*Defaults to `10` (it is highly recommended not to decrease this number).*
**Datatype:** Integer -| `max_trade_duration_minute` | Edge will filter out trades with long duration. If a trade is profitable after 1 month, it is hard to evaluate the strategy based on it. But if most of trades are profitable and they have maximum duration of 30 minutes, then it is clearly a good sign.
**NOTICE:** While configuring this value, you should take into consideration your timeframe (ticker interval). As an example filtering out trades having duration less than one day for a strategy which has 4h interval does not make sense. Default value is set assuming your strategy interval is relatively small (1m or 5m, etc.).
*Defaults to `1440` (one day).*
**Datatype:** Integer +| `max_trade_duration_minute` | Edge will filter out trades with long duration. If a trade is profitable after 1 month, it is hard to evaluate the strategy based on it. But if most of trades are profitable and they have maximum duration of 30 minutes, then it is clearly a good sign.
**NOTICE:** While configuring this value, you should take into consideration your timeframe. As an example filtering out trades having duration less than one day for a strategy which has 4h interval does not make sense. Default value is set assuming your strategy interval is relatively small (1m or 5m, etc.).
*Defaults to `1440` (one day).*
**Datatype:** Integer | `remove_pumps` | Edge will remove sudden pumps in a given market while going through historical data. However, given that pumps happen very often in crypto markets, we recommend you keep this off.
*Defaults to `false`.*
**Datatype:** Boolean ## Running Edge independently diff --git a/docs/exchanges.md b/docs/exchanges.md index 06db26f89..fcf7c1cad 100644 --- a/docs/exchanges.md +++ b/docs/exchanges.md @@ -30,6 +30,15 @@ Binance has been split into 3, and users must use the correct ccxt exchange ID f The Kraken API does only provide 720 historic candles, which is sufficient for Freqtrade dry-run and live trade modes, but is a problem for backtesting. To download data for the Kraken exchange, using `--dl-trades` is mandatory, otherwise the bot will download the same 720 candles over and over, and you'll not have enough backtest data. +Due to the heavy rate-limiting applied by Kraken, the following configuration section should be used to download data: + +``` json + "ccxt_async_config": { + "enableRateLimit": true, + "rateLimit": 3100 + }, +``` + ## Bittrex ### Order types @@ -62,6 +71,30 @@ res = [ f"{x['MarketCurrency']}/{x['BaseCurrency']}" for x in ct.publicGetMarket print(res) ``` +## FTX + +!!! Tip "Stoploss on Exchange" + FTX supports `stoploss_on_exchange` and can use both stop-loss-market and stop-loss-limit orders. It provides great advantages, so we recommend to benefit from it. + You can use either `"limit"` or `"market"` in the `order_types.stoploss` configuration setting to decide. + + +### Using subaccounts + +To use subaccounts with FTX, you need to edit the configuration and add the following: + +``` json +"exchange": { + "ccxt_config": { + "headers": { + "FTX-SUBACCOUNT": "name" + } + }, +} +``` + +!!! Note + Older versions of freqtrade may require this key to be added to `"ccxt_async_config"` as well. + ## All exchanges Should you experience constant errors with Nonce (like `InvalidNonce`), it is best to regenerate the API keys. Resetting Nonce is difficult and it's usually easier to regenerate the API keys. diff --git a/docs/faq.md b/docs/faq.md index 8e8a1bf35..48f52a566 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -1,5 +1,9 @@ # Freqtrade FAQ +## Beginner Tips & Tricks + +* When you work with your strategy & hyperopt file you should use a proper code editor like vscode or Pycharm. A good code editor will provide syntax highlighting as well as line numbers, making it easy to find syntax errors (most likely, pointed out by Freqtrade during startup). + ## Freqtrade common issues ### The bot does not start @@ -15,10 +19,12 @@ This could have the following reasons: ### I have waited 5 minutes, why hasn't the bot made any trades yet?! -Depending on the buy strategy, the amount of whitelisted coins, the +* Depending on the buy strategy, the amount of whitelisted coins, the situation of the market etc, it can take up to hours to find good entry position for a trade. Be patient! +* Or it may because of a configuration error? Best check the logs, it's usually telling you if the bot is simply not getting buy signals (only heartbeat messages), or if there is something wrong (errors / exceptions in the log). + ### I have made 12 trades already, why is my total profit negative?! I understand your disappointment but unfortunately 12 trades is just @@ -45,6 +51,20 @@ the tutorial [here|Testing-new-strategies-with-Hyperopt](bot-usage.md#hyperopt-c You can use the `/forcesell all` command from Telegram. +### I want to run multiple bots on the same machine + +Please look at the [advanced setup documentation Page](advanced-setup.md#running-multiple-instances-of-freqtrade). + +### I'm getting "Missing data fillup" messages in the log + +This message is just a warning that the latest candles had missing candles in them. +Depending on the exchange, this can indicate that the pair didn't have a trade for the timeframe you are using - and the exchange does only return candles with volume. +On low volume pairs, this is a rather common occurance. + +If this happens for all pairs in the pairlist, this might indicate a recent exchange downtime. Please check your exchange's public channels for details. + +Irrespectively of the reason, Freqtrade will fill up these candles with "empty" candles, where open, high, low and close are set to the previous candle close - and volume is empty. In a chart, this will look like a `_` - and is aligned with how exchanges usually represent 0 volume candles. + ### I'm getting the "RESTRICTED_MARKET" message in the log Currently known to happen for US Bittrex users. @@ -115,25 +135,27 @@ to find a great result (unless if you are very lucky), so you probably have to run it for 10.000 or more. But it will take an eternity to compute. -We recommend you to run it at least 10.000 epochs: +Since hyperopt uses Bayesian search, running for too many epochs may not produce greater results. + +It's therefore recommended to run between 500-1000 epochs over and over until you hit at least 10.000 epocs in total (or are satisfied with the result). You can best judge by looking at the results - if the bot keeps discovering better strategies, it's best to keep on going. ```bash -freqtrade hyperopt -e 10000 +freqtrade hyperopt -e 1000 ``` or if you want intermediate result to see ```bash -for i in {1..100}; do freqtrade hyperopt -e 100; done +for i in {1..100}; do freqtrade hyperopt -e 1000; done ``` -### Why it is so long to run hyperopt? +### Why does it take a long time to run hyperopt? -Finding a great Hyperopt results takes time. +* Discovering a great strategy with Hyperopt takes time. Study www.freqtrade.io, the Freqtrade Documentation page, join the Freqtrade [Slack community](https://join.slack.com/t/highfrequencybot/shared_invite/enQtNjU5ODcwNjI1MDU3LTU1MTgxMjkzNmYxNWE1MDEzYzQ3YmU4N2MwZjUyNjJjODRkMDVkNjg4YTAyZGYzYzlhOTZiMTE4ZjQ4YzM0OGE) - or the Freqtrade [discord community](https://discord.gg/X89cVG). While you patiently wait for the most advanced, free crypto bot in the world, to hand you a possible golden strategy specially designed just for you. -If you wonder why it takes a while to find great hyperopt results +* If you wonder why it can take from 20 minutes to days to do 1000 epocs here are some answers: -This answer was written during the under the release 0.15.1, when we had: +This answer was written during the release 0.15.1, when we had: - 8 triggers - 9 guards: let's say we evaluate even 10 values from each @@ -143,7 +165,14 @@ The following calculation is still very rough and not very precise but it will give the idea. With only these triggers and guards there is already 8\*10^9\*10 evaluations. A roughly total of 80 billion evals. Did you run 100 000 evals? Congrats, you've done roughly 1 / 100 000 th -of the search space. +of the search space, assuming that the bot never tests the same parameters more than once. + +* The time it takes to run 1000 hyperopt epocs depends on things like: The available cpu, harddisk, ram, timeframe, timerange, indicator settings, indicator count, amount of coins that hyperopt test strategies on and the resulting trade count - which can be 650 trades in a year or 10.0000 trades depending if the strategy aims for big profits by trading rarely or for many low profit trades. + +Example: 4% profit 650 times vs 0,3% profit a trade 10.000 times in a year. If we assume you set the --timerange to 365 days. + +Example: +`freqtrade --config config.json --strategy SampleStrategy --hyperopt SampleHyperopt -e 1000 --timerange 20190601-20200601` ## Edge module diff --git a/docs/hyperopt.md b/docs/hyperopt.md index ad812a5ad..efb11e188 100644 --- a/docs/hyperopt.md +++ b/docs/hyperopt.md @@ -6,9 +6,7 @@ algorithms included in the `scikit-optimize` package to accomplish this. The search will burn all your CPU cores, make your laptop sound like a fighter jet and still take a long time. -In general, the search for best parameters starts with a few random combinations and then uses Bayesian search with a -ML regressor algorithm (currently ExtraTreesRegressor) to quickly find a combination of parameters in the search hyperspace -that minimizes the value of the [loss function](#loss-functions). +In general, the search for best parameters starts with a few random combinations (see [below](#reproducible-results) for more details) and then uses Bayesian search with a ML regressor algorithm (currently ExtraTreesRegressor) to quickly find a combination of parameters in the search hyperspace that minimizes the value of the [loss function](#loss-functions). Hyperopt requires historic data to be available, just as backtesting does. 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. @@ -65,6 +63,9 @@ Optional - can also be loaded from a strategy: !!! Note Assuming the optional methods are not in your hyperopt file, please use `--strategy AweSomeStrategy` which contains these methods so hyperopt can use these methods instead. +!!! Note + You always have to provide a strategy to Hyperopt, even if your custom Hyperopt class contains all methods. + Rarely you may also need to override: * `roi_space` - for custom ROI optimization (if you need the ranges for the ROI parameters in the optimization hyperspace that differ from default) @@ -123,9 +124,9 @@ To avoid naming collisions in the search-space, please prefix all sell-spaces wi #### Using timeframe as a part of the Strategy -The Strategy class exposes the timeframe (ticker interval) value as the `self.ticker_interval` attribute. -The same value is available as class-attribute `HyperoptName.ticker_interval`. -In the case of the linked sample-value this would be `SampleHyperOpt.ticker_interval`. +The Strategy class exposes the timeframe value as the `self.timeframe` attribute. +The same value is available as class-attribute `HyperoptName.timeframe`. +In the case of the linked sample-value this would be `SampleHyperOpt.timeframe`. ## Solving a Mystery @@ -264,7 +265,7 @@ freqtrade hyperopt --timerange 20180401-20180501 Hyperopt can reuse `populate_indicators`, `populate_buy_trend`, `populate_sell_trend` from your strategy, assuming these methods are **not** in your custom hyperopt file, and a strategy is provided. ```bash -freqtrade hyperopt --strategy SampleStrategy --customhyperopt SampleHyperopt +freqtrade hyperopt --strategy SampleStrategy --hyperopt SampleHyperopt ``` ### Running Hyperopt with Smaller Search Space @@ -311,7 +312,7 @@ You can also enable position stacking in the configuration file by explicitly se ### Reproducible results -The search for optimal parameters starts with a few (currently 30) random combinations in the hyperspace of parameters, random Hyperopt epochs. These random epochs are marked with a leading asterisk sign at the Hyperopt output. +The search for optimal parameters starts with a few (currently 30) random combinations in the hyperspace of parameters, random Hyperopt epochs. These random epochs are marked with an asterisk character (`*`) in the first column in the Hyperopt output. The initial state for generation of these random values (random state) is controlled by the value of the `--random-state` command line option. You can set it to some arbitrary value of your choice to obtain reproducible results. @@ -402,7 +403,7 @@ As stated in the comment, you can also use it as the value of the `minimal_roi` #### Default ROI Search Space -If you are optimizing ROI, Freqtrade creates the 'roi' optimization hyperspace for you -- it's the hyperspace of components for the ROI tables. By default, each ROI table generated by the Freqtrade consists of 4 rows (steps). Hyperopt implements adaptive ranges for ROI tables with ranges for values in the ROI steps that depend on the ticker_interval used. By default the values vary in the following ranges (for some of the most used timeframes, values are rounded to 5 digits after the decimal point): +If you are optimizing ROI, Freqtrade creates the 'roi' optimization hyperspace for you -- it's the hyperspace of components for the ROI tables. By default, each ROI table generated by the Freqtrade consists of 4 rows (steps). Hyperopt implements adaptive ranges for ROI tables with ranges for values in the ROI steps that depend on the timeframe used. By default the values vary in the following ranges (for some of the most used timeframes, values are rounded to 5 digits after the decimal point): | # step | 1m | | 5m | | 1h | | 1d | | | ------ | ------ | ----------------- | -------- | ----------- | ---------- | ----------------- | ------------ | ----------------- | @@ -411,7 +412,7 @@ If you are optimizing ROI, Freqtrade creates the 'roi' optimization hyperspace f | 3 | 4...20 | 0.00387...0.01547 | 20...100 | 0.01...0.04 | 240...1200 | 0.02294...0.09177 | 5760...28800 | 0.04059...0.16237 | | 4 | 6...44 | 0.0 | 30...220 | 0.0 | 360...2640 | 0.0 | 8640...63360 | 0.0 | -These ranges should be sufficient in most cases. The minutes in the steps (ROI dict keys) are scaled linearly depending on the timeframe (ticker interval) used. The ROI values in the steps (ROI dict values) are scaled logarithmically depending on the timeframe used. +These ranges should be sufficient in most cases. The minutes in the steps (ROI dict keys) are scaled linearly depending on the timeframe used. The ROI values in the steps (ROI dict values) are scaled logarithmically depending on the timeframe used. If you have the `generate_roi_table()` and `roi_space()` methods in your custom hyperopt file, remove them in order to utilize these adaptive ROI tables and the ROI hyperoptimization space generated by Freqtrade by default. @@ -486,7 +487,7 @@ As stated in the comment, you can also use it as the values of the corresponding If you are optimizing trailing stop values, Freqtrade creates the 'trailing' optimization hyperspace for you. By default, the `trailing_stop` parameter is always set to True in that hyperspace, the value of the `trailing_only_offset_is_reached` vary between True and False, the values of the `trailing_stop_positive` and `trailing_stop_positive_offset` parameters vary in the ranges 0.02...0.35 and 0.01...0.1 correspondingly, which is sufficient in most cases. -Override the `trailing_space()` method and define the desired range in it if you need values of the trailing stop parameters to vary in other ranges during hyperoptimization. A sample for this method can be found in [user_data/hyperopts/sample_hyperopt_advanced.py](https://github.com/freqtrade/freqtrade/blob/develop/user_data/hyperopts/sample_hyperopt_advanced.py). +Override the `trailing_space()` method and define the desired range in it if you need values of the trailing stop parameters to vary in other ranges during hyperoptimization. A sample for this method can be found in [user_data/hyperopts/sample_hyperopt_advanced.py](https://github.com/freqtrade/freqtrade/blob/develop/freqtrade/templates/sample_hyperopt_advanced.py). ## Show details of Hyperopt results @@ -497,8 +498,3 @@ After you run Hyperopt for the desired amount of epochs, you can later list all Once the optimized strategy has been implemented into your strategy, you should backtest this strategy to make sure everything is working as expected. To achieve same results (number of trades, their durations, profit, etc.) than during Hyperopt, please use same set of arguments `--dmmp`/`--disable-max-market-positions` and `--eps`/`--enable-position-stacking` for Backtesting. - -## Next Step - -Now you have a perfect bot and want to control it from Telegram. Your -next step is to learn the [Telegram usage](telegram-usage.md). diff --git a/docs/installation.md b/docs/installation.md index 88e2ef6eb..c03be55d1 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -13,7 +13,7 @@ Click each one for install guide: * [Python >= 3.6.x](http://docs.python-guide.org/en/latest/starting/installation/) * [pip](https://pip.pypa.io/en/stable/installing/) * [git](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) -* [virtualenv](https://virtualenv.pypa.io/en/stable/installation/) (Recommended) +* [virtualenv](https://virtualenv.pypa.io/en/stable/installation.html) (Recommended) * [TA-Lib](https://mrjbq7.github.io/ta-lib/install.html) (install instructions below) We also recommend a [Telegram bot](telegram-usage.md#setup-your-telegram-bot), which is optional but recommended. @@ -248,14 +248,14 @@ git clone https://github.com/freqtrade/freqtrade.git Install ta-lib according to the [ta-lib documentation](https://github.com/mrjbq7/ta-lib#windows). -As compiling from source on windows has heavy dependencies (requires a partial visual studio installation), there is also a repository of unofficial precompiled windows Wheels [here](https://www.lfd.uci.edu/~gohlke/pythonlibs/#ta-lib), which needs to be downloaded and installed using `pip install TA_Lib‑0.4.17‑cp36‑cp36m‑win32.whl` (make sure to use the version matching your python version) +As compiling from source on windows has heavy dependencies (requires a partial visual studio installation), there is also a repository of unofficial precompiled windows Wheels [here](https://www.lfd.uci.edu/~gohlke/pythonlibs/#ta-lib), which needs to be downloaded and installed using `pip install TA_Lib‑0.4.18‑cp38‑cp38‑win_amd64.whl` (make sure to use the version matching your python version) ```cmd >cd \path\freqtrade-develop >python -m venv .env >.env\Scripts\activate.bat REM optionally install ta-lib from wheel -REM >pip install TA_Lib‑0.4.17‑cp36‑cp36m‑win32.whl +REM >pip install TA_Lib‑0.4.18‑cp38‑cp38‑win_amd64.whl >pip install -r requirements.txt >pip install -e . >freqtrade diff --git a/docs/plotting.md b/docs/plotting.md index be83065a6..d3a2df1c1 100644 --- a/docs/plotting.md +++ b/docs/plotting.md @@ -31,7 +31,7 @@ usage: freqtrade plot-dataframe [-h] [-v] [--logfile FILE] [-V] [-c PATH] [--plot-limit INT] [--db-url PATH] [--trade-source {DB,file}] [--export EXPORT] [--export-filename PATH] - [--timerange TIMERANGE] [-i TICKER_INTERVAL] + [--timerange TIMERANGE] [-i TIMEFRAME] [--no-trades] optional arguments: @@ -65,7 +65,7 @@ optional arguments: _today.json` --timerange TIMERANGE Specify what timerange of data to use. - -i TICKER_INTERVAL, --ticker-interval TICKER_INTERVAL + -i TIMEFRAME, --timeframe TIMEFRAME, --ticker-interval TIMEFRAME Specify ticker interval (`1m`, `5m`, `30m`, `1h`, `1d`). --no-trades Skip using trades from backtesting file and DB. @@ -227,7 +227,7 @@ usage: freqtrade plot-profit [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] [--userdir PATH] [-p PAIRS [PAIRS ...]] [--timerange TIMERANGE] [--export EXPORT] [--export-filename PATH] [--db-url PATH] - [--trade-source {DB,file}] [-i TICKER_INTERVAL] + [--trade-source {DB,file}] [-i TIMEFRAME] optional arguments: -h, --help show this help message and exit @@ -250,7 +250,7 @@ optional arguments: --trade-source {DB,file} Specify the source for trades (Can be DB or file (backtest file)) Default: file - -i TICKER_INTERVAL, --ticker-interval TICKER_INTERVAL + -i TIMEFRAME, --timeframe TIMEFRAME, --ticker-interval TIMEFRAME Specify ticker interval (`1m`, `5m`, `30m`, `1h`, `1d`). @@ -261,9 +261,10 @@ Common arguments: details. -V, --version show program's version number and exit -c PATH, --config PATH - Specify configuration file (default: `config.json`). - Multiple --config options may be used. Can be set to - `-` to read config from stdin. + Specify configuration file (default: + `userdir/config.json` or `config.json` whichever + exists). Multiple --config options may be used. Can be + set to `-` to read config from stdin. -d PATH, --datadir PATH Path to directory with historical backtesting data. --userdir PATH, --user-data-dir PATH diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index 1f8d710a9..4068e364b 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,2 +1,2 @@ -mkdocs-material==5.1.1 +mkdocs-material==5.5.3 mdx_truly_sane_lists==1.2 diff --git a/docs/rest-api.md b/docs/rest-api.md index b68364f39..68754f79a 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -11,6 +11,9 @@ Sample configuration: "enabled": true, "listen_ip_address": "127.0.0.1", "listen_port": 8080, + "verbosity": "info", + "jwt_secret_key": "somethingrandom", + "CORS_origins": [], "username": "Freqtrader", "password": "SuperSecret1!" }, @@ -29,7 +32,7 @@ This should return the response: {"status":"pong"} ``` -All other endpoints return sensitive info and require authentication, so are not available through a web browser. +All other endpoints return sensitive info and require authentication and are therefore not available through a web browser. To generate a secure password, either use a password manager, or use the below code snipped. @@ -38,9 +41,12 @@ import secrets secrets.token_hex() ``` +!!! Hint + Use the same method to also generate a JWT secret key (`jwt_secret_key`). + ### Configuration with docker -If you run your bot using docker, you'll need to have the bot listen to incomming connections. The security is then handled by docker. +If you run your bot using docker, you'll need to have the bot listen to incoming connections. The security is then handled by docker. ``` json "api_server": { @@ -100,26 +106,29 @@ python3 scripts/rest_client.py --config rest_config.json [optional par ## Available commands -| Command | Default | Description | -|----------|---------|-------------| -| `start` | | Starts the trader -| `stop` | | Stops the trader -| `stopbuy` | | Stops the trader from opening new trades. Gracefully closes open trades according to their rules. -| `reload_conf` | | Reloads the configuration file -| `show_config` | | Shows part of the current configuration with relevant settings to operation -| `status` | | Lists all open trades -| `count` | | Displays number of trades used and available -| `profit` | | Display a summary of your profit/loss from close trades and some stats about your performance -| `forcesell ` | | Instantly sells the given trade (Ignoring `minimum_roi`). -| `forcesell all` | | Instantly sells all open trades (Ignoring `minimum_roi`). -| `forcebuy [rate]` | | Instantly buys the given pair. Rate is optional. (`forcebuy_enable` must be set to True) -| `performance` | | Show performance of each finished trade grouped by pair -| `balance` | | Show account balance per currency -| `daily ` | 7 | Shows profit or loss per day, over the last n days -| `whitelist` | | Show the current whitelist -| `blacklist [pair]` | | Show the current blacklist, or adds a pair to the blacklist. -| `edge` | | Show validated pairs by Edge if it is enabled. -| `version` | | Show version +| Command | Description | +|----------|-------------| +| `ping` | Simple command testing the API Readiness - requires no authentication. +| `start` | Starts the trader +| `stop` | Stops the trader +| `stopbuy` | Stops the trader from opening new trades. Gracefully closes open trades according to their rules. +| `reload_config` | Reloads the configuration file +| `trades` | List last trades. +| `delete_trade ` | Remove trade from the database. Tries to close open orders. Requires manual handling of this trade on the exchange. +| `show_config` | Shows part of the current configuration with relevant settings to operation +| `status` | Lists all open trades +| `count` | Displays number of trades used and available +| `profit` | Display a summary of your profit/loss from close trades and some stats about your performance +| `forcesell ` | Instantly sells the given trade (Ignoring `minimum_roi`). +| `forcesell all` | Instantly sells all open trades (Ignoring `minimum_roi`). +| `forcebuy [rate]` | Instantly buys the given pair. Rate is optional. (`forcebuy_enable` must be set to True) +| `performance` | Show performance of each finished trade grouped by pair +| `balance` | Show account balance per currency +| `daily ` | Shows profit or loss per day, over the last n days (n defaults to 7) +| `whitelist` | Show the current whitelist +| `blacklist [pair]` | Show the current blacklist, or adds a pair to the blacklist. +| `edge` | Show validated pairs by Edge if it is enabled. +| `version` | Show version Possible commands can be listed from the rest-client script using the `help` command. @@ -169,7 +178,7 @@ profit Returns the profit summary :returns: json object -reload_conf +reload_config Reload configuration :returns: json object @@ -191,7 +200,7 @@ stop stopbuy Stop buying (but handle sells gracefully). - use reload_conf to reset + use reload_config to reset :returns: json object version @@ -202,3 +211,51 @@ whitelist Show the current whitelist :returns: json object ``` + +## Advanced API usage using JWT tokens + +!!! Note + The below should be done in an application (a Freqtrade REST API client, which fetches info via API), and is not intended to be used on a regular basis. + +Freqtrade's REST API also offers JWT (JSON Web Tokens). +You can login using the following command, and subsequently use the resulting access_token. + +``` bash +> curl -X POST --user Freqtrader http://localhost:8080/api/v1/token/login +{"access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE1ODkxMTk2ODEsIm5iZiI6MTU4OTExOTY4MSwianRpIjoiMmEwYmY0NWUtMjhmOS00YTUzLTlmNzItMmM5ZWVlYThkNzc2IiwiZXhwIjoxNTg5MTIwNTgxLCJpZGVudGl0eSI6eyJ1IjoiRnJlcXRyYWRlciJ9LCJmcmVzaCI6ZmFsc2UsInR5cGUiOiJhY2Nlc3MifQ.qt6MAXYIa-l556OM7arBvYJ0SDI9J8bIk3_glDujF5g","refresh_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE1ODkxMTk2ODEsIm5iZiI6MTU4OTExOTY4MSwianRpIjoiZWQ1ZWI3YjAtYjMwMy00YzAyLTg2N2MtNWViMjIxNWQ2YTMxIiwiZXhwIjoxNTkxNzExNjgxLCJpZGVudGl0eSI6eyJ1IjoiRnJlcXRyYWRlciJ9LCJ0eXBlIjoicmVmcmVzaCJ9.d1AT_jYICyTAjD0fiQAr52rkRqtxCjUGEMwlNuuzgNQ"} + +> access_token="eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE1ODkxMTk2ODEsIm5iZiI6MTU4OTExOTY4MSwianRpIjoiMmEwYmY0NWUtMjhmOS00YTUzLTlmNzItMmM5ZWVlYThkNzc2IiwiZXhwIjoxNTg5MTIwNTgxLCJpZGVudGl0eSI6eyJ1IjoiRnJlcXRyYWRlciJ9LCJmcmVzaCI6ZmFsc2UsInR5cGUiOiJhY2Nlc3MifQ.qt6MAXYIa-l556OM7arBvYJ0SDI9J8bIk3_glDujF5g" +# Use access_token for authentication +> curl -X GET --header "Authorization: Bearer ${access_token}" http://localhost:8080/api/v1/count + +``` + +Since the access token has a short timeout (15 min) - the `token/refresh` request should be used periodically to get a fresh access token: + +``` bash +> curl -X POST --header "Authorization: Bearer ${refresh_token}"http://localhost:8080/api/v1/token/refresh +{"access_token":"eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpYXQiOjE1ODkxMTk5NzQsIm5iZiI6MTU4OTExOTk3NCwianRpIjoiMDBjNTlhMWUtMjBmYS00ZTk0LTliZjAtNWQwNTg2MTdiZDIyIiwiZXhwIjoxNTg5MTIwODc0LCJpZGVudGl0eSI6eyJ1IjoiRnJlcXRyYWRlciJ9LCJmcmVzaCI6ZmFsc2UsInR5cGUiOiJhY2Nlc3MifQ.1seHlII3WprjjclY6DpRhen0rqdF4j6jbvxIhUFaSbs"} +``` + +## CORS + +All web-based frontends are subject to [CORS](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) - Cross-Origin Resource Sharing. +Since most of the requests to the Freqtrade API must be authenticated, a proper CORS policy is key to avoid security problems. +Also, the standard disallows `*` CORS policies for requests with credentials, so this setting must be set appropriately. + +Users can configure this themselves via the `CORS_origins` configuration setting. +It consists of a list of allowed sites that are allowed to consume resources from the bot's API. + +Assuming your application is deployed as `https://frequi.freqtrade.io/home/` - this would mean that the following configuration becomes necessary: + +```jsonc +{ + //... + "jwt_secret_key": "somethingrandom", + "CORS_origins": ["https://frequi.freqtrade.io"], + //... +} +``` + +!!! Note + We strongly recommend to also set `jwt_secret_key` to something random and known only to yourself to avoid unauthorized access to your bot. diff --git a/docs/sql_cheatsheet.md b/docs/sql_cheatsheet.md index b7b38c3dc..748b16928 100644 --- a/docs/sql_cheatsheet.md +++ b/docs/sql_cheatsheet.md @@ -1,13 +1,29 @@ # SQL Helper + This page contains some help if you want to edit your sqlite db. ## Install sqlite3 -**Ubuntu/Debian installation** + +Sqlite3 is a terminal based sqlite application. +Feel free to use a visual Database editor like SqliteBrowser if you feel more comfortable with that. + +### Ubuntu/Debian installation + ```bash sudo apt-get install sqlite3 ``` +### Using sqlite3 via docker-compose + +The freqtrade docker image does contain sqlite3, so you can edit the database without having to install anything on the host system. + +``` bash +docker-compose exec freqtrade /bin/bash +sqlite3 .sqlite +``` + ## Open the DB + ```bash sqlite3 .open @@ -16,45 +32,61 @@ sqlite3 ## Table structure ### List tables + ```bash .tables ``` ### Display table structure + ```bash .schema ``` ### Trade table structure + ```sql -CREATE TABLE trades ( - id INTEGER NOT NULL, - exchange VARCHAR NOT NULL, - pair VARCHAR NOT NULL, - is_open BOOLEAN NOT NULL, - fee_open FLOAT NOT NULL, - fee_close FLOAT NOT NULL, - open_rate FLOAT, - open_rate_requested FLOAT, - close_rate FLOAT, - close_rate_requested FLOAT, - close_profit FLOAT, - stake_amount FLOAT NOT NULL, - amount FLOAT, - open_date DATETIME NOT NULL, - close_date DATETIME, - open_order_id VARCHAR, - stop_loss FLOAT, - initial_stop_loss FLOAT, - stoploss_order_id VARCHAR, - stoploss_last_update DATETIME, - max_rate FLOAT, - sell_reason VARCHAR, - strategy VARCHAR, - ticker_interval INTEGER, - PRIMARY KEY (id), - CHECK (is_open IN (0, 1)) +CREATE TABLE trades + id INTEGER NOT NULL, + exchange VARCHAR NOT NULL, + pair VARCHAR NOT NULL, + is_open BOOLEAN NOT NULL, + fee_open FLOAT NOT NULL, + fee_open_cost FLOAT, + fee_open_currency VARCHAR, + fee_close FLOAT NOT NULL, + fee_close_cost FLOAT, + fee_close_currency VARCHAR, + open_rate FLOAT, + open_rate_requested FLOAT, + open_trade_price FLOAT, + close_rate FLOAT, + close_rate_requested FLOAT, + close_profit FLOAT, + close_profit_abs FLOAT, + stake_amount FLOAT NOT NULL, + amount FLOAT, + open_date DATETIME NOT NULL, + close_date DATETIME, + open_order_id VARCHAR, + stop_loss FLOAT, + stop_loss_pct FLOAT, + initial_stop_loss FLOAT, + initial_stop_loss_pct FLOAT, + stoploss_order_id VARCHAR, + stoploss_last_update DATETIME, + max_rate FLOAT, + min_rate FLOAT, + sell_reason VARCHAR, + strategy VARCHAR, + timeframe INTEGER, + PRIMARY KEY (id), + CHECK (is_open IN (0, 1)) ); +CREATE INDEX ix_trades_stoploss_order_id ON trades (stoploss_order_id); +CREATE INDEX ix_trades_pair ON trades (pair); +CREATE INDEX ix_trades_is_open ON trades (is_open); + ``` ## Get all trades in the table @@ -77,8 +109,8 @@ UPDATE trades SET is_open=0, close_date=, close_rate=, - close_profit=close_rate/open_rate-1, - close_profit_abs = (amount * * (1 - fee_close) - (amount * open_rate * 1 - fee_open), + close_profit = close_rate / open_rate - 1, + close_profit_abs = (amount * * (1 - fee_close) - (amount * (open_rate * 1 - fee_open))), sell_reason= WHERE id=; ``` @@ -88,24 +120,39 @@ WHERE id=; ```sql UPDATE trades SET is_open=0, - close_date='2017-12-20 03:08:45.103418', + close_date='2020-06-20 03:08:45.103418', close_rate=0.19638016, close_profit=0.0496, - close_profit_abs = (amount * 0.19638016 * (1 - fee_close) - (amount * open_rate * 1 - fee_open) + close_profit_abs = (amount * 0.19638016 * (1 - fee_close) - (amount * open_rate * (1 - fee_open))), sell_reason='force_sell' WHERE id=31; ``` -## Insert manually a new trade +## Manually insert a new trade ```sql INSERT INTO trades (exchange, pair, is_open, fee_open, fee_close, open_rate, stake_amount, amount, open_date) -VALUES ('bittrex', 'ETH/BTC', 1, 0.0025, 0.0025, , , , '') +VALUES ('binance', 'ETH/BTC', 1, 0.0025, 0.0025, , , , '') ``` -##### Example: +### Insert trade example ```sql INSERT INTO trades (exchange, pair, is_open, fee_open, fee_close, open_rate, stake_amount, amount, open_date) -VALUES ('bittrex', 'ETH/BTC', 1, 0.0025, 0.0025, 0.00258580, 0.002, 0.7715262081, '2017-11-28 12:44:24.000000') +VALUES ('binance', 'ETH/BTC', 1, 0.0025, 0.0025, 0.00258580, 0.002, 0.7715262081, '2020-06-28 12:44:24.000000') ``` + +## Remove trade from the database + +Maybe you'd like to remove a trade from the database, because something went wrong. + +```sql +DELETE FROM trades WHERE id = ; +``` + +```sql +DELETE FROM trades WHERE id = 31; +``` + +!!! Warning + This will remove this trade from the database. Please make sure you got the correct id and **NEVER** run this query without the `where` clause. diff --git a/docs/stoploss.md b/docs/stoploss.md index f6d56fd41..bf7270dff 100644 --- a/docs/stoploss.md +++ b/docs/stoploss.md @@ -1,6 +1,6 @@ # Stop Loss -The `stoploss` configuration parameter is loss in percentage that should trigger a sale. +The `stoploss` configuration parameter is loss as ratio that should trigger a sale. For example, value `-0.10` will cause immediate sell if the profit dips below -10% for a given trade. This parameter is optional. Most of the strategy files already include the optimal `stoploss` value. @@ -27,7 +27,7 @@ So this parameter will tell the bot how often it should update the stoploss orde This same logic will reapply a stoploss order on the exchange should you cancel it accidentally. !!! Note - Stoploss on exchange is only supported for Binance (stop-loss-limit) and Kraken (stop-loss-market) as of now. + Stoploss on exchange is only supported for Binance (stop-loss-limit), Kraken (stop-loss-market) and FTX (stop limit and stop-market) as of now. ## Static Stop Loss @@ -84,7 +84,7 @@ This option can be used with or without `trailing_stop_positive`, but uses `trai ``` python trailing_stop_positive_offset = 0.011 - trailing_only_offset_is_reached = true + trailing_only_offset_is_reached = True ``` Simplified example: @@ -101,7 +101,7 @@ Simplified example: ## Changing stoploss on open trades -A stoploss on an open trade can be changed by changing the value in the configuration or strategy and use the `/reload_conf` command (alternatively, completely stopping and restarting the bot also works). +A stoploss on an open trade can be changed by changing the value in the configuration or strategy and use the `/reload_config` command (alternatively, completely stopping and restarting the bot also works). The new stoploss value will be applied to open trades (and corresponding log-messages will be generated). diff --git a/docs/strategy-advanced.md b/docs/strategy-advanced.md new file mode 100644 index 000000000..e4bab303e --- /dev/null +++ b/docs/strategy-advanced.md @@ -0,0 +1,201 @@ +# 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. + +[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. + +!!! Note + All callback methods described below should only be implemented in a strategy if they are actually used. + +## Custom order timeout rules + +Simple, timebased order-timeouts can be configured either via strategy or in the configuration in the `unfilledtimeout` section. + +However, freqtrade also offers a custom callback for both ordertypes, which allows you to decide based on custom criteria if a order did time out or not. + +!!! Note + Unfilled order timeouts are not relevant during backtesting or hyperopt, and are only relevant during real (live) trading. Therefore these methods are only called in these circumstances. + +### Custom order timeout example + +A simple example, which applies different unfilled-timeouts depending on the price of the asset can be seen below. +It applies a tight timeout for higher priced assets, while allowing more time to fill on cheap coins. + +The function must return either `True` (cancel order) or `False` (keep order alive). + +``` python +from datetime import datetime, timedelta +from freqtrade.persistence import Trade + +class Awesomestrategy(IStrategy): + + # ... populate_* methods + + # Set unfilledtimeout to 25 hours, since our maximum timeout from below is 24 hours. + unfilledtimeout = { + 'buy': 60 * 25, + 'sell': 60 * 25 + } + + def check_buy_timeout(self, pair: str, trade: 'Trade', order: dict, **kwargs) -> bool: + if trade.open_rate > 100 and trade.open_date < datetime.utcnow() - timedelta(minutes=5): + return True + elif trade.open_rate > 10 and trade.open_date < datetime.utcnow() - timedelta(minutes=3): + return True + elif trade.open_rate < 1 and trade.open_date < datetime.utcnow() - timedelta(hours=24): + return True + return False + + + def check_sell_timeout(self, pair: str, trade: 'Trade', order: dict, **kwargs) -> bool: + if trade.open_rate > 100 and trade.open_date < datetime.utcnow() - timedelta(minutes=5): + return True + elif trade.open_rate > 10 and trade.open_date < datetime.utcnow() - timedelta(minutes=3): + return True + elif trade.open_rate < 1 and trade.open_date < datetime.utcnow() - timedelta(hours=24): + return True + return False +``` + +!!! Note + For the above example, `unfilledtimeout` must be set to something bigger than 24h, otherwise that type of timeout will apply first. + +### Custom order timeout example (using additional data) + +``` python +from datetime import datetime +from freqtrade.persistence import Trade + +class Awesomestrategy(IStrategy): + + # ... populate_* methods + + # Set unfilledtimeout to 25 hours, since our maximum timeout from below is 24 hours. + unfilledtimeout = { + 'buy': 60 * 25, + 'sell': 60 * 25 + } + + def check_buy_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool: + ob = self.dp.orderbook(pair, 1) + current_price = ob['bids'][0][0] + # Cancel buy order if price is more than 2% above the order. + if current_price > order['price'] * 1.02: + return True + return False + + + def check_sell_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool: + ob = self.dp.orderbook(pair, 1) + current_price = ob['asks'][0][0] + # Cancel sell order if price is more than 2% below the order. + if current_price < order['price'] * 0.98: + return True + return False +``` + +## Bot loop start callback + +A simple callback which is called once at the start of every bot throttling iteration. +This can be used to perform calculations which are pair independent (apply to all pairs), loading of external data, etc. + +``` python +import requests + +class Awesomestrategy(IStrategy): + + # ... populate_* methods + + def bot_loop_start(self, **kwargs) -> None: + """ + Called at the start of the bot iteration (one loop). + Might be used to perform pair-independent tasks + (e.g. gather some remote resource for comparison) + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + """ + 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.remote_data = requests.get('https://some_remote_source.example.com') + +``` + +## Bot order confirmation + +### Trade entry (buy order) confirmation + +`confirm_trade_entry()` can be used to abort a trade entry at the latest second (maybe because the price is not what we expect). + +``` python +class Awesomestrategy(IStrategy): + + # ... populate_* methods + + def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float, + time_in_force: str, **kwargs) -> bool: + """ + Called right before placing a buy order. + Timing for this function is critical, so avoid doing heavy computations or + network requests in this method. + + For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ + + When not implemented by a strategy, returns True (always confirming). + + :param pair: Pair that's about to be bought. + :param order_type: Order type (as configured in order_types). usually limit or market. + :param amount: Amount in target (quote) currency that's going to be traded. + :param rate: Rate that's going to be used when using limit orders + :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return bool: When True is returned, then the buy-order is placed on the exchange. + False aborts the process + """ + return True + +``` + +### Trade exit (sell order) confirmation + +`confirm_trade_exit()` can be used to abort a trade exit (sell) at the latest second (maybe because the price is not what we expect). + +``` python +from freqtrade.persistence import Trade + + +class Awesomestrategy(IStrategy): + + # ... populate_* methods + + def confirm_trade_exit(self, pair: str, trade: Trade, order_type: str, amount: float, + rate: float, time_in_force: str, sell_reason: str, **kwargs) -> bool: + """ + Called right before placing a regular sell order. + Timing for this function is critical, so avoid doing heavy computations or + network requests in this method. + + For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ + + When not implemented by a strategy, returns True (always confirming). + + :param pair: Pair that's about to be sold. + :param order_type: Order type (as configured in order_types). usually limit or market. + :param amount: Amount in quote currency. + :param rate: Rate that's going to be used when using limit orders + :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). + :param sell_reason: Sell reason. + Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss', + 'sell_signal', 'force_sell', 'emergency_sell'] + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return bool: When True is returned, then the sell-order is placed on the exchange. + False aborts the process + """ + if sell_reason == 'force_sell' and trade.calc_profit_ratio(rate) < 0: + # Reject force-sells with negative profit + # This is just a sample, please adjust to your needs + # (this does not necessarily make sense, assuming you know when you're force-selling) + return False + return True + +``` diff --git a/docs/strategy-customization.md b/docs/strategy-customization.md index 7793ea148..98c71b4b2 100644 --- a/docs/strategy-customization.md +++ b/docs/strategy-customization.md @@ -1,7 +1,8 @@ # Strategy Customization -This page explains where to customize your strategies, and add new -indicators. +This page explains how to customize your strategies, add new indicators and set up trading rules. + +Please familiarize yourself with [Freqtrade basics](bot-basics.md) first, which provides overall info on how the bot operates. ## Install a custom strategy file @@ -140,10 +141,10 @@ By letting the bot know how much history is needed, backtest trades can start at #### Example -Let's try to backtest 1 month (January 2019) of 5m candles using the an example strategy with EMA100, as above. +Let's try to backtest 1 month (January 2019) of 5m candles using an example strategy with EMA100, as above. ``` bash -freqtrade backtesting --timerange 20190101-20190201 --ticker-interval 5m +freqtrade backtesting --timerange 20190101-20190201 --timeframe 5m ``` Assuming `startup_candle_count` is set to 100, backtesting knows it needs 100 candles to generate valid buy signals. It will load data from `20190101 - (100 * 5m)` - which is ~2019-12-31 15:30:00. @@ -249,7 +250,7 @@ minimal_roi = { While technically not completely disabled, this would sell once the trade reaches 10000% Profit. -To use times based on candle duration (ticker_interval or timeframe), the following snippet can be handy. +To use times based on candle duration (timeframe), the following snippet can be handy. This will allow you to change the ticket_interval for the strategy, and ROI times will still be set as candles (e.g. after 3 candles ...) ``` python @@ -257,12 +258,12 @@ from freqtrade.exchange import timeframe_to_minutes class AwesomeStrategy(IStrategy): - ticker_interval = "1d" - ticker_interval_mins = timeframe_to_minutes(ticker_interval) + timeframe = "1d" + timeframe_mins = timeframe_to_minutes(timeframe) minimal_roi = { "0": 0.05, # 5% for the first 3 candles - str(ticker_interval_mins * 3)): 0.02, # 2% after 3 candles - str(ticker_interval_mins * 6)): 0.01, # 1% After 6 candles + str(timeframe_mins * 3)): 0.02, # 2% after 3 candles + str(timeframe_mins * 6)): 0.01, # 1% After 6 candles } ``` @@ -291,7 +292,7 @@ Common values are `"1m"`, `"5m"`, `"15m"`, `"1h"`, however all values supported Please note that the same buy/sell signals may work well with one timeframe, but not with the others. -This setting is accessible within the strategy methods as the `self.ticker_interval` attribute. +This setting is accessible within the strategy methods as the `self.timeframe` attribute. ### Metadata dict @@ -325,67 +326,14 @@ class Awesomestrategy(IStrategy): !!! Note If the data is pair-specific, make sure to use pair as one of the keys in the dictionary. -### Additional data (DataProvider) +*** -The strategy provides access to the `DataProvider`. This allows you to get additional data to use in your strategy. - -All methods return `None` in case of failure (do not raise an exception). - -Please always check the mode of operation to select the correct method to get data (samples see below). - -#### Possible options for DataProvider - -- `available_pairs` - Property with tuples listing cached pairs with their intervals (pair, interval). -- `ohlcv(pair, timeframe)` - Currently cached candle (OHLCV) data for the pair, returns DataFrame or empty DataFrame. -- `historic_ohlcv(pair, timeframe)` - Returns historical data stored on disk. -- `get_pair_dataframe(pair, timeframe)` - This is a universal method, which returns either historical data (for backtesting) or cached live data (for the Dry-Run and Live-Run modes). -- `orderbook(pair, maximum)` - Returns latest orderbook data for the pair, a dict with bids/asks with a total of `maximum` entries. -- `market(pair)` - Returns market data for the pair: fees, limits, precisions, activity flag, etc. See [ccxt documentation](https://github.com/ccxt/ccxt/wiki/Manual#markets) for more details on Market data structure. -- `runmode` - Property containing the current runmode. - -#### Example: fetch live / historical candle (OHLCV) data for the first informative pair - -``` python -if self.dp: - inf_pair, inf_timeframe = self.informative_pairs()[0] - informative = self.dp.get_pair_dataframe(pair=inf_pair, - timeframe=inf_timeframe) -``` - -!!! Warning "Warning about backtesting" - Be carefull when using dataprovider in backtesting. `historic_ohlcv()` (and `get_pair_dataframe()` - for the backtesting runmode) provides the full time-range in one go, - so please be aware of it and make sure to not "look into the future" to avoid surprises when running in dry/live mode). - -!!! Warning "Warning in hyperopt" - This option cannot currently be used during hyperopt. - -#### Orderbook - -``` python -if self.dp: - if self.dp.runmode.value in ('live', 'dry_run'): - ob = self.dp.orderbook(metadata['pair'], 1) - dataframe['best_bid'] = ob['bids'][0][0] - dataframe['best_ask'] = ob['asks'][0][0] -``` - -!!! Warning - The order book is not part of the historic data which means backtesting and hyperopt will not work if this - method is used. - -#### Available Pairs - -``` python -if self.dp: - for pair, timeframe in self.dp.available_pairs: - print(f"available {pair}, {timeframe}") -``` +### Additional data (informative_pairs) #### Get data for non-tradeable pairs Data for additional, informative pairs (reference pairs) can be beneficial for some strategies. -Ohlcv data for these pairs will be downloaded as part of the regular whitelist refresh process and is available via `DataProvider` just as other pairs (see above). +Ohlcv data for these pairs will be downloaded as part of the regular whitelist refresh process and is available via `DataProvider` just as other pairs (see below). These parts will **not** be traded unless they are also specified in the pair whitelist, or have been selected by Dynamic Whitelisting. The pairs need to be specified as tuples in the format `("pair", "interval")`, with pair as the first and time interval as the second argument. @@ -405,6 +353,178 @@ def informative_pairs(self): It is however better to use resampling to longer time-intervals when possible to avoid hammering the exchange with too many requests and risk being blocked. +*** + +### Additional data (DataProvider) + +The strategy provides access to the `DataProvider`. This allows you to get additional data to use in your strategy. + +All methods return `None` in case of failure (do not raise an exception). + +Please always check the mode of operation to select the correct method to get data (samples see below). + +#### Possible options for DataProvider + +- [`available_pairs`](#available_pairs) - Property with tuples listing cached pairs with their intervals (pair, interval). +- [`current_whitelist()`](#current_whitelist) - Returns a current list of whitelisted pairs. Useful for accessing dynamic whitelists (ie. VolumePairlist) +- [`get_pair_dataframe(pair, timeframe)`](#get_pair_dataframepair-timeframe) - This is a universal method, which returns either historical data (for backtesting) or cached live data (for the Dry-Run and Live-Run modes). +- [`get_analyzed_dataframe(pair, timeframe)`](#get_analyzed_dataframepair-timeframe) - Returns the analyzed dataframe (after calling `populate_indicators()`, `populate_buy()`, `populate_sell()`) and the time of the latest analysis. +- `historic_ohlcv(pair, timeframe)` - Returns historical data stored on disk. +- `market(pair)` - Returns market data for the pair: fees, limits, precisions, activity flag, etc. See [ccxt documentation](https://github.com/ccxt/ccxt/wiki/Manual#markets) for more details on the Market data structure. +- `ohlcv(pair, timeframe)` - Currently cached candle (OHLCV) data for the pair, returns DataFrame or empty DataFrame. +- [`orderbook(pair, maximum)`](#orderbookpair-maximum) - Returns latest orderbook data for the pair, a dict with bids/asks with a total of `maximum` entries. +- [`ticker(pair)`](#tickerpair) - Returns current ticker data for the pair. See [ccxt documentation](https://github.com/ccxt/ccxt/wiki/Manual#price-tickers) for more details on the Ticker data structure. +- `runmode` - Property containing the current runmode. + +#### Example Usages: + +#### *available_pairs* + +``` python +if self.dp: + for pair, timeframe in self.dp.available_pairs: + print(f"available {pair}, {timeframe}") +``` + +#### *current_whitelist()* + +Imagine you've developed a strategy that trades the `5m` timeframe using signals generated from a `1d` timeframe on the top 10 volume pairs by volume. + +The strategy might look something like this: + +*Scan through the top 10 pairs by volume using the `VolumePairList` every 5 minutes and use a 14 day RSI to buy and sell.* + +Due to the limited available data, it's very difficult to resample our `5m` candles into daily candles for use in a 14 day RSI. Most exchanges limit us to just 500 candles which effectively gives us around 1.74 daily candles. We need 14 days at least! + +Since we can't resample our data we will have to use an informative pair; and since our whitelist will be dynamic we don't know which pair(s) to use. + +This is where calling `self.dp.current_whitelist()` comes in handy. + +```python +class SampleStrategy(IStrategy): + # strategy init stuff... + + timeframe = '5m' + + # more strategy init stuff.. + + def informative_pairs(self): + + # get access to all pairs available in whitelist. + pairs = self.dp.current_whitelist() + # Assign tf to each pair so they can be downloaded and cached for strategy. + informative_pairs = [(pair, '1d') for pair in pairs] + return informative_pairs + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + + inf_tf = '1d' + # Get the informative pair + informative = self.dp.get_pair_dataframe(pair=metadata['pair'], timeframe='1d') + # Get the 14 day rsi + informative['rsi'] = ta.RSI(informative, timeperiod=14) + + # Rename columns to be unique + informative.columns = [f"{col}_{inf_tf}" for col in informative.columns] + # Assuming inf_tf = '1d' - then the columns will now be: + # date_1d, open_1d, high_1d, low_1d, close_1d, rsi_1d + + # Combine the 2 dataframes + # all indicators on the informative sample MUST be calculated before this point + dataframe = pd.merge(dataframe, informative, left_on='date', right_on=f'date_{inf_tf}', how='left') + # FFill to have the 1d value available in every row throughout the day. + # Without this, comparisons would only work once per day. + dataframe = dataframe.ffill() + # Calculate rsi of the original dataframe (5m timeframe) + dataframe['rsi'] = ta.RSI(dataframe, timeperiod=14) + + # Do other stuff + # ... + + return dataframe + + def populate_buy_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + + dataframe.loc[ + ( + (qtpylib.crossed_above(dataframe['rsi'], 30)) & # Signal: RSI crosses above 30 + (dataframe['rsi_1d'] < 30) & # Ensure daily RSI is < 30 + (dataframe['volume'] > 0) # Ensure this candle had volume (important for backtesting) + ), + 'buy'] = 1 + +``` + +#### *get_pair_dataframe(pair, timeframe)* + +``` python +# fetch live / historical candle (OHLCV) data for the first informative pair +if self.dp: + inf_pair, inf_timeframe = self.informative_pairs()[0] + informative = self.dp.get_pair_dataframe(pair=inf_pair, + timeframe=inf_timeframe) +``` + +!!! Warning "Warning about backtesting" + Be careful when using dataprovider in backtesting. `historic_ohlcv()` (and `get_pair_dataframe()` + for the backtesting runmode) provides the full time-range in one go, + so please be aware of it and make sure to not "look into the future" to avoid surprises when running in dry/live mode). + +!!! Warning "Warning in hyperopt" + This option cannot currently be used during hyperopt. + +#### *get_analyzed_dataframe(pair, timeframe)* + +This method is used by freqtrade internally to determine the last signal. +It can also be used in specific callbacks to get the signal that caused the action (see [Advanced Strategy Documentation](strategy-advanced.md) for more details on available callbacks). + +``` python +# fetch current dataframe +if self.dp: + dataframe, last_updated = self.dp.get_analyzed_dataframe(pair=metadata['pair'], + timeframe=self.ticker_interval) +``` + +!!! Note "No data available" + Returns an empty dataframe if the requested pair was not cached. + This should not happen when using whitelisted pairs. + +!!! Warning "Warning in hyperopt" + This option cannot currently be used during hyperopt. + +#### *orderbook(pair, maximum)* + +``` python +if self.dp: + if self.dp.runmode.value in ('live', 'dry_run'): + ob = self.dp.orderbook(metadata['pair'], 1) + dataframe['best_bid'] = ob['bids'][0][0] + dataframe['best_ask'] = ob['asks'][0][0] +``` + +!!! Warning + The order book is not part of the historic data which means backtesting and hyperopt will not work if this + method is used. + +#### *ticker(pair)* + +``` python +if self.dp: + if self.dp.runmode.value in ('live', 'dry_run'): + ticker = self.dp.ticker(metadata['pair']) + dataframe['last_price'] = ticker['last'] + dataframe['volume24h'] = ticker['quoteVolume'] + dataframe['vwap'] = ticker['vwap'] +``` + +!!! Warning + Although the ticker data structure is a part of the ccxt Unified Interface, the values returned by this method can + vary for different exchanges. For instance, many exchanges do not return `vwap` values, the FTX exchange + does not always fills in the `last` field (so it can be None), etc. So you need to carefully verify the ticker + data returned from the exchange and add appropriate error handling / defaults. + +*** + ### Additional data (Wallets) The strategy provides access to the `Wallets` object. This contains the current balances on the exchange. @@ -427,6 +547,8 @@ if self.wallets: - `get_used(asset)` - currently tied up balance (open orders) - `get_total(asset)` - total available balance - sum of the 2 above +*** + ### Additional data (Trades) A history of Trades can be retrieved in the strategy by querying the database. @@ -491,7 +613,7 @@ Locks can also be lifted manually, by calling `self.unlock_pair(pair)`. To verify if a pair is currently locked, use `self.is_pair_locked(pair)`. !!! Note - Locked pairs are not persisted, so a restart of the bot, or calling `/reload_conf` will reset locked pairs. + Locked pairs are not persisted, so a restart of the bot, or calling `/reload_config` will reset locked pairs. !!! Warning Locking pairs is not functioning during backtesting. diff --git a/docs/strategy_analysis_example.md b/docs/strategy_analysis_example.md index d26d684ce..6b4ad567f 100644 --- a/docs/strategy_analysis_example.md +++ b/docs/strategy_analysis_example.md @@ -18,7 +18,7 @@ config = Configuration.from_files([]) # config = Configuration.from_files(["config.json"]) # Define some constants -config["ticker_interval"] = "5m" +config["timeframe"] = "5m" # Name of the strategy class config["strategy"] = "SampleStrategy" # Location of the data @@ -33,7 +33,7 @@ pair = "BTC_USDT" from freqtrade.data.history import load_pair_history candles = load_pair_history(datadir=data_location, - timeframe=config["ticker_interval"], + timeframe=config["timeframe"], pair=pair) # Confirm success diff --git a/docs/telegram-usage.md b/docs/telegram-usage.md index f683ae8da..9776b26ba 100644 --- a/docs/telegram-usage.md +++ b/docs/telegram-usage.md @@ -9,7 +9,7 @@ Telegram user id. Start a chat with the [Telegram BotFather](https://telegram.me/BotFather) -Send the message `/newbot`. +Send the message `/newbot`. *BotFather response:* @@ -47,28 +47,30 @@ Per default, the Telegram bot shows predefined commands. Some commands are only available by sending them to the bot. The table below list the official commands. You can ask at any moment for help with `/help`. -| Command | Default | Description | -|----------|---------|-------------| -| `/start` | | Starts the trader -| `/stop` | | Stops the trader -| `/stopbuy` | | Stops the trader from opening new trades. Gracefully closes open trades according to their rules. -| `/reload_conf` | | Reloads the configuration file -| `/show_config` | | Shows part of the current configuration with relevant settings to operation -| `/status` | | Lists all open trades -| `/status table` | | List all open trades in a table format. Pending buy orders are marked with an asterisk (*) Pending sell orders are marked with a double asterisk (**) -| `/count` | | Displays number of trades used and available -| `/profit` | | Display a summary of your profit/loss from close trades and some stats about your performance -| `/forcesell ` | | Instantly sells the given trade (Ignoring `minimum_roi`). -| `/forcesell all` | | Instantly sells all open trades (Ignoring `minimum_roi`). -| `/forcebuy [rate]` | | Instantly buys the given pair. Rate is optional. (`forcebuy_enable` must be set to True) -| `/performance` | | Show performance of each finished trade grouped by pair -| `/balance` | | Show account balance per currency -| `/daily ` | 7 | Shows profit or loss per day, over the last n days -| `/whitelist` | | Show the current whitelist -| `/blacklist [pair]` | | Show the current blacklist, or adds a pair to the blacklist. -| `/edge` | | Show validated pairs by Edge if it is enabled. -| `/help` | | Show help message -| `/version` | | Show version +| Command | Description | +|----------|-------------| +| `/start` | Starts the trader +| `/stop` | Stops the trader +| `/stopbuy` | Stops the trader from opening new trades. Gracefully closes open trades according to their rules. +| `/reload_config` | Reloads the configuration file +| `/show_config` | Shows part of the current configuration with relevant settings to operation +| `/status` | Lists all open trades +| `/status table` | List all open trades in a table format. Pending buy orders are marked with an asterisk (*) Pending sell orders are marked with a double asterisk (**) +| `/trades [limit]` | List all recently closed trades in a table format. +| `/delete ` | Delete a specific trade from the Database. Tries to close open orders. Requires manual handling of this trade on the exchange. +| `/count` | Displays number of trades used and available +| `/profit` | Display a summary of your profit/loss from close trades and some stats about your performance +| `/forcesell ` | Instantly sells the given trade (Ignoring `minimum_roi`). +| `/forcesell all` | Instantly sells all open trades (Ignoring `minimum_roi`). +| `/forcebuy [rate]` | Instantly buys the given pair. Rate is optional. (`forcebuy_enable` must be set to True) +| `/performance` | Show performance of each finished trade grouped by pair +| `/balance` | Show account balance per currency +| `/daily ` | Shows profit or loss per day, over the last n days (n defaults to 7) +| `/whitelist` | Show the current whitelist +| `/blacklist [pair]` | Show the current blacklist, or adds a pair to the blacklist. +| `/edge` | Show validated pairs by Edge if it is enabled. +| `/help` | Show help message +| `/version` | Show version ## Telegram commands in action @@ -85,14 +87,14 @@ Below, example of Telegram message you will receive for each command. ### /stopbuy -> **status:** `Setting max_open_trades to 0. Run /reload_conf to reset.` +> **status:** `Setting max_open_trades to 0. Run /reload_config to reset.` Prevents the bot from opening new trades by temporarily setting "max_open_trades" to 0. Open trades will be handled via their regular rules (ROI / Sell-signal, stoploss, ...). After this, give the bot time to close off open trades (can be checked via `/status table`). Once all positions are sold, run `/stop` to completely stop the bot. -`/reload_conf` resets "max_open_trades" to the value set in the configuration and resets this command. +`/reload_config` resets "max_open_trades" to the value set in the configuration and resets this command. !!! Warning The stop-buy signal is ONLY active while the bot is running, and is not persisted anyway, so restarting the bot will cause this to reset. @@ -113,6 +115,7 @@ For each open trade, the bot will send you the following message. ### /status table Return the status of all open trades in a table format. + ``` ID Pair Since Profit ---- -------- ------- -------- @@ -123,6 +126,7 @@ Return the status of all open trades in a table format. ### /count Return the number of trades used and available. + ``` current max --------- ----- @@ -208,15 +212,15 @@ Shows the current whitelist Shows the current blacklist. If Pair is set, then this pair will be added to the pairlist. -Also supports multiple pairs, seperated by a space. -Use `/reload_conf` to reset the blacklist. +Also supports multiple pairs, separated by a space. +Use `/reload_config` to reset the blacklist. > Using blacklist `StaticPairList` with 2 pairs >`DODGE/BTC`, `HOT/BTC`. ### /edge -Shows pairs validated by Edge along with their corresponding winrate, expectancy and stoploss values. +Shows pairs validated by Edge along with their corresponding win-rate, expectancy and stoploss values. > **Edge only validated following pairs:** ``` diff --git a/docs/utils.md b/docs/utils.md index 269b9affd..8c7e381ff 100644 --- a/docs/utils.md +++ b/docs/utils.md @@ -62,7 +62,7 @@ $ freqtrade new-config --config config_binance.json ? Please insert your stake currency: BTC ? Please insert your stake amount: 0.05 ? Please insert max_open_trades (Integer or 'unlimited'): 3 -? Please insert your timeframe (ticker interval): 5m +? Please insert your desired timeframe (e.g. 5m): 5m ? Please insert your display Currency (for reporting): USD ? Select exchange binance ? Do you want to enable Telegram? No @@ -77,7 +77,7 @@ Results will be located in `user_data/strategies/.py`. ``` output usage: freqtrade new-strategy [-h] [--userdir PATH] [-s NAME] - [--template {full,minimal}] + [--template {full,minimal,advanced}] optional arguments: -h, --help show this help message and exit @@ -86,10 +86,10 @@ optional arguments: -s NAME, --strategy NAME Specify strategy class name which will be used by the bot. - --template {full,minimal} - Use a template which is either `minimal` or `full` - (containing multiple sample indicators). Default: - `full`. + --template {full,minimal,advanced} + Use a template which is either `minimal`, `full` + (containing multiple sample indicators) or `advanced`. + Default: `full`. ``` @@ -105,6 +105,12 @@ With custom user directory freqtrade new-strategy --userdir ~/.freqtrade/ --strategy AwesomeStrategy ``` +Using the advanced template (populates all optional functions and methods) + +```bash +freqtrade new-strategy --strategy AwesomeStrategy --template advanced +``` + ## Create new hyperopt Creates a new hyperopt from a template similar to SampleHyperopt. @@ -114,7 +120,7 @@ Results will be located in `user_data/hyperopts/.py`. ``` output usage: freqtrade new-hyperopt [-h] [--userdir PATH] [--hyperopt NAME] - [--template {full,minimal}] + [--template {full,minimal,advanced}] optional arguments: -h, --help show this help message and exit @@ -122,10 +128,10 @@ optional arguments: Path to userdata directory. --hyperopt NAME Specify hyperopt class name which will be used by the bot. - --template {full,minimal} - Use a template which is either `minimal` or `full` - (containing multiple sample indicators). Default: - `full`. + --template {full,minimal,advanced} + Use a template which is either `minimal`, `full` + (containing multiple sample indicators) or `advanced`. + Default: `full`. ``` ### Sample usage of new-hyperopt @@ -426,9 +432,9 @@ usage: freqtrade hyperopt-list [-h] [-v] [--logfile FILE] [-V] [-c PATH] [--max-trades INT] [--min-avg-time FLOAT] [--max-avg-time FLOAT] [--min-avg-profit FLOAT] [--max-avg-profit FLOAT] - [--min-total-profit FLOAT] - [--max-total-profit FLOAT] [--no-color] - [--print-json] [--no-details] + [--min-total-profit FLOAT] [--max-total-profit FLOAT] + [--min-objective FLOAT] [--max-objective FLOAT] + [--no-color] [--print-json] [--no-details] [--export-csv FILE] optional arguments: @@ -447,6 +453,10 @@ optional arguments: Select epochs on above total profit. --max-total-profit FLOAT Select epochs on below total profit. + --min-objective FLOAT + Select epochs on above objective (- is added by default). + --max-objective FLOAT + Select epochs on below objective (- is added by default). --no-color Disable colorization of hyperopt results. May be useful if you are redirecting output to a file. --print-json Print best result detailization in JSON format. @@ -515,3 +525,48 @@ Prints JSON data with details for the last best epoch (i.e., the best of all epo ``` freqtrade hyperopt-show --best -n -1 --print-json --no-header ``` + +## Show trades + +Print selected (or all) trades from database to screen. + +``` +usage: freqtrade show-trades [-h] [-v] [--logfile FILE] [-V] [-c PATH] + [-d PATH] [--userdir PATH] [--db-url PATH] + [--trade-ids TRADE_IDS [TRADE_IDS ...]] + [--print-json] + +optional arguments: + -h, --help show this help message and exit + --db-url PATH Override trades database URL, this is useful in custom + deployments (default: `sqlite:///tradesv3.sqlite` for + Live Run mode, `sqlite:///tradesv3.dryrun.sqlite` for + Dry Run). + --trade-ids TRADE_IDS [TRADE_IDS ...] + Specify the list of trade ids. + --print-json Print output in JSON format. + +Common arguments: + -v, --verbose Verbose mode (-vv for more, -vvv to get all messages). + --logfile FILE Log to the file specified. Special values are: + 'syslog', 'journald'. See the documentation for more + details. + -V, --version show program's version number and exit + -c PATH, --config PATH + Specify configuration file (default: + `userdir/config.json` or `config.json` whichever + exists). Multiple --config options may be used. Can be + set to `-` to read config from stdin. + -d PATH, --datadir PATH + Path to directory with historical backtesting data. + --userdir PATH, --user-data-dir PATH + Path to userdata directory. +``` + +### Examples + +Print trades with id 2 and 3 as json + +``` bash +freqtrade show-trades --db-url sqlite:///tradesv3.sqlite --trade-ids 2 3 --print-json +``` diff --git a/docs/webhook-config.md b/docs/webhook-config.md index 70a41dd46..db6d4d1ef 100644 --- a/docs/webhook-config.md +++ b/docs/webhook-config.md @@ -47,6 +47,7 @@ Different payloads can be configured for different events. Not all fields are ne The fields in `webhook.webhookbuy` are filled when the bot executes a buy. Parameters are filled using string.format. Possible parameters are: +* `trade_id` * `exchange` * `pair` * `limit` @@ -63,6 +64,7 @@ Possible parameters are: The fields in `webhook.webhookbuycancel` are filled when the bot cancels a buy order. Parameters are filled using string.format. Possible parameters are: +* `trade_id` * `exchange` * `pair` * `limit` @@ -79,6 +81,7 @@ Possible parameters are: The fields in `webhook.webhooksell` are filled when the bot sells a trade. Parameters are filled using string.format. Possible parameters are: +* `trade_id` * `exchange` * `pair` * `gain` @@ -100,6 +103,7 @@ Possible parameters are: The fields in `webhook.webhooksellcancel` are filled when the bot cancels a sell order. Parameters are filled using string.format. Possible parameters are: +* `trade_id` * `exchange` * `pair` * `gain` diff --git a/freqtrade/commands/__init__.py b/freqtrade/commands/__init__.py index f80c74e05..4ce3eb421 100644 --- a/freqtrade/commands/__init__.py +++ b/freqtrade/commands/__init__.py @@ -9,7 +9,8 @@ Note: Be careful with file-scoped imports in these subfiles. from freqtrade.commands.arguments import Arguments from freqtrade.commands.build_config_commands import start_new_config from freqtrade.commands.data_commands import (start_convert_data, - start_download_data) + start_download_data, + start_list_data) from freqtrade.commands.deploy_commands import (start_create_userdir, start_new_hyperopt, start_new_strategy) @@ -19,7 +20,8 @@ from freqtrade.commands.list_commands import (start_list_exchanges, start_list_hyperopts, start_list_markets, start_list_strategies, - start_list_timeframes) + start_list_timeframes, + start_show_trades) from freqtrade.commands.optimize_commands import (start_backtesting, start_edge, start_hyperopt) from freqtrade.commands.pairlist_commands import start_test_pairlist diff --git a/freqtrade/commands/arguments.py b/freqtrade/commands/arguments.py index 8c64c5857..4a87def88 100644 --- a/freqtrade/commands/arguments.py +++ b/freqtrade/commands/arguments.py @@ -15,7 +15,7 @@ ARGS_STRATEGY = ["strategy", "strategy_path"] ARGS_TRADE = ["db_url", "sd_notify", "dry_run"] -ARGS_COMMON_OPTIMIZE = ["ticker_interval", "timerange", +ARGS_COMMON_OPTIMIZE = ["timeframe", "timerange", "max_open_trades", "stake_amount", "fee"] ARGS_BACKTEST = ARGS_COMMON_OPTIMIZE + ["position_stacking", "use_max_market_positions", @@ -54,21 +54,26 @@ ARGS_BUILD_HYPEROPT = ["user_data_dir", "hyperopt", "template"] ARGS_CONVERT_DATA = ["pairs", "format_from", "format_to", "erase"] ARGS_CONVERT_DATA_OHLCV = ARGS_CONVERT_DATA + ["timeframes"] +ARGS_LIST_DATA = ["exchange", "dataformat_ohlcv", "pairs"] + ARGS_DOWNLOAD_DATA = ["pairs", "pairs_file", "days", "download_trades", "exchange", "timeframes", "erase", "dataformat_ohlcv", "dataformat_trades"] ARGS_PLOT_DATAFRAME = ["pairs", "indicators1", "indicators2", "plot_limit", "db_url", "trade_source", "export", "exportfilename", - "timerange", "ticker_interval", "no_trades"] + "timerange", "timeframe", "no_trades"] ARGS_PLOT_PROFIT = ["pairs", "timerange", "export", "exportfilename", "db_url", - "trade_source", "ticker_interval"] + "trade_source", "timeframe"] + +ARGS_SHOW_TRADES = ["db_url", "trade_ids", "print_json"] ARGS_HYPEROPT_LIST = ["hyperopt_list_best", "hyperopt_list_profitable", "hyperopt_list_min_trades", "hyperopt_list_max_trades", "hyperopt_list_min_avg_time", "hyperopt_list_max_avg_time", "hyperopt_list_min_avg_profit", "hyperopt_list_max_avg_profit", "hyperopt_list_min_total_profit", "hyperopt_list_max_total_profit", + "hyperopt_list_min_objective", "hyperopt_list_max_objective", "print_colorized", "print_json", "hyperopt_list_no_details", "export_csv"] @@ -76,9 +81,9 @@ ARGS_HYPEROPT_SHOW = ["hyperopt_list_best", "hyperopt_list_profitable", "hyperop "print_json", "hyperopt_show_no_header"] NO_CONF_REQURIED = ["convert-data", "convert-trade-data", "download-data", "list-timeframes", - "list-markets", "list-pairs", "list-strategies", + "list-markets", "list-pairs", "list-strategies", "list-data", "list-hyperopts", "hyperopt-list", "hyperopt-show", - "plot-dataframe", "plot-profit"] + "plot-dataframe", "plot-profit", "show-trades"] NO_CONF_ALLOWED = ["create-userdir", "list-exchanges", "new-hyperopt", "new-strategy"] @@ -157,13 +162,13 @@ class Arguments: self._build_args(optionlist=['version'], parser=self.parser) from freqtrade.commands import (start_create_userdir, start_convert_data, - start_download_data, + start_download_data, start_list_data, start_hyperopt_list, start_hyperopt_show, start_list_exchanges, start_list_hyperopts, start_list_markets, start_list_strategies, start_list_timeframes, start_new_config, start_new_hyperopt, start_new_strategy, - start_plot_dataframe, start_plot_profit, + start_plot_dataframe, start_plot_profit, start_show_trades, start_backtesting, start_hyperopt, start_edge, start_test_pairlist, start_trading) @@ -179,25 +184,6 @@ class Arguments: trade_cmd.set_defaults(func=start_trading) self._build_args(optionlist=ARGS_TRADE, parser=trade_cmd) - # Add backtesting subcommand - backtesting_cmd = subparsers.add_parser('backtesting', help='Backtesting module.', - parents=[_common_parser, _strategy_parser]) - backtesting_cmd.set_defaults(func=start_backtesting) - self._build_args(optionlist=ARGS_BACKTEST, parser=backtesting_cmd) - - # Add edge subcommand - edge_cmd = subparsers.add_parser('edge', help='Edge module.', - parents=[_common_parser, _strategy_parser]) - edge_cmd.set_defaults(func=start_edge) - self._build_args(optionlist=ARGS_EDGE, parser=edge_cmd) - - # Add hyperopt subcommand - hyperopt_cmd = subparsers.add_parser('hyperopt', help='Hyperopt module.', - parents=[_common_parser, _strategy_parser], - ) - hyperopt_cmd.set_defaults(func=start_hyperopt) - self._build_args(optionlist=ARGS_HYPEROPT, parser=hyperopt_cmd) - # add create-userdir subcommand create_userdir_cmd = subparsers.add_parser('create-userdir', help="Create user-data directory.", @@ -211,79 +197,17 @@ class Arguments: build_config_cmd.set_defaults(func=start_new_config) self._build_args(optionlist=ARGS_BUILD_CONFIG, parser=build_config_cmd) - # add new-strategy subcommand - build_strategy_cmd = subparsers.add_parser('new-strategy', - help="Create new strategy") - build_strategy_cmd.set_defaults(func=start_new_strategy) - self._build_args(optionlist=ARGS_BUILD_STRATEGY, parser=build_strategy_cmd) - # add new-hyperopt subcommand build_hyperopt_cmd = subparsers.add_parser('new-hyperopt', help="Create new hyperopt") build_hyperopt_cmd.set_defaults(func=start_new_hyperopt) self._build_args(optionlist=ARGS_BUILD_HYPEROPT, parser=build_hyperopt_cmd) - # Add list-strategies subcommand - list_strategies_cmd = subparsers.add_parser( - 'list-strategies', - help='Print available strategies.', - parents=[_common_parser], - ) - list_strategies_cmd.set_defaults(func=start_list_strategies) - self._build_args(optionlist=ARGS_LIST_STRATEGIES, parser=list_strategies_cmd) - - # Add list-hyperopts subcommand - list_hyperopts_cmd = subparsers.add_parser( - 'list-hyperopts', - help='Print available hyperopt classes.', - parents=[_common_parser], - ) - list_hyperopts_cmd.set_defaults(func=start_list_hyperopts) - self._build_args(optionlist=ARGS_LIST_HYPEROPTS, parser=list_hyperopts_cmd) - - # Add list-exchanges subcommand - list_exchanges_cmd = subparsers.add_parser( - 'list-exchanges', - help='Print available exchanges.', - parents=[_common_parser], - ) - list_exchanges_cmd.set_defaults(func=start_list_exchanges) - self._build_args(optionlist=ARGS_LIST_EXCHANGES, parser=list_exchanges_cmd) - - # Add list-timeframes subcommand - list_timeframes_cmd = subparsers.add_parser( - 'list-timeframes', - help='Print available ticker intervals (timeframes) for the exchange.', - parents=[_common_parser], - ) - list_timeframes_cmd.set_defaults(func=start_list_timeframes) - self._build_args(optionlist=ARGS_LIST_TIMEFRAMES, parser=list_timeframes_cmd) - - # Add list-markets subcommand - list_markets_cmd = subparsers.add_parser( - 'list-markets', - help='Print markets on exchange.', - parents=[_common_parser], - ) - list_markets_cmd.set_defaults(func=partial(start_list_markets, pairs_only=False)) - self._build_args(optionlist=ARGS_LIST_PAIRS, parser=list_markets_cmd) - - # Add list-pairs subcommand - list_pairs_cmd = subparsers.add_parser( - 'list-pairs', - help='Print pairs on exchange.', - parents=[_common_parser], - ) - list_pairs_cmd.set_defaults(func=partial(start_list_markets, pairs_only=True)) - self._build_args(optionlist=ARGS_LIST_PAIRS, parser=list_pairs_cmd) - - # Add test-pairlist subcommand - test_pairlist_cmd = subparsers.add_parser( - 'test-pairlist', - help='Test your pairlist configuration.', - ) - test_pairlist_cmd.set_defaults(func=start_test_pairlist) - self._build_args(optionlist=ARGS_TEST_PAIRLIST, parser=test_pairlist_cmd) + # add new-strategy subcommand + build_strategy_cmd = subparsers.add_parser('new-strategy', + help="Create new strategy") + build_strategy_cmd.set_defaults(func=start_new_strategy) + self._build_args(optionlist=ARGS_BUILD_STRATEGY, parser=build_strategy_cmd) # Add download-data subcommand download_data_cmd = subparsers.add_parser( @@ -312,23 +236,33 @@ class Arguments: convert_trade_data_cmd.set_defaults(func=partial(start_convert_data, ohlcv=False)) self._build_args(optionlist=ARGS_CONVERT_DATA, parser=convert_trade_data_cmd) - # Add Plotting subcommand - plot_dataframe_cmd = subparsers.add_parser( - 'plot-dataframe', - help='Plot candles with indicators.', - parents=[_common_parser, _strategy_parser], - ) - plot_dataframe_cmd.set_defaults(func=start_plot_dataframe) - self._build_args(optionlist=ARGS_PLOT_DATAFRAME, parser=plot_dataframe_cmd) - - # Plot profit - plot_profit_cmd = subparsers.add_parser( - 'plot-profit', - help='Generate plot showing profits.', + # Add list-data subcommand + list_data_cmd = subparsers.add_parser( + 'list-data', + help='List downloaded data.', parents=[_common_parser], ) - plot_profit_cmd.set_defaults(func=start_plot_profit) - self._build_args(optionlist=ARGS_PLOT_PROFIT, parser=plot_profit_cmd) + list_data_cmd.set_defaults(func=start_list_data) + self._build_args(optionlist=ARGS_LIST_DATA, parser=list_data_cmd) + + # Add backtesting subcommand + backtesting_cmd = subparsers.add_parser('backtesting', help='Backtesting module.', + parents=[_common_parser, _strategy_parser]) + backtesting_cmd.set_defaults(func=start_backtesting) + self._build_args(optionlist=ARGS_BACKTEST, parser=backtesting_cmd) + + # Add edge subcommand + edge_cmd = subparsers.add_parser('edge', help='Edge module.', + parents=[_common_parser, _strategy_parser]) + edge_cmd.set_defaults(func=start_edge) + self._build_args(optionlist=ARGS_EDGE, parser=edge_cmd) + + # Add hyperopt subcommand + hyperopt_cmd = subparsers.add_parser('hyperopt', help='Hyperopt module.', + parents=[_common_parser, _strategy_parser], + ) + hyperopt_cmd.set_defaults(func=start_hyperopt) + self._build_args(optionlist=ARGS_HYPEROPT, parser=hyperopt_cmd) # Add hyperopt-list subcommand hyperopt_list_cmd = subparsers.add_parser( @@ -347,3 +281,92 @@ class Arguments: ) hyperopt_show_cmd.set_defaults(func=start_hyperopt_show) self._build_args(optionlist=ARGS_HYPEROPT_SHOW, parser=hyperopt_show_cmd) + + # Add list-exchanges subcommand + list_exchanges_cmd = subparsers.add_parser( + 'list-exchanges', + help='Print available exchanges.', + parents=[_common_parser], + ) + list_exchanges_cmd.set_defaults(func=start_list_exchanges) + self._build_args(optionlist=ARGS_LIST_EXCHANGES, parser=list_exchanges_cmd) + + # Add list-hyperopts subcommand + list_hyperopts_cmd = subparsers.add_parser( + 'list-hyperopts', + help='Print available hyperopt classes.', + parents=[_common_parser], + ) + list_hyperopts_cmd.set_defaults(func=start_list_hyperopts) + self._build_args(optionlist=ARGS_LIST_HYPEROPTS, parser=list_hyperopts_cmd) + + # Add list-markets subcommand + list_markets_cmd = subparsers.add_parser( + 'list-markets', + help='Print markets on exchange.', + parents=[_common_parser], + ) + list_markets_cmd.set_defaults(func=partial(start_list_markets, pairs_only=False)) + self._build_args(optionlist=ARGS_LIST_PAIRS, parser=list_markets_cmd) + + # Add list-pairs subcommand + list_pairs_cmd = subparsers.add_parser( + 'list-pairs', + help='Print pairs on exchange.', + parents=[_common_parser], + ) + list_pairs_cmd.set_defaults(func=partial(start_list_markets, pairs_only=True)) + self._build_args(optionlist=ARGS_LIST_PAIRS, parser=list_pairs_cmd) + + # Add list-strategies subcommand + list_strategies_cmd = subparsers.add_parser( + 'list-strategies', + help='Print available strategies.', + parents=[_common_parser], + ) + list_strategies_cmd.set_defaults(func=start_list_strategies) + self._build_args(optionlist=ARGS_LIST_STRATEGIES, parser=list_strategies_cmd) + + # Add list-timeframes subcommand + list_timeframes_cmd = subparsers.add_parser( + 'list-timeframes', + help='Print available timeframes for the exchange.', + parents=[_common_parser], + ) + list_timeframes_cmd.set_defaults(func=start_list_timeframes) + self._build_args(optionlist=ARGS_LIST_TIMEFRAMES, parser=list_timeframes_cmd) + + # Add show-trades subcommand + show_trades = subparsers.add_parser( + 'show-trades', + help='Show trades.', + parents=[_common_parser], + ) + show_trades.set_defaults(func=start_show_trades) + self._build_args(optionlist=ARGS_SHOW_TRADES, parser=show_trades) + + # Add test-pairlist subcommand + test_pairlist_cmd = subparsers.add_parser( + 'test-pairlist', + help='Test your pairlist configuration.', + ) + test_pairlist_cmd.set_defaults(func=start_test_pairlist) + self._build_args(optionlist=ARGS_TEST_PAIRLIST, parser=test_pairlist_cmd) + + # Add Plotting subcommand + plot_dataframe_cmd = subparsers.add_parser( + 'plot-dataframe', + help='Plot candles with indicators.', + parents=[_common_parser, _strategy_parser], + ) + plot_dataframe_cmd.set_defaults(func=start_plot_dataframe) + self._build_args(optionlist=ARGS_PLOT_DATAFRAME, parser=plot_dataframe_cmd) + + # Plot profit + plot_profit_cmd = subparsers.add_parser( + 'plot-profit', + help='Generate plot showing profits.', + parents=[_common_parser], + ) + plot_profit_cmd.set_defaults(func=start_plot_profit) + self._build_args(optionlist=ARGS_PLOT_PROFIT, parser=plot_profit_cmd) diff --git a/freqtrade/commands/build_config_commands.py b/freqtrade/commands/build_config_commands.py index 58ac6ec27..0c98b2e55 100644 --- a/freqtrade/commands/build_config_commands.py +++ b/freqtrade/commands/build_config_commands.py @@ -75,8 +75,8 @@ def ask_user_config() -> Dict[str, Any]: }, { "type": "text", - "name": "ticker_interval", - "message": "Please insert your timeframe (ticker interval):", + "name": "timeframe", + "message": "Please insert your desired timeframe (e.g. 5m):", "default": "5m", }, { @@ -163,7 +163,7 @@ def deploy_new_config(config_path: Path, selections: Dict[str, Any]) -> None: ) except TemplateNotFound: selections['exchange'] = render_template( - templatefile=f"subtemplates/exchange_generic.j2", + templatefile="subtemplates/exchange_generic.j2", arguments=selections ) diff --git a/freqtrade/commands/cli_options.py b/freqtrade/commands/cli_options.py index 5cf1b7fce..8eb5c3ce8 100644 --- a/freqtrade/commands/cli_options.py +++ b/freqtrade/commands/cli_options.py @@ -110,8 +110,8 @@ AVAILABLE_CLI_OPTIONS = { action='store_true', ), # Optimize common - "ticker_interval": Arg( - '-i', '--ticker-interval', + "timeframe": Arg( + '-i', '--timeframe', '--ticker-interval', help='Specify ticker interval (`1m`, `5m`, `30m`, `1h`, `1d`).', ), "timerange": Arg( @@ -217,7 +217,7 @@ AVAILABLE_CLI_OPTIONS = { ), "print_json": Arg( '--print-json', - help='Print best result detailization in JSON format.', + help='Print output in JSON format.', action='store_true', default=False, ), @@ -372,8 +372,8 @@ AVAILABLE_CLI_OPTIONS = { ), "timeframes": Arg( '-t', '--timeframes', - help=f'Specify which tickers to download. Space-separated list. ' - f'Default: `1m 5m`.', + help='Specify which tickers to download. Space-separated list. ' + 'Default: `1m 5m`.', choices=['1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h', '6h', '8h', '12h', '1d', '3d', '1w'], default=['1m', '5m'], @@ -387,9 +387,9 @@ AVAILABLE_CLI_OPTIONS = { # Templating options "template": Arg( '--template', - help='Use a template which is either `minimal` or ' - '`full` (containing multiple sample indicators). Default: `%(default)s`.', - choices=['full', 'minimal'], + help='Use a template which is either `minimal`, ' + '`full` (containing multiple sample indicators) or `advanced`. Default: `%(default)s`.', + choices=['full', 'minimal', 'advanced'], default='full', ), # Plot dataframe @@ -425,6 +425,11 @@ AVAILABLE_CLI_OPTIONS = { choices=["DB", "file"], default="file", ), + "trade_ids": Arg( + '--trade-ids', + help='Specify the list of trade ids.', + nargs='+', + ), # hyperopt-list, hyperopt-show "hyperopt_list_profitable": Arg( '--profitable', @@ -450,37 +455,49 @@ AVAILABLE_CLI_OPTIONS = { ), "hyperopt_list_min_avg_time": Arg( '--min-avg-time', - help='Select epochs on above average time.', + help='Select epochs above average time.', type=float, metavar='FLOAT', ), "hyperopt_list_max_avg_time": Arg( '--max-avg-time', - help='Select epochs on under average time.', + help='Select epochs below average time.', type=float, metavar='FLOAT', ), "hyperopt_list_min_avg_profit": Arg( '--min-avg-profit', - help='Select epochs on above average profit.', + help='Select epochs above average profit.', type=float, metavar='FLOAT', ), "hyperopt_list_max_avg_profit": Arg( '--max-avg-profit', - help='Select epochs on below average profit.', + help='Select epochs below average profit.', type=float, metavar='FLOAT', ), "hyperopt_list_min_total_profit": Arg( '--min-total-profit', - help='Select epochs on above total profit.', + help='Select epochs above total profit.', type=float, metavar='FLOAT', ), "hyperopt_list_max_total_profit": Arg( '--max-total-profit', - help='Select epochs on below total profit.', + help='Select epochs below total profit.', + type=float, + metavar='FLOAT', + ), + "hyperopt_list_min_objective": Arg( + '--min-objective', + help='Select epochs above objective.', + type=float, + metavar='FLOAT', + ), + "hyperopt_list_max_objective": Arg( + '--max-objective', + help='Select epochs below objective.', type=float, metavar='FLOAT', ), diff --git a/freqtrade/commands/data_commands.py b/freqtrade/commands/data_commands.py index fc3a49f1d..aa0b826b5 100644 --- a/freqtrade/commands/data_commands.py +++ b/freqtrade/commands/data_commands.py @@ -1,5 +1,6 @@ import logging import sys +from collections import defaultdict from typing import Any, Dict, List import arrow @@ -11,6 +12,7 @@ from freqtrade.data.history import (convert_trades_to_ohlcv, refresh_backtest_ohlcv_data, refresh_backtest_trades_data) from freqtrade.exceptions import OperationalException +from freqtrade.exchange import timeframe_to_minutes from freqtrade.resolvers import ExchangeResolver from freqtrade.state import RunMode @@ -88,3 +90,30 @@ def start_convert_data(args: Dict[str, Any], ohlcv: bool = True) -> None: convert_trades_format(config, convert_from=args['format_from'], convert_to=args['format_to'], erase=args['erase']) + + +def start_list_data(args: Dict[str, Any]) -> None: + """ + List available backtest data + """ + + config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE) + + from freqtrade.data.history.idatahandler import get_datahandler + from tabulate import tabulate + dhc = get_datahandler(config['datadir'], config['dataformat_ohlcv']) + + paircombs = dhc.ohlcv_get_available_data(config['datadir']) + + if args['pairs']: + paircombs = [comb for comb in paircombs if comb[0] in args['pairs']] + + print(f"Found {len(paircombs)} pair / timeframe combinations.") + groupedpair = defaultdict(list) + for pair, timeframe in sorted(paircombs, key=lambda x: (x[0], timeframe_to_minutes(x[1]))): + groupedpair[pair].append(timeframe) + + if groupedpair: + print(tabulate([(pair, ', '.join(timeframes)) for pair, timeframes in groupedpair.items()], + headers=("Pair", "Timeframe"), + tablefmt='psql', stralign='right')) diff --git a/freqtrade/commands/deploy_commands.py b/freqtrade/commands/deploy_commands.py index f5a68f748..86562fa7c 100644 --- a/freqtrade/commands/deploy_commands.py +++ b/freqtrade/commands/deploy_commands.py @@ -8,7 +8,7 @@ from freqtrade.configuration.directory_operations import (copy_sample_files, create_userdata_dir) from freqtrade.constants import USERPATH_HYPEROPTS, USERPATH_STRATEGIES from freqtrade.exceptions import OperationalException -from freqtrade.misc import render_template +from freqtrade.misc import render_template, render_template_with_fallback from freqtrade.state import RunMode logger = logging.getLogger(__name__) @@ -32,10 +32,27 @@ def deploy_new_strategy(strategy_name: str, strategy_path: Path, subtemplate: st """ Deploy new strategy from template to strategy_path """ - indicators = render_template(templatefile=f"subtemplates/indicators_{subtemplate}.j2",) - buy_trend = render_template(templatefile=f"subtemplates/buy_trend_{subtemplate}.j2",) - sell_trend = render_template(templatefile=f"subtemplates/sell_trend_{subtemplate}.j2",) - plot_config = render_template(templatefile=f"subtemplates/plot_config_{subtemplate}.j2",) + fallback = 'full' + indicators = render_template_with_fallback( + templatefile=f"subtemplates/indicators_{subtemplate}.j2", + templatefallbackfile=f"subtemplates/indicators_{fallback}.j2", + ) + buy_trend = render_template_with_fallback( + templatefile=f"subtemplates/buy_trend_{subtemplate}.j2", + templatefallbackfile=f"subtemplates/buy_trend_{fallback}.j2", + ) + sell_trend = render_template_with_fallback( + templatefile=f"subtemplates/sell_trend_{subtemplate}.j2", + templatefallbackfile=f"subtemplates/sell_trend_{fallback}.j2", + ) + plot_config = render_template_with_fallback( + templatefile=f"subtemplates/plot_config_{subtemplate}.j2", + templatefallbackfile=f"subtemplates/plot_config_{fallback}.j2", + ) + additional_methods = render_template_with_fallback( + templatefile=f"subtemplates/strategy_methods_{subtemplate}.j2", + templatefallbackfile="subtemplates/strategy_methods_empty.j2", + ) strategy_text = render_template(templatefile='base_strategy.py.j2', arguments={"strategy": strategy_name, @@ -43,6 +60,7 @@ def deploy_new_strategy(strategy_name: str, strategy_path: Path, subtemplate: st "buy_trend": buy_trend, "sell_trend": sell_trend, "plot_config": plot_config, + "additional_methods": additional_methods, }) logger.info(f"Writing strategy to `{strategy_path}`.") @@ -73,14 +91,23 @@ def deploy_new_hyperopt(hyperopt_name: str, hyperopt_path: Path, subtemplate: st """ Deploys a new hyperopt template to hyperopt_path """ - buy_guards = render_template( - templatefile=f"subtemplates/hyperopt_buy_guards_{subtemplate}.j2",) - sell_guards = render_template( - templatefile=f"subtemplates/hyperopt_sell_guards_{subtemplate}.j2",) - buy_space = render_template( - templatefile=f"subtemplates/hyperopt_buy_space_{subtemplate}.j2",) - sell_space = render_template( - templatefile=f"subtemplates/hyperopt_sell_space_{subtemplate}.j2",) + fallback = 'full' + buy_guards = render_template_with_fallback( + templatefile=f"subtemplates/hyperopt_buy_guards_{subtemplate}.j2", + templatefallbackfile=f"subtemplates/hyperopt_buy_guards_{fallback}.j2", + ) + sell_guards = render_template_with_fallback( + templatefile=f"subtemplates/hyperopt_sell_guards_{subtemplate}.j2", + templatefallbackfile=f"subtemplates/hyperopt_sell_guards_{fallback}.j2", + ) + buy_space = render_template_with_fallback( + templatefile=f"subtemplates/hyperopt_buy_space_{subtemplate}.j2", + templatefallbackfile=f"subtemplates/hyperopt_buy_space_{fallback}.j2", + ) + sell_space = render_template_with_fallback( + templatefile=f"subtemplates/hyperopt_sell_space_{subtemplate}.j2", + templatefallbackfile=f"subtemplates/hyperopt_sell_space_{fallback}.j2", + ) strategy_text = render_template(templatefile='base_hyperopt.py.j2', arguments={"hyperopt": hyperopt_name, diff --git a/freqtrade/commands/hyperopt_commands.py b/freqtrade/commands/hyperopt_commands.py index 3f61ea66c..4fae51e28 100755 --- a/freqtrade/commands/hyperopt_commands.py +++ b/freqtrade/commands/hyperopt_commands.py @@ -35,36 +35,38 @@ def start_hyperopt_list(args: Dict[str, Any]) -> None: 'filter_min_avg_profit': config.get('hyperopt_list_min_avg_profit', None), 'filter_max_avg_profit': config.get('hyperopt_list_max_avg_profit', None), 'filter_min_total_profit': config.get('hyperopt_list_min_total_profit', None), - 'filter_max_total_profit': config.get('hyperopt_list_max_total_profit', None) + 'filter_max_total_profit': config.get('hyperopt_list_max_total_profit', None), + 'filter_min_objective': config.get('hyperopt_list_min_objective', None), + 'filter_max_objective': config.get('hyperopt_list_max_objective', None), } - trials_file = (config['user_data_dir'] / - 'hyperopt_results' / 'hyperopt_results.pickle') + results_file = (config['user_data_dir'] / + 'hyperopt_results' / 'hyperopt_results.pickle') # Previous evaluations - trials = Hyperopt.load_previous_results(trials_file) - total_epochs = len(trials) + epochs = Hyperopt.load_previous_results(results_file) + total_epochs = len(epochs) - trials = _hyperopt_filter_trials(trials, filteroptions) + epochs = hyperopt_filter_epochs(epochs, filteroptions) if print_colorized: colorama_init(autoreset=True) if not export_csv: try: - print(Hyperopt.get_result_table(config, trials, total_epochs, + print(Hyperopt.get_result_table(config, epochs, total_epochs, not filteroptions['only_best'], print_colorized, 0)) except KeyboardInterrupt: print('User interrupted..') - if trials and not no_details: - sorted_trials = sorted(trials, key=itemgetter('loss')) - results = sorted_trials[0] + if epochs and not no_details: + sorted_epochs = sorted(epochs, key=itemgetter('loss')) + results = sorted_epochs[0] Hyperopt.print_epoch_details(results, total_epochs, print_json, no_header) - if trials and export_csv: + if epochs and export_csv: Hyperopt.export_csv_file( - config, trials, total_epochs, not filteroptions['only_best'], export_csv + config, epochs, total_epochs, not filteroptions['only_best'], export_csv ) @@ -78,8 +80,8 @@ def start_hyperopt_show(args: Dict[str, Any]) -> None: print_json = config.get('print_json', False) no_header = config.get('hyperopt_show_no_header', False) - trials_file = (config['user_data_dir'] / - 'hyperopt_results' / 'hyperopt_results.pickle') + results_file = (config['user_data_dir'] / + 'hyperopt_results' / 'hyperopt_results.pickle') n = config.get('hyperopt_show_index', -1) filteroptions = { @@ -92,93 +94,130 @@ def start_hyperopt_show(args: Dict[str, Any]) -> None: 'filter_min_avg_profit': config.get('hyperopt_list_min_avg_profit', None), 'filter_max_avg_profit': config.get('hyperopt_list_max_avg_profit', None), 'filter_min_total_profit': config.get('hyperopt_list_min_total_profit', None), - 'filter_max_total_profit': config.get('hyperopt_list_max_total_profit', None) + 'filter_max_total_profit': config.get('hyperopt_list_max_total_profit', None), + 'filter_min_objective': config.get('hyperopt_list_min_objective', None), + 'filter_max_objective': config.get('hyperopt_list_max_objective', None) } # Previous evaluations - trials = Hyperopt.load_previous_results(trials_file) - total_epochs = len(trials) + epochs = Hyperopt.load_previous_results(results_file) + total_epochs = len(epochs) - trials = _hyperopt_filter_trials(trials, filteroptions) - trials_epochs = len(trials) + epochs = hyperopt_filter_epochs(epochs, filteroptions) + filtered_epochs = len(epochs) - if n > trials_epochs: + if n > filtered_epochs: raise OperationalException( - f"The index of the epoch to show should be less than {trials_epochs + 1}.") - if n < -trials_epochs: + f"The index of the epoch to show should be less than {filtered_epochs + 1}.") + if n < -filtered_epochs: raise OperationalException( - f"The index of the epoch to show should be greater than {-trials_epochs - 1}.") + f"The index of the epoch to show should be greater than {-filtered_epochs - 1}.") # Translate epoch index from human-readable format to pythonic if n > 0: n -= 1 - if trials: - val = trials[n] + if epochs: + val = epochs[n] Hyperopt.print_epoch_details(val, total_epochs, print_json, no_header, header_str="Epoch details") -def _hyperopt_filter_trials(trials: List, filteroptions: dict) -> List: +def hyperopt_filter_epochs(epochs: List, filteroptions: dict) -> List: """ Filter our items from the list of hyperopt results """ if filteroptions['only_best']: - trials = [x for x in trials if x['is_best']] + epochs = [x for x in epochs if x['is_best']] if filteroptions['only_profitable']: - trials = [x for x in trials if x['results_metrics']['profit'] > 0] - if filteroptions['filter_min_trades'] > 0: - trials = [ - x for x in trials - if x['results_metrics']['trade_count'] > filteroptions['filter_min_trades'] - ] - if filteroptions['filter_max_trades'] > 0: - trials = [ - x for x in trials - if x['results_metrics']['trade_count'] < filteroptions['filter_max_trades'] - ] - if filteroptions['filter_min_avg_time'] is not None: - trials = [x for x in trials if x['results_metrics']['trade_count'] > 0] - trials = [ - x for x in trials - if x['results_metrics']['duration'] > filteroptions['filter_min_avg_time'] - ] - if filteroptions['filter_max_avg_time'] is not None: - trials = [x for x in trials if x['results_metrics']['trade_count'] > 0] - trials = [ - x for x in trials - if x['results_metrics']['duration'] < filteroptions['filter_max_avg_time'] - ] - if filteroptions['filter_min_avg_profit'] is not None: - trials = [x for x in trials if x['results_metrics']['trade_count'] > 0] - trials = [ - x for x in trials - if x['results_metrics']['avg_profit'] - > filteroptions['filter_min_avg_profit'] - ] - if filteroptions['filter_max_avg_profit'] is not None: - trials = [x for x in trials if x['results_metrics']['trade_count'] > 0] - trials = [ - x for x in trials - if x['results_metrics']['avg_profit'] - < filteroptions['filter_max_avg_profit'] - ] - if filteroptions['filter_min_total_profit'] is not None: - trials = [x for x in trials if x['results_metrics']['trade_count'] > 0] - trials = [ - x for x in trials - if x['results_metrics']['profit'] > filteroptions['filter_min_total_profit'] - ] - if filteroptions['filter_max_total_profit'] is not None: - trials = [x for x in trials if x['results_metrics']['trade_count'] > 0] - trials = [ - x for x in trials - if x['results_metrics']['profit'] < filteroptions['filter_max_total_profit'] - ] + epochs = [x for x in epochs if x['results_metrics']['profit'] > 0] - logger.info(f"{len(trials)} " + + epochs = _hyperopt_filter_epochs_trade_count(epochs, filteroptions) + + epochs = _hyperopt_filter_epochs_duration(epochs, filteroptions) + + epochs = _hyperopt_filter_epochs_profit(epochs, filteroptions) + + epochs = _hyperopt_filter_epochs_objective(epochs, filteroptions) + + logger.info(f"{len(epochs)} " + ("best " if filteroptions['only_best'] else "") + ("profitable " if filteroptions['only_profitable'] else "") + "epochs found.") + return epochs - return trials + +def _hyperopt_filter_epochs_trade_count(epochs: List, filteroptions: dict) -> List: + + if filteroptions['filter_min_trades'] > 0: + epochs = [ + x for x in epochs + if x['results_metrics']['trade_count'] > filteroptions['filter_min_trades'] + ] + if filteroptions['filter_max_trades'] > 0: + epochs = [ + x for x in epochs + if x['results_metrics']['trade_count'] < filteroptions['filter_max_trades'] + ] + return epochs + + +def _hyperopt_filter_epochs_duration(epochs: List, filteroptions: dict) -> List: + + if filteroptions['filter_min_avg_time'] is not None: + epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0] + epochs = [ + x for x in epochs + if x['results_metrics']['duration'] > filteroptions['filter_min_avg_time'] + ] + if filteroptions['filter_max_avg_time'] is not None: + epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0] + epochs = [ + x for x in epochs + if x['results_metrics']['duration'] < filteroptions['filter_max_avg_time'] + ] + + return epochs + + +def _hyperopt_filter_epochs_profit(epochs: List, filteroptions: dict) -> List: + + if filteroptions['filter_min_avg_profit'] is not None: + epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0] + epochs = [ + x for x in epochs + if x['results_metrics']['avg_profit'] > filteroptions['filter_min_avg_profit'] + ] + if filteroptions['filter_max_avg_profit'] is not None: + epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0] + epochs = [ + x for x in epochs + if x['results_metrics']['avg_profit'] < filteroptions['filter_max_avg_profit'] + ] + if filteroptions['filter_min_total_profit'] is not None: + epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0] + epochs = [ + x for x in epochs + if x['results_metrics']['profit'] > filteroptions['filter_min_total_profit'] + ] + if filteroptions['filter_max_total_profit'] is not None: + epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0] + epochs = [ + x for x in epochs + if x['results_metrics']['profit'] < filteroptions['filter_max_total_profit'] + ] + return epochs + + +def _hyperopt_filter_epochs_objective(epochs: List, filteroptions: dict) -> List: + + if filteroptions['filter_min_objective'] is not None: + epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0] + + epochs = [x for x in epochs if x['loss'] < filteroptions['filter_min_objective']] + if filteroptions['filter_max_objective'] is not None: + epochs = [x for x in epochs if x['results_metrics']['trade_count'] > 0] + + epochs = [x for x in epochs if x['loss'] > filteroptions['filter_max_objective']] + + return epochs diff --git a/freqtrade/commands/list_commands.py b/freqtrade/commands/list_commands.py index 327901dc0..b29aabe25 100644 --- a/freqtrade/commands/list_commands.py +++ b/freqtrade/commands/list_commands.py @@ -102,8 +102,8 @@ def start_list_timeframes(args: Dict[str, Any]) -> None: Print ticker intervals (timeframes) available on Exchange """ config = setup_utils_configuration(args, RunMode.UTIL_EXCHANGE) - # Do not use ticker_interval set in the config - config['ticker_interval'] = None + # Do not use timeframe set in the config + config['timeframe'] = None # Init exchange exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config, validate=False) @@ -197,3 +197,30 @@ def start_list_markets(args: Dict[str, Any], pairs_only: bool = False) -> None: args.get('list_pairs_print_json', False) or args.get('print_csv', False)): print(f"{summary_str}.") + + +def start_show_trades(args: Dict[str, Any]) -> None: + """ + Show trades + """ + from freqtrade.persistence import init, Trade + import json + config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE) + + if 'db_url' not in config: + raise OperationalException("--db-url is required for this command.") + + logger.info(f'Using DB: "{config["db_url"]}"') + init(config['db_url'], clean_open_orders=False) + tfilter = [] + + if config.get('trade_ids'): + tfilter.append(Trade.id.in_(config['trade_ids'])) + + trades = Trade.get_trades(tfilter).all() + logger.info(f"Printing {len(trades)} Trades: ") + if config.get('print_json', False): + print(json.dumps([trade.to_json() for trade in trades], indent=4)) + else: + for trade in trades: + print(trade) diff --git a/freqtrade/commands/pairlist_commands.py b/freqtrade/commands/pairlist_commands.py index bf0b217a5..77bcb04b4 100644 --- a/freqtrade/commands/pairlist_commands.py +++ b/freqtrade/commands/pairlist_commands.py @@ -25,7 +25,6 @@ def start_test_pairlist(args: Dict[str, Any]) -> None: results = {} for curr in quote_currencies: config['stake_currency'] = curr - # Do not use ticker_interval set in the config pairlists = PairListManager(exchange, config) pairlists.refresh_pairlist() results[curr] = pairlists.whitelist diff --git a/freqtrade/commands/trade_commands.py b/freqtrade/commands/trade_commands.py index 352fac26d..c058e4f9d 100644 --- a/freqtrade/commands/trade_commands.py +++ b/freqtrade/commands/trade_commands.py @@ -18,6 +18,9 @@ def start_trading(args: Dict[str, Any]) -> int: try: worker = Worker(args) worker.run() + except Exception as e: + logger.error(str(e)) + logger.exception("Fatal exception!") except KeyboardInterrupt: logger.info('SIGINT received, aborting ...') finally: diff --git a/freqtrade/configuration/configuration.py b/freqtrade/configuration/configuration.py index e5515670d..08a600176 100644 --- a/freqtrade/configuration/configuration.py +++ b/freqtrade/configuration/configuration.py @@ -204,9 +204,9 @@ class Configuration: def _process_optimize_options(self, config: Dict[str, Any]) -> None: # This will override the strategy configuration - self._args_to_config(config, argname='ticker_interval', - logstring='Parameter -i/--ticker-interval detected ... ' - 'Using ticker_interval: {} ...') + self._args_to_config(config, argname='timeframe', + logstring='Parameter -i/--timeframe detected ... ' + 'Using timeframe: {} ...') self._args_to_config(config, argname='position_stacking', logstring='Parameter --enable-position-stacking detected ...') @@ -242,8 +242,8 @@ class Configuration: self._args_to_config(config, argname='strategy_list', logstring='Using strategy list of {} strategies', logfun=len) - self._args_to_config(config, argname='ticker_interval', - logstring='Overriding ticker interval with Command line argument') + self._args_to_config(config, argname='timeframe', + logstring='Overriding timeframe with Command line argument') self._args_to_config(config, argname='export', logstring='Parameter --export detected: {} ...') @@ -334,6 +334,12 @@ class Configuration: self._args_to_config(config, argname='hyperopt_list_max_total_profit', logstring='Parameter --max-total-profit detected: {}') + self._args_to_config(config, argname='hyperopt_list_min_objective', + logstring='Parameter --min-objective detected: {}') + + self._args_to_config(config, argname='hyperopt_list_max_objective', + logstring='Parameter --max-objective detected: {}') + self._args_to_config(config, argname='hyperopt_list_no_details', logstring='Parameter --no-details detected: {}') @@ -351,8 +357,12 @@ class Configuration: self._args_to_config(config, argname='indicators2', logstring='Using indicators2: {}') + self._args_to_config(config, argname='trade_ids', + logstring='Filtering on trade_ids: {}') + self._args_to_config(config, argname='plot_limit', logstring='Limiting plot to: {}') + self._args_to_config(config, argname='trade_source', logstring='Using trades from: {}') diff --git a/freqtrade/configuration/deprecated_settings.py b/freqtrade/configuration/deprecated_settings.py index 55497d4f5..03ed41ab8 100644 --- a/freqtrade/configuration/deprecated_settings.py +++ b/freqtrade/configuration/deprecated_settings.py @@ -58,35 +58,23 @@ def process_temporary_deprecated_settings(config: Dict[str, Any]) -> None: process_deprecated_setting(config, 'ask_strategy', 'ignore_roi_if_buy_signal', 'experimental', 'ignore_roi_if_buy_signal') - if not config.get('pairlists') and not config.get('pairlists'): - config['pairlists'] = [{'method': 'StaticPairList'}] - logger.warning( - "DEPRECATED: " - "Pairlists must be defined explicitly in the future." - "Defaulting to StaticPairList for now.") - - if config.get('pairlist', {}).get("method") == 'VolumePairList': - logger.warning( - "DEPRECATED: " - f"Using VolumePairList in pairlist is deprecated and must be moved to pairlists. " - "Please refer to the docs on configuration details") - pl = {'method': 'VolumePairList'} - pl.update(config.get('pairlist', {}).get('config')) - config['pairlists'].append(pl) - - if config.get('pairlist', {}).get('config', {}).get('precision_filter'): - logger.warning( - "DEPRECATED: " - f"Using precision_filter setting is deprecated and has been replaced by" - "PrecisionFilter. Please refer to the docs on configuration details") - config['pairlists'].append({'method': 'PrecisionFilter'}) - if (config.get('edge', {}).get('enabled', False) and 'capital_available_percentage' in config.get('edge', {})): - logger.warning( + raise OperationalException( "DEPRECATED: " "Using 'edge.capital_available_percentage' has been deprecated in favor of " "'tradable_balance_ratio'. Please migrate your configuration to " "'tradable_balance_ratio' and remove 'capital_available_percentage' " "from the edge configuration." ) + if 'ticker_interval' in config: + logger.warning( + "DEPRECATED: " + "Please use 'timeframe' instead of 'ticker_interval." + ) + if 'timeframe' in config: + raise OperationalException( + "Both 'timeframe' and 'ticker_interval' detected." + "Please remove 'ticker_interval' from your configuration to continue operating." + ) + config['timeframe'] = config['ticker_interval'] diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 54f620631..1dadc6e16 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -3,6 +3,9 @@ """ bot constants """ +from typing import List, Tuple + + DEFAULT_CONFIG = 'config.json' DEFAULT_EXCHANGE = 'bittrex' PROCESS_THROTTLE_SECS = 5 # sec @@ -19,11 +22,15 @@ ORDERBOOK_SIDES = ['ask', 'bid'] ORDERTYPE_POSSIBILITIES = ['limit', 'market'] ORDERTIF_POSSIBILITIES = ['gtc', 'fok', 'ioc'] AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList', - 'PrecisionFilter', 'PriceFilter', 'SpreadFilter'] + 'AgeFilter', 'PrecisionFilter', 'PriceFilter', + 'ShuffleFilter', 'SpreadFilter'] AVAILABLE_DATAHANDLERS = ['json', 'jsongz'] DRY_RUN_WALLET = 1000 MATH_CLOSE_PREC = 1e-14 # Precision used for float comparisons DEFAULT_DATAFRAME_COLUMNS = ['date', 'open', 'high', 'low', 'close', 'volume'] +# Don't modify sequence of DEFAULT_TRADES_COLUMNS +# it has wide consequences for stored trades files +DEFAULT_TRADES_COLUMNS = ['timestamp', 'id', 'type', 'side', 'price', 'amount', 'cost'] USERPATH_HYPEROPTS = 'hyperopts' USERPATH_STRATEGIES = 'strategies' @@ -65,7 +72,7 @@ CONF_SCHEMA = { 'type': 'object', 'properties': { 'max_open_trades': {'type': ['integer', 'number'], 'minimum': -1}, - 'ticker_interval': {'type': 'string'}, + 'timeframe': {'type': 'string'}, 'stake_currency': {'type': 'string'}, 'stake_amount': { 'type': ['number', 'string'], @@ -85,6 +92,7 @@ CONF_SCHEMA = { 'fiat_display_currency': {'type': 'string', 'enum': SUPPORTED_FIAT}, 'dry_run': {'type': 'boolean'}, 'dry_run_wallet': {'type': 'number', 'default': DRY_RUN_WALLET}, + 'cancel_open_orders_on_exit': {'type': 'boolean', 'default': False}, 'process_only_new_candles': {'type': 'boolean'}, 'minimal_roi': { 'type': 'object', @@ -148,7 +156,9 @@ CONF_SCHEMA = { 'emergencysell': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, 'stoploss': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, 'stoploss_on_exchange': {'type': 'boolean'}, - 'stoploss_on_exchange_interval': {'type': 'number'} + 'stoploss_on_exchange_interval': {'type': 'number'}, + 'stoploss_on_exchange_limit_ratio': {'type': 'number', 'minimum': 0.0, + 'maximum': 1.0} }, 'required': ['buy', 'sell', 'stoploss', 'stoploss_on_exchange'] }, @@ -214,12 +224,16 @@ CONF_SCHEMA = { }, 'username': {'type': 'string'}, 'password': {'type': 'string'}, + 'jwt_secret_key': {'type': 'string'}, + 'CORS_origins': {'type': 'array', 'items': {'type': 'string'}}, + 'verbosity': {'type': 'string', 'enum': ['error', 'info']}, }, 'required': ['enabled', 'listen_ip_address', 'listen_port', 'username', 'password'] }, 'db_url': {'type': 'string'}, 'initial_state': {'type': 'string', 'enum': ['running', 'stopped']}, 'forcebuy_enable': {'type': 'boolean'}, + 'disable_dataframe_checks': {'type': 'boolean'}, 'internals': { 'type': 'object', 'default': {}, @@ -278,7 +292,6 @@ CONF_SCHEMA = { 'process_throttle_secs': {'type': 'integer', 'minimum': 600}, 'calculate_since_number_of_days': {'type': 'integer'}, 'allowed_risk': {'type': 'number'}, - 'capital_available_percentage': {'type': 'number'}, 'stoploss_range_min': {'type': 'number'}, 'stoploss_range_max': {'type': 'number'}, 'stoploss_range_step': {'type': 'number'}, @@ -295,6 +308,7 @@ CONF_SCHEMA = { SCHEMA_TRADE_REQUIRED = [ 'exchange', + 'timeframe', 'max_open_trades', 'stake_currency', 'stake_amount', @@ -318,3 +332,14 @@ SCHEMA_MINIMAL_REQUIRED = [ 'dataformat_ohlcv', 'dataformat_trades', ] + +CANCEL_REASON = { + "TIMEOUT": "cancelled due to timeout", + "PARTIALLY_FILLED": "partially filled - keeping order open", + "ALL_CANCELLED": "cancelled (all unfilled and partially filled open orders cancelled)", + "CANCELLED_ON_EXCHANGE": "cancelled on exchange", +} + +# List of pairs with their timeframes +PairWithTimeframe = Tuple[str, str] +ListPairsWithTimeframes = List[PairWithTimeframe] diff --git a/freqtrade/data/btanalysis.py b/freqtrade/data/btanalysis.py index b0c642c1d..b169850ba 100644 --- a/freqtrade/data/btanalysis.py +++ b/freqtrade/data/btanalysis.py @@ -16,7 +16,7 @@ from freqtrade.persistence import Trade logger = logging.getLogger(__name__) # must align with columns in backtest.py -BT_DATA_COLUMNS = ["pair", "profitperc", "open_time", "close_time", "index", "duration", +BT_DATA_COLUMNS = ["pair", "profit_percent", "open_time", "close_time", "index", "duration", "open_rate", "close_rate", "open_at_end", "sell_reason"] @@ -99,11 +99,11 @@ def load_trades_from_db(db_url: str) -> pd.DataFrame: trades: pd.DataFrame = pd.DataFrame([], columns=BT_DATA_COLUMNS) persistence.init(db_url, clean_open_orders=False) - columns = ["pair", "open_time", "close_time", "profit", "profitperc", + columns = ["pair", "open_time", "close_time", "profit", "profit_percent", "open_rate", "close_rate", "amount", "duration", "sell_reason", "fee_open", "fee_close", "open_rate_requested", "close_rate_requested", "stake_amount", "max_rate", "min_rate", "id", "exchange", - "stop_loss", "initial_stop_loss", "strategy", "ticker_interval"] + "stop_loss", "initial_stop_loss", "strategy", "timeframe"] trades = pd.DataFrame([(t.pair, t.open_date.replace(tzinfo=timezone.utc), @@ -121,7 +121,7 @@ def load_trades_from_db(db_url: str) -> pd.DataFrame: t.min_rate, t.id, t.exchange, t.stop_loss, t.initial_stop_loss, - t.strategy, t.ticker_interval + t.strategy, t.timeframe ) for t in Trade.get_trades().all()], columns=columns) @@ -190,15 +190,19 @@ def create_cum_profit(df: pd.DataFrame, trades: pd.DataFrame, col_name: str, """ Adds a column `col_name` with the cumulative profit for the given trades array. :param df: DataFrame with date index - :param trades: DataFrame containing trades (requires columns close_time and profitperc) + :param trades: DataFrame containing trades (requires columns close_time and profit_percent) :param col_name: Column name that will be assigned the results :param timeframe: Timeframe used during the operations :return: Returns df with one additional column, col_name, containing the cumulative profit. + :raise: ValueError if trade-dataframe was found empty. """ + if len(trades) == 0: + raise ValueError("Trade dataframe empty.") from freqtrade.exchange import timeframe_to_minutes timeframe_minutes = timeframe_to_minutes(timeframe) # Resample to timeframe to make sure trades match candles - _trades_sum = trades.resample(f'{timeframe_minutes}min', on='close_time')[['profitperc']].sum() + _trades_sum = trades.resample(f'{timeframe_minutes}min', on='close_time' + )[['profit_percent']].sum() df.loc[:, col_name] = _trades_sum.cumsum() # Set first value to 0 df.loc[df.iloc[0].name, col_name] = 0 @@ -208,13 +212,13 @@ def create_cum_profit(df: pd.DataFrame, trades: pd.DataFrame, col_name: str, def calculate_max_drawdown(trades: pd.DataFrame, *, date_col: str = 'close_time', - value_col: str = 'profitperc' + value_col: str = 'profit_percent' ) -> Tuple[float, pd.Timestamp, pd.Timestamp]: """ Calculate max drawdown and the corresponding close dates - :param trades: DataFrame containing trades (requires columns close_time and profitperc) + :param trades: DataFrame containing trades (requires columns close_time and profit_percent) :param date_col: Column in DataFrame to use for dates (defaults to 'close_time') - :param value_col: Column in DataFrame to use for values (defaults to 'profitperc') + :param value_col: Column in DataFrame to use for values (defaults to 'profit_percent') :return: Tuple (float, highdate, lowdate) with absolute max drawdown, high and low time :raise: ValueError if trade-dataframe was found empty. """ diff --git a/freqtrade/data/converter.py b/freqtrade/data/converter.py index 77371bf27..46b653eb0 100644 --- a/freqtrade/data/converter.py +++ b/freqtrade/data/converter.py @@ -1,14 +1,17 @@ """ Functions to convert data from one format to another """ +import itertools import logging from datetime import datetime, timezone -from typing import Any, Dict +from operator import itemgetter +from typing import Any, Dict, List import pandas as pd from pandas import DataFrame, to_datetime -from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS +from freqtrade.constants import (DEFAULT_DATAFRAME_COLUMNS, + DEFAULT_TRADES_COLUMNS) logger = logging.getLogger(__name__) @@ -154,7 +157,27 @@ def order_book_to_dataframe(bids: list, asks: list) -> DataFrame: return frame -def trades_to_ohlcv(trades: list, timeframe: str) -> DataFrame: +def trades_remove_duplicates(trades: List[List]) -> List[List]: + """ + Removes duplicates from the trades list. + Uses itertools.groupby to avoid converting to pandas. + Tests show it as being pretty efficient on lists of 4M Lists. + :param trades: List of Lists with constants.DEFAULT_TRADES_COLUMNS as columns + :return: same format as above, but with duplicates removed + """ + return [i for i, _ in itertools.groupby(sorted(trades, key=itemgetter(0)))] + + +def trades_dict_to_list(trades: List[Dict]) -> List[List]: + """ + Convert fetch_trades result into a List (to be more memory efficient). + :param trades: List of trades, as returned by ccxt.fetch_trades. + :return: List of Lists, with constants.DEFAULT_TRADES_COLUMNS as columns + """ + return [[t[col] for col in DEFAULT_TRADES_COLUMNS] for t in trades] + + +def trades_to_ohlcv(trades: List, timeframe: str) -> DataFrame: """ Converts trades list to OHLCV list TODO: This should get a dedicated test @@ -164,16 +187,17 @@ def trades_to_ohlcv(trades: list, timeframe: str) -> DataFrame: """ from freqtrade.exchange import timeframe_to_minutes timeframe_minutes = timeframe_to_minutes(timeframe) - df = pd.DataFrame(trades) - df['datetime'] = pd.to_datetime(df['datetime']) - df = df.set_index('datetime') + df = pd.DataFrame(trades, columns=DEFAULT_TRADES_COLUMNS) + df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms', + utc=True,) + df = df.set_index('timestamp') df_new = df['price'].resample(f'{timeframe_minutes}min').ohlc() df_new['volume'] = df['amount'].resample(f'{timeframe_minutes}min').sum() df_new['date'] = df_new.index # Drop 0 volume rows df_new = df_new.dropna() - return df_new[DEFAULT_DATAFRAME_COLUMNS] + return df_new.loc[:, DEFAULT_DATAFRAME_COLUMNS] def convert_trades_format(config: Dict[str, Any], convert_from: str, convert_to: str, erase: bool): @@ -212,12 +236,12 @@ def convert_ohlcv_format(config: Dict[str, Any], convert_from: str, convert_to: from freqtrade.data.history.idatahandler import get_datahandler src = get_datahandler(config['datadir'], convert_from) trg = get_datahandler(config['datadir'], convert_to) - timeframes = config.get('timeframes', [config.get('ticker_interval')]) + 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 ticker_interval. + # Check timeframes or fall back to timeframe. for timeframe in timeframes: config['pairs'].extend(src.ohlcv_get_pairs(config['datadir'], timeframe)) diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 1df710152..3b4de823f 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -5,11 +5,15 @@ including ticker and orderbook data, live and historical candle (OHLCV) data Common Interface for bot and strategy to access data. """ import logging +from datetime import datetime, timezone from typing import Any, Dict, List, Optional, Tuple +from arrow import Arrow from pandas import DataFrame +from freqtrade.constants import ListPairsWithTimeframes, PairWithTimeframe from freqtrade.data.history import load_pair_history +from freqtrade.exceptions import ExchangeError, OperationalException from freqtrade.exchange import Exchange from freqtrade.state import RunMode @@ -18,13 +22,26 @@ logger = logging.getLogger(__name__) class DataProvider: - def __init__(self, config: dict, exchange: Exchange) -> None: + def __init__(self, config: dict, exchange: Exchange, pairlists=None) -> None: self._config = config self._exchange = exchange + self._pairlists = pairlists + self.__cached_pairs: Dict[PairWithTimeframe, Tuple[DataFrame, datetime]] = {} + + def _set_cached_df(self, pair: str, timeframe: str, dataframe: DataFrame) -> None: + """ + Store cached Dataframe. + Using private method as this should never be used by a user + (but the class is exposed via `self.dp` to the strategy) + :param pair: pair to get the data for + :param timeframe: Timeframe to get data for + :param dataframe: analyzed dataframe + """ + self.__cached_pairs[(pair, timeframe)] = (dataframe, Arrow.utcnow().datetime) def refresh(self, - pairlist: List[Tuple[str, str]], - helping_pairs: List[Tuple[str, str]] = None) -> None: + pairlist: ListPairsWithTimeframes, + helping_pairs: ListPairsWithTimeframes = None) -> None: """ Refresh data, called with each cycle """ @@ -34,7 +51,7 @@ class DataProvider: self._exchange.refresh_latest_ohlcv(pairlist) @property - def available_pairs(self) -> List[Tuple[str, str]]: + def available_pairs(self) -> ListPairsWithTimeframes: """ Return a list of tuples containing (pair, timeframe) for which data is currently cached. Should be whitelist + open trades. @@ -51,7 +68,7 @@ class DataProvider: Use False only for read-only operations (where the dataframe is not modified) """ if self.runmode in (RunMode.DRY_RUN, RunMode.LIVE): - return self._exchange.klines((pair, timeframe or self._config['ticker_interval']), + return self._exchange.klines((pair, timeframe or self._config['timeframe']), copy=copy) else: return DataFrame() @@ -63,7 +80,7 @@ class DataProvider: :param timeframe: timeframe to get data for """ return load_pair_history(pair=pair, - timeframe=timeframe or self._config['ticker_interval'], + timeframe=timeframe or self._config['timeframe'], datadir=self._config['datadir'] ) @@ -85,6 +102,20 @@ class DataProvider: logger.warning(f"No data found for ({pair}, {timeframe}).") return data + def get_analyzed_dataframe(self, pair: str, timeframe: str) -> Tuple[DataFrame, datetime]: + """ + :param pair: pair to get the data for + :param timeframe: timeframe to get data for + :return: Tuple of (Analyzed Dataframe, lastrefreshed) for the requested pair / timeframe + combination. + Returns empty dataframe and Epoch 0 (1970-01-01) if no dataframe was cached. + """ + if (pair, timeframe) in self.__cached_pairs: + return self.__cached_pairs[(pair, timeframe)] + else: + + return (DataFrame(), datetime.fromtimestamp(0, tz=timezone.utc)) + def market(self, pair: str) -> Optional[Dict[str, Any]]: """ Return market data for the pair @@ -95,19 +126,24 @@ class DataProvider: def ticker(self, pair: str): """ - Return last ticker data + Return last ticker data from exchange + :param pair: Pair to get the data for + :return: Ticker dict from exchange or empty dict if ticker is not available for the pair """ - # TODO: Implement me - pass + try: + return self._exchange.fetch_ticker(pair) + except ExchangeError: + return {} def orderbook(self, pair: str, maximum: int) -> Dict[str, List]: """ - fetch latest orderbook data + Fetch latest l2 orderbook data + Warning: Does a network request - so use with common sense. :param pair: pair to get the data for :param maximum: Maximum number of orderbook entries to query :return: dict including bids/asks with a total of `maximum` entries. """ - return self._exchange.get_order_book(pair, maximum) + return self._exchange.fetch_l2_order_book(pair, maximum) @property def runmode(self) -> RunMode: @@ -116,3 +152,17 @@ class DataProvider: can be "live", "dry-run", "backtest", "edgecli", "hyperopt" or "other". """ return RunMode(self._config.get('runmode', RunMode.OTHER)) + + def current_whitelist(self) -> List[str]: + """ + fetch latest available whitelist. + + Useful when you have a large whitelist and need to call each pair as an informative pair. + As available pairs does not show whitelist until after informative pairs have been cached. + :return: list of pairs in whitelist + """ + + if self._pairlists: + return self._pairlists.whitelist + else: + raise OperationalException("Dataprovider was not initialized with a pairlist provider.") diff --git a/freqtrade/data/history/history_utils.py b/freqtrade/data/history/history_utils.py index 89d29d33b..58bd752ea 100644 --- a/freqtrade/data/history/history_utils.py +++ b/freqtrade/data/history/history_utils.py @@ -9,10 +9,13 @@ from pandas import DataFrame from freqtrade.configuration import TimeRange from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS -from freqtrade.data.converter import ohlcv_to_dataframe, trades_to_ohlcv +from freqtrade.data.converter import (ohlcv_to_dataframe, + trades_remove_duplicates, + trades_to_ohlcv) from freqtrade.data.history.idatahandler import IDataHandler, get_datahandler from freqtrade.exceptions import OperationalException from freqtrade.exchange import Exchange +from freqtrade.misc import format_ms_time logger = logging.getLogger(__name__) @@ -257,27 +260,45 @@ def _download_trades_history(exchange: Exchange, """ try: - since = timerange.startts * 1000 if timerange and timerange.starttype == 'date' else None + since = timerange.startts * 1000 if \ + (timerange and timerange.starttype == 'date') else int(arrow.utcnow().shift( + days=-30).float_timestamp) * 1000 trades = data_handler.trades_load(pair) - from_id = trades[-1]['id'] if trades else None + # TradesList columns are defined in constants.DEFAULT_TRADES_COLUMNS + # DEFAULT_TRADES_COLUMNS: 0 -> timestamp + # DEFAULT_TRADES_COLUMNS: 1 -> id - logger.debug("Current Start: %s", trades[0]['datetime'] if trades else 'None') - logger.debug("Current End: %s", trades[-1]['datetime'] if trades else 'None') + if trades and since < trades[0][0]: + # since is before the first trade + logger.info(f"Start earlier than available data. Redownloading trades for {pair}...") + trades = [] + + from_id = trades[-1][1] if trades else None + if trades and since < trades[-1][0]: + # Reset since to the last available point + # - 5 seconds (to ensure we're getting all trades) + since = trades[-1][0] - (5 * 1000) + logger.info(f"Using last trade date -5s - Downloading trades for {pair} " + f"since: {format_ms_time(since)}.") + + logger.debug(f"Current Start: {format_ms_time(trades[0][0]) if trades else 'None'}") + logger.debug(f"Current End: {format_ms_time(trades[-1][0]) if trades else 'None'}") + logger.info(f"Current Amount of trades: {len(trades)}") # Default since_ms to 30 days if nothing is given new_trades = exchange.get_historic_trades(pair=pair, - since=since if since else - int(arrow.utcnow().shift( - days=-30).float_timestamp) * 1000, + since=since, from_id=from_id, ) trades.extend(new_trades[1]) + # Remove duplicates to make sure we're not storing data we don't need + trades = trades_remove_duplicates(trades) data_handler.trades_store(pair, data=trades) - logger.debug("New Start: %s", trades[0]['datetime']) - logger.debug("New End: %s", trades[-1]['datetime']) + logger.debug(f"New Start: {format_ms_time(trades[0][0])}") + logger.debug(f"New End: {format_ms_time(trades[-1][0])}") logger.info(f"New Amount of trades: {len(trades)}") return True diff --git a/freqtrade/data/history/idatahandler.py b/freqtrade/data/history/idatahandler.py index 1bb4d5971..96d288e01 100644 --- a/freqtrade/data/history/idatahandler.py +++ b/freqtrade/data/history/idatahandler.py @@ -8,22 +8,35 @@ from abc import ABC, abstractclassmethod, abstractmethod from copy import deepcopy from datetime import datetime, timezone from pathlib import Path -from typing import Dict, List, Optional, Type +from typing import List, Optional, Type from pandas import DataFrame from freqtrade.configuration import TimeRange -from freqtrade.data.converter import clean_ohlcv_dataframe, trim_dataframe +from freqtrade.constants import ListPairsWithTimeframes +from freqtrade.data.converter import (clean_ohlcv_dataframe, + trades_remove_duplicates, trim_dataframe) from freqtrade.exchange import timeframe_to_seconds logger = logging.getLogger(__name__) +# Type for trades list +TradeList = List[List] + class IDataHandler(ABC): def __init__(self, datadir: Path) -> None: self._datadir = datadir + @abstractclassmethod + def ohlcv_get_available_data(cls, datadir: Path) -> ListPairsWithTimeframes: + """ + Returns a list of all pairs with ohlcv data available in this datadir + :param datadir: Directory to search for ohlcv files + :return: List of Tuples of (pair, timeframe) + """ + @abstractclassmethod def ohlcv_get_pairs(cls, datadir: Path, timeframe: str) -> List[str]: """ @@ -89,23 +102,25 @@ class IDataHandler(ABC): """ @abstractmethod - def trades_store(self, pair: str, data: List[Dict]) -> None: + def trades_store(self, pair: str, data: TradeList) -> None: """ Store trades data (list of Dicts) to file :param pair: Pair - used for filename - :param data: List of Dicts containing trade data + :param data: List of Lists containing trade data, + column sequence as in DEFAULT_TRADES_COLUMNS """ @abstractmethod - def trades_append(self, pair: str, data: List[Dict]): + def trades_append(self, pair: str, data: TradeList): """ Append data to existing files :param pair: Pair - used for filename - :param data: List of Dicts containing trade data + :param data: List of Lists containing trade data, + column sequence as in DEFAULT_TRADES_COLUMNS """ @abstractmethod - def trades_load(self, pair: str, timerange: Optional[TimeRange] = None) -> List[Dict]: + def _trades_load(self, pair: str, timerange: Optional[TimeRange] = None) -> TradeList: """ Load a pair from file, either .json.gz or .json :param pair: Load trades for this pair @@ -121,6 +136,16 @@ class IDataHandler(ABC): :return: True when deleted, false if file did not exist. """ + def trades_load(self, pair: str, timerange: Optional[TimeRange] = None) -> TradeList: + """ + Load a pair from file, either .json.gz or .json + Removes duplicates in the process. + :param pair: Load trades for this pair + :param timerange: Timerange to load trades for - currently not implemented + :return: List of trades + """ + return trades_remove_duplicates(self._trades_load(pair, timerange=timerange)) + def ohlcv_load(self, pair, timeframe: str, timerange: Optional[TimeRange] = None, fill_missing: bool = True, diff --git a/freqtrade/data/history/jsondatahandler.py b/freqtrade/data/history/jsondatahandler.py index 363b03958..2e7c0f773 100644 --- a/freqtrade/data/history/jsondatahandler.py +++ b/freqtrade/data/history/jsondatahandler.py @@ -1,15 +1,20 @@ +import logging import re from pathlib import Path -from typing import Dict, List, Optional +from typing import List, Optional import numpy as np from pandas import DataFrame, read_json, to_datetime from freqtrade import misc from freqtrade.configuration import TimeRange -from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS +from freqtrade.constants import (DEFAULT_DATAFRAME_COLUMNS, + ListPairsWithTimeframes) +from freqtrade.data.converter import trades_dict_to_list -from .idatahandler import IDataHandler +from .idatahandler import IDataHandler, TradeList + +logger = logging.getLogger(__name__) class JsonDataHandler(IDataHandler): @@ -17,6 +22,18 @@ class JsonDataHandler(IDataHandler): _use_zip = False _columns = DEFAULT_DATAFRAME_COLUMNS + @classmethod + def ohlcv_get_available_data(cls, datadir: Path) -> ListPairsWithTimeframes: + """ + Returns a list of all pairs with ohlcv data available in this datadir + :param datadir: Directory to search for ohlcv files + :return: List of Tuples of (pair, timeframe) + """ + _tmp = [re.search(r'^([a-zA-Z_]+)\-(\d+\S+)(?=.json)', p.name) + for p in datadir.glob(f"*.{cls._get_file_extension()}")] + return [(match[1].replace('_', '/'), match[2]) for match in _tmp + if match and len(match.groups()) > 1] + @classmethod def ohlcv_get_pairs(cls, datadir: Path, timeframe: str) -> List[str]: """ @@ -113,24 +130,26 @@ class JsonDataHandler(IDataHandler): # Check if regex found something and only return these results to avoid exceptions. return [match[0].replace('_', '/') for match in _tmp if match] - def trades_store(self, pair: str, data: List[Dict]) -> None: + def trades_store(self, pair: str, data: TradeList) -> None: """ Store trades data (list of Dicts) to file :param pair: Pair - used for filename - :param data: List of Dicts containing trade data + :param data: List of Lists containing trade data, + column sequence as in DEFAULT_TRADES_COLUMNS """ filename = self._pair_trades_filename(self._datadir, pair) misc.file_dump_json(filename, data, is_zip=self._use_zip) - def trades_append(self, pair: str, data: List[Dict]): + def trades_append(self, pair: str, data: TradeList): """ Append data to existing files :param pair: Pair - used for filename - :param data: List of Dicts containing trade data + :param data: List of Lists containing trade data, + column sequence as in DEFAULT_TRADES_COLUMNS """ raise NotImplementedError() - def trades_load(self, pair: str, timerange: Optional[TimeRange] = None) -> List[Dict]: + def _trades_load(self, pair: str, timerange: Optional[TimeRange] = None) -> TradeList: """ Load a pair from file, either .json.gz or .json # TODO: respect timerange ... @@ -140,9 +159,15 @@ class JsonDataHandler(IDataHandler): """ filename = self._pair_trades_filename(self._datadir, pair) tradesdata = misc.file_load_json(filename) + if not tradesdata: return [] + if isinstance(tradesdata[0], dict): + # Convert trades dict to list + logger.info("Old trades format detected - converting") + tradesdata = trades_dict_to_list(tradesdata) + pass return tradesdata def trades_purge(self, pair: str) -> bool: diff --git a/freqtrade/edge/edge_positioning.py b/freqtrade/edge/edge_positioning.py index 5305e23cf..1993eded3 100644 --- a/freqtrade/edge/edge_positioning.py +++ b/freqtrade/edge/edge_positioning.py @@ -57,9 +57,7 @@ class Edge: if self.config['stake_amount'] != UNLIMITED_STAKE_AMOUNT: raise OperationalException('Edge works only with unlimited stake amount') - # Deprecated capital_available_percentage. Will use tradable_balance_ratio in the future. - self._capital_percentage: float = self.edge_config.get( - 'capital_available_percentage', self.config['tradable_balance_ratio']) + self._capital_ratio: float = self.config['tradable_balance_ratio'] self._allowed_risk: float = self.edge_config.get('allowed_risk') self._since_number_of_days: int = self.edge_config.get('calculate_since_number_of_days', 14) self._last_updated: int = 0 # Timestamp of pairs last updated time @@ -100,14 +98,14 @@ class Edge: datadir=self.config['datadir'], pairs=pairs, exchange=self.exchange, - timeframe=self.strategy.ticker_interval, + timeframe=self.strategy.timeframe, timerange=self._timerange, ) data = load_data( datadir=self.config['datadir'], pairs=pairs, - timeframe=self.strategy.ticker_interval, + timeframe=self.strategy.timeframe, timerange=self._timerange, startup_candles=self.strategy.startup_candle_count, data_format=self.config.get('dataformat_ohlcv', 'json'), @@ -157,7 +155,7 @@ class Edge: def stake_amount(self, pair: str, free_capital: float, total_capital: float, capital_in_trade: float) -> float: stoploss = self.stoploss(pair) - available_capital = (total_capital + capital_in_trade) * self._capital_percentage + available_capital = (total_capital + capital_in_trade) * self._capital_ratio allowed_capital_at_risk = available_capital * self._allowed_risk max_position_size = abs(allowed_capital_at_risk / stoploss) position_size = min(max_position_size, free_capital) @@ -238,20 +236,9 @@ class Edge: :param result Dataframe :return: result Dataframe """ - - # stake and fees - # stake = 0.015 - # 0.05% is 0.0005 - # fee = 0.001 - - # we set stake amount to an arbitrary amount. - # as it doesn't change the calculation. - # all returned values are relative. - # they are defined as ratios. + # We set stake amount to an arbitrary amount, as it doesn't change the calculation. + # All returned values are relative, they are defined as ratios. stake = 0.015 - fee = self.fee - open_fee = fee / 2 - close_fee = fee / 2 result['trade_duration'] = result['close_time'] - result['open_time'] @@ -262,12 +249,12 @@ class Edge: # Buy Price result['buy_vol'] = stake / result['open_rate'] # How many target are we buying - result['buy_fee'] = stake * open_fee + result['buy_fee'] = stake * self.fee result['buy_spend'] = stake + result['buy_fee'] # How much we're spending # Sell price result['sell_sum'] = result['buy_vol'] * result['close_rate'] - result['sell_fee'] = result['sell_sum'] * close_fee + result['sell_fee'] = result['sell_sum'] * self.fee result['sell_take'] = result['sell_sum'] - result['sell_fee'] # profit_ratio @@ -294,8 +281,8 @@ class Edge: # # Removing Pumps if self.edge_config.get('remove_pumps', False): - results = results.groupby(['pair', 'stoploss']).apply( - lambda x: x[x['profit_abs'] < 2 * x['profit_abs'].std() + x['profit_abs'].mean()]) + results = results[results['profit_abs'] < 2 * results['profit_abs'].std() + + results['profit_abs'].mean()] ########################################################################## # Removing trades having a duration more than X minutes (set in config) diff --git a/freqtrade/exceptions.py b/freqtrade/exceptions.py index 2f05ddb57..c85fccc4b 100644 --- a/freqtrade/exceptions.py +++ b/freqtrade/exceptions.py @@ -21,6 +21,14 @@ class DependencyException(FreqtradeException): """ +class PricingError(DependencyException): + """ + Subclass of DependencyException. + Indicates that the price could not be determined. + Implicitly a buy / sell operation. + """ + + class InvalidOrderException(FreqtradeException): """ This is returned when the order is not valid. Example: @@ -29,9 +37,37 @@ class InvalidOrderException(FreqtradeException): """ -class TemporaryError(FreqtradeException): +class RetryableOrderError(InvalidOrderException): + """ + This is returned when the order is not found. + This Error will be repeated with increasing backof (in line with DDosError). + """ + + +class ExchangeError(DependencyException): + """ + Error raised out of the exchange. + Has multiple Errors to determine the appropriate error. + """ + + +class TemporaryError(ExchangeError): """ Temporary network or exchange related error. This could happen when an exchange is congested, unavailable, or the user has networking problems. Usually resolves itself after a time. """ + + +class DDosProtection(TemporaryError): + """ + Temporary error caused by DDOS protection. + Bot will wait for a second and then retry. + """ + + +class StrategyError(FreqtradeException): + """ + Errors with custom user-code deteced. + Usually caused by errors in the strategy. + """ diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index 875628af9..f2fe1d6ad 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -4,9 +4,11 @@ from typing import Dict import ccxt -from freqtrade.exceptions import (DependencyException, InvalidOrderException, - OperationalException, TemporaryError) +from freqtrade.exceptions import (DDosProtection, ExchangeError, + InvalidOrderException, OperationalException, + TemporaryError) from freqtrade.exchange import Exchange +from freqtrade.exchange.common import retrier logger = logging.getLogger(__name__) @@ -20,7 +22,7 @@ class Binance(Exchange): "trades_pagination_arg": "fromId", } - def get_order_book(self, pair: str, limit: int = 100) -> dict: + def fetch_l2_order_book(self, pair: str, limit: int = 100) -> dict: """ get order book level 2 from exchange @@ -30,7 +32,7 @@ class Binance(Exchange): # get next-higher step in the limit_range list limit = min(list(filter(lambda x: limit <= x, limit_range))) - return super().get_order_book(pair, limit) + return super().fetch_l2_order_book(pair, limit) def stoploss_adjust(self, stop_loss: float, order: Dict) -> bool: """ @@ -39,6 +41,7 @@ class Binance(Exchange): """ return order['type'] == 'stop_loss_limit' and stop_loss > float(order['info']['stopPrice']) + @retrier(retries=0) def stoploss(self, pair: str, amount: float, stop_price: float, order_types: Dict) -> Dict: """ creates a stoploss limit order. @@ -72,13 +75,13 @@ class Binance(Exchange): rate = self.price_to_precision(pair, rate) order = self._api.create_order(symbol=pair, type=ordertype, side='sell', - amount=amount, price=stop_price, params=params) + amount=amount, price=rate, params=params) logger.info('stoploss limit order added for %s. ' 'stop price: %s. limit: %s', pair, stop_price, rate) return order except ccxt.InsufficientFunds as e: - raise DependencyException( - f'Insufficient funds to create {ordertype} sell order on market {pair}.' + raise ExchangeError( + f'Insufficient funds to create {ordertype} sell order on market {pair}. ' f'Tried to sell amount {amount} at rate {rate}. ' f'Message: {e}') from e except ccxt.InvalidOrder as e: @@ -88,6 +91,8 @@ class Binance(Exchange): f'Could not create {ordertype} sell order on market {pair}. ' f'Tried to sell amount {amount} at rate {rate}. ' f'Message: {e}') from e + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e except (ccxt.NetworkError, ccxt.ExchangeError) as e: raise TemporaryError( f'Could not place sell order due to {e.__class__.__name__}. Message: {e}') from e diff --git a/freqtrade/exchange/common.py b/freqtrade/exchange/common.py index b38ed35a3..3bba9be72 100644 --- a/freqtrade/exchange/common.py +++ b/freqtrade/exchange/common.py @@ -1,6 +1,10 @@ +import asyncio import logging +import time +from functools import wraps -from freqtrade.exceptions import DependencyException, TemporaryError +from freqtrade.exceptions import (DDosProtection, RetryableOrderError, + TemporaryError) logger = logging.getLogger(__name__) @@ -88,17 +92,28 @@ MAP_EXCHANGE_CHILDCLASS = { } +def calculate_backoff(retrycount, max_retries): + """ + Calculate backoff + """ + return (max_retries - retrycount) ** 2 + 1 + + def retrier_async(f): async def wrapper(*args, **kwargs): count = kwargs.pop('count', API_RETRY_COUNT) try: return await f(*args, **kwargs) - except (TemporaryError, DependencyException) as ex: + except TemporaryError as ex: logger.warning('%s() returned exception: "%s"', f.__name__, ex) if count > 0: + logger.warning('retrying %s() still for %s times', f.__name__, count) count -= 1 kwargs.update({'count': count}) - logger.warning('retrying %s() still for %s times', f.__name__, count) + if isinstance(ex, DDosProtection): + backoff_delay = calculate_backoff(count + 1, API_RETRY_COUNT) + logger.info(f"Applying DDosProtection backoff delay: {backoff_delay}") + await asyncio.sleep(backoff_delay) return await wrapper(*args, **kwargs) else: logger.warning('Giving up retrying: %s()', f.__name__) @@ -106,19 +121,31 @@ def retrier_async(f): return wrapper -def retrier(f): - def wrapper(*args, **kwargs): - count = kwargs.pop('count', API_RETRY_COUNT) - try: - return f(*args, **kwargs) - except (TemporaryError, DependencyException) as ex: - logger.warning('%s() returned exception: "%s"', f.__name__, ex) - if count > 0: - count -= 1 - kwargs.update({'count': count}) - logger.warning('retrying %s() still for %s times', f.__name__, count) - return wrapper(*args, **kwargs) - else: - logger.warning('Giving up retrying: %s()', f.__name__) - raise ex - return wrapper +def retrier(_func=None, retries=API_RETRY_COUNT): + def decorator(f): + @wraps(f) + def wrapper(*args, **kwargs): + count = kwargs.pop('count', retries) + try: + return f(*args, **kwargs) + except (TemporaryError, RetryableOrderError) as ex: + logger.warning('%s() returned exception: "%s"', f.__name__, ex) + if count > 0: + logger.warning('retrying %s() still for %s times', f.__name__, count) + count -= 1 + kwargs.update({'count': count}) + if isinstance(ex, DDosProtection) or isinstance(ex, RetryableOrderError): + # increasing backoff + backoff_delay = calculate_backoff(count + 1, retries) + logger.info(f"Applying DDosProtection backoff delay: {backoff_delay}") + time.sleep(backoff_delay) + return wrapper(*args, **kwargs) + else: + logger.warning('Giving up retrying: %s()', f.__name__) + raise ex + return wrapper + # Support both @retrier and @retrier(retries=2) syntax + if _func is None: + return decorator + else: + return decorator(_func) diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 1a0565959..df47e7b10 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -18,12 +18,13 @@ from ccxt.base.decimal_to_precision import (ROUND_DOWN, ROUND_UP, TICK_SIZE, TRUNCATE, decimal_to_precision) from pandas import DataFrame -from freqtrade.data.converter import ohlcv_to_dataframe -from freqtrade.exceptions import (DependencyException, InvalidOrderException, - OperationalException, TemporaryError) +from freqtrade.constants import ListPairsWithTimeframes +from freqtrade.data.converter import ohlcv_to_dataframe, trades_dict_to_list +from freqtrade.exceptions import (DDosProtection, ExchangeError, + InvalidOrderException, OperationalException, + RetryableOrderError, TemporaryError) from freqtrade.exchange.common import BAD_EXCHANGES, retrier, retrier_async -from freqtrade.misc import deep_merge_dicts - +from freqtrade.misc import deep_merge_dicts, safe_value_fallback2 CcxtModuleType = Any @@ -79,7 +80,7 @@ class Exchange: if config['dry_run']: logger.info('Instance is running with dry_run enabled') - + logger.info(f"Using CCXT {ccxt.__version__}") exchange_config = config['exchange'] # Deep merge ft_has with default ft_has options @@ -98,12 +99,14 @@ class Exchange: # Initialize ccxt objects ccxt_config = self._ccxt_config.copy() - ccxt_config = deep_merge_dicts(exchange_config.get('ccxt_config', {}), - ccxt_config) - self._api = self._init_ccxt( - exchange_config, ccxt_kwargs=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) + + self._api = self._init_ccxt(exchange_config, ccxt_kwargs=ccxt_config) ccxt_async_config = self._ccxt_config.copy() + ccxt_async_config = deep_merge_dicts(exchange_config.get('ccxt_config', {}), + ccxt_async_config) ccxt_async_config = deep_merge_dicts(exchange_config.get('ccxt_async_config', {}), ccxt_async_config) self._api_async = self._init_ccxt( @@ -113,7 +116,7 @@ class Exchange: if validate: # Check if timeframe is available - self.validate_timeframes(config.get('ticker_interval')) + self.validate_timeframes(config.get('timeframe')) # Initial markets load self._load_markets() @@ -184,11 +187,16 @@ class Exchange: def timeframes(self) -> List[str]: return list((self._api.timeframes or {}).keys()) + @property + def ohlcv_candle_limit(self) -> int: + """exchange ohlcv candle limit""" + return int(self._ohlcv_candle_limit) + @property def markets(self) -> Dict: """exchange ccxt markets""" if not self._api.markets: - logger.warning("Markets were not loaded. Loading them now..") + logger.info("Markets were not loaded. Loading them now..") self._load_markets() return self._api.markets @@ -250,8 +258,8 @@ class Exchange: api.urls['api'] = api.urls['test'] logger.info("Enabled Sandbox API on %s", name) else: - logger.warning(name, "No Sandbox URL in CCXT, exiting. " - "Please check your config.json") + logger.warning( + f"No Sandbox URL in CCXT for {name}, exiting. Please check your config.json") raise OperationalException(f'Exchange {name} does not provide a sandbox api') def _load_async_markets(self, reload: bool = False) -> None: @@ -273,8 +281,8 @@ class Exchange: except ccxt.BaseError as e: logger.warning('Unable to initialize markets. Reason: %s', e) - def _reload_markets(self) -> None: - """Reload markets both sync and async, if refresh interval has passed""" + def reload_markets(self) -> None: + """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 @@ -283,6 +291,8 @@ class Exchange: logger.debug("Performing scheduled market reload..") try: self._api.load_markets(reload=True) + # Also reload async markets to avoid issues with newly listed pairs + self._load_async_markets(reload=True) self._last_markets_refresh = arrow.utcnow().timestamp except ccxt.BaseError: logger.exception("Could not reload markets.") @@ -347,7 +357,7 @@ class Exchange: for pair in [f"{curr_1}/{curr_2}", f"{curr_2}/{curr_1}"]: if pair in self.markets and self.markets[pair].get('active'): return pair - raise DependencyException(f"Could not combine {curr_1} and {curr_2} to get a valid pair.") + raise ExchangeError(f"Could not combine {curr_1} and {curr_2} to get a valid pair.") def validate_timeframes(self, timeframe: Optional[str]) -> None: """ @@ -367,8 +377,7 @@ class Exchange: f"Invalid timeframe '{timeframe}'. This exchange supports: {self.timeframes}") if timeframe and timeframe_to_minutes(timeframe) < 1: - raise OperationalException( - f"Timeframes < 1m are currently not supported by Freqtrade.") + raise OperationalException("Timeframes < 1m are currently not supported by Freqtrade.") def validate_ordertypes(self, order_types: Dict) -> None: """ @@ -471,27 +480,33 @@ class Exchange: "id": order_id, 'pair': pair, 'price': rate, + 'average': rate, 'amount': _amount, - "cost": _amount * rate, + 'cost': _amount * rate, 'type': ordertype, 'side': side, 'remaining': _amount, 'datetime': arrow.utcnow().isoformat(), 'status': "closed" if ordertype == "market" else "open", 'fee': None, - "info": {} + 'info': {} } - self._store_dry_order(dry_order) + self._store_dry_order(dry_order, pair) # Copy order and close it - so the returned order is open unless it's a market order return dry_order - def _store_dry_order(self, dry_order: Dict) -> None: + def _store_dry_order(self, dry_order: Dict, pair: str) -> None: closed_order = dry_order.copy() - if closed_order["type"] in ["market", "limit"]: + if closed_order['type'] in ["market", "limit"]: closed_order.update({ - "status": "closed", - "filled": closed_order["amount"], - "remaining": 0 + 'status': 'closed', + 'filled': closed_order['amount'], + 'remaining': 0, + 'fee': { + 'currency': self.get_pair_quote_currency(pair), + 'cost': dry_order['cost'] * self.get_fee(pair), + 'rate': self.get_fee(pair) + } }) if closed_order["type"] in ["stop_loss_limit"]: closed_order["info"].update({"stopPrice": closed_order["price"]}) @@ -510,15 +525,17 @@ class Exchange: amount, rate_for_order, params) except ccxt.InsufficientFunds as e: - raise DependencyException( - f'Insufficient funds to create {ordertype} {side} order on market {pair}.' + raise ExchangeError( + f'Insufficient funds to create {ordertype} {side} order on market {pair}. ' f'Tried to {side} amount {amount} at rate {rate}.' f'Message: {e}') from e except ccxt.InvalidOrder as e: - raise DependencyException( - f'Could not create {ordertype} {side} order on market {pair}.' - f'Tried to {side} amount {amount} at rate {rate}.' + raise ExchangeError( + f'Could not create {ordertype} {side} order on market {pair}. ' + f'Tried to {side} amount {amount} at rate {rate}. ' f'Message: {e}') from e + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e except (ccxt.NetworkError, ccxt.ExchangeError) as e: raise TemporaryError( f'Could not place {side} order due to {e.__class__.__name__}. Message: {e}') from e @@ -598,6 +615,8 @@ class Exchange: balances.pop("used", None) return balances + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e except (ccxt.NetworkError, ccxt.ExchangeError) as e: raise TemporaryError( f'Could not get balance due to {e.__class__.__name__}. Message: {e}') from e @@ -612,6 +631,8 @@ class Exchange: raise OperationalException( f'Exchange {self._api.name} does not support fetching tickers in batch. ' f'Message: {e}') from e + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e except (ccxt.NetworkError, ccxt.ExchangeError) as e: raise TemporaryError( f'Could not load tickers due to {e.__class__.__name__}. Message: {e}') from e @@ -622,9 +643,11 @@ class Exchange: def fetch_ticker(self, pair: str) -> dict: try: if pair not in self._api.markets or not self._api.markets[pair].get('active'): - raise DependencyException(f"Pair {pair} not available") + raise ExchangeError(f"Pair {pair} not available") data = self._api.fetch_ticker(pair) return data + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e except (ccxt.NetworkError, ccxt.ExchangeError) as e: raise TemporaryError( f'Could not load ticker due to {e.__class__.__name__}. Message: {e}') from e @@ -672,7 +695,7 @@ class Exchange: logger.info("Downloaded data for %s with length %s.", pair, len(data)) return data - def refresh_latest_ohlcv(self, pair_list: List[Tuple[str, str]]) -> List[Tuple[str, List]]: + def refresh_latest_ohlcv(self, pair_list: ListPairsWithTimeframes) -> List[Tuple[str, List]]: """ Refresh in-memory OHLCV asynchronously and set `_klines` with the result Loops asynchronously over pair_list and downloads all pairs async (semi-parallel). @@ -758,6 +781,8 @@ class Exchange: raise OperationalException( f'Exchange {self._api.name} does not support fetching historical ' f'candle (OHLCV) data. Message: {e}') from e + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e except (ccxt.NetworkError, ccxt.ExchangeError) as e: raise TemporaryError(f'Could not fetch historical candle (OHLCV) data ' f'for pair {pair} due to {e.__class__.__name__}. ' @@ -769,7 +794,7 @@ class Exchange: @retrier_async async def _async_fetch_trades(self, pair: str, since: Optional[int] = None, - params: Optional[dict] = None) -> List[Dict]: + params: Optional[dict] = None) -> List[List]: """ Asyncronously gets trade history using fetch_trades. Handles exchange errors, does one call to the exchange. @@ -789,11 +814,13 @@ class Exchange: '(' + arrow.get(since // 1000).isoformat() + ') ' if since is not None else '' ) trades = await self._api_async.fetch_trades(pair, since=since, limit=1000) - return trades + return trades_dict_to_list(trades) except ccxt.NotSupported as e: raise OperationalException( f'Exchange {self._api.name} does not support fetching historical trade data.' f'Message: {e}') from e + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e except (ccxt.NetworkError, ccxt.ExchangeError) as e: raise TemporaryError(f'Could not load trade history due to {e.__class__.__name__}. ' f'Message: {e}') from e @@ -803,7 +830,7 @@ class Exchange: async def _async_get_trade_history_id(self, pair: str, until: int, since: Optional[int] = None, - from_id: Optional[str] = None) -> Tuple[str, List[Dict]]: + from_id: Optional[str] = None) -> Tuple[str, List[List]]: """ Asyncronously gets trade history using fetch_trades use this when exchange uses id-based iteration (check `self._trades_pagination`) @@ -814,7 +841,7 @@ class Exchange: returns tuple: (pair, trades-list) """ - trades: List[Dict] = [] + trades: List[List] = [] if not from_id: # Fetch first elements using timebased method to get an ID to paginate on @@ -823,7 +850,9 @@ class Exchange: # e.g. Binance returns the "last 1000" candles within a 1h time interval # - so we will miss the first trades. t = await self._async_fetch_trades(pair, since=since) - from_id = t[-1]['id'] + # DEFAULT_TRADES_COLUMNS: 0 -> timestamp + # DEFAULT_TRADES_COLUMNS: 1 -> id + from_id = t[-1][1] trades.extend(t[:-1]) while True: t = await self._async_fetch_trades(pair, @@ -831,21 +860,21 @@ class Exchange: if len(t): # Skip last id since its the key for the next call trades.extend(t[:-1]) - if from_id == t[-1]['id'] or t[-1]['timestamp'] > until: + if from_id == t[-1][1] or t[-1][0] > until: logger.debug(f"Stopping because from_id did not change. " - f"Reached {t[-1]['timestamp']} > {until}") + f"Reached {t[-1][0]} > {until}") # Reached the end of the defined-download period - add last trade as well. trades.extend(t[-1:]) break - from_id = t[-1]['id'] + from_id = t[-1][1] else: break return (pair, trades) async def _async_get_trade_history_time(self, pair: str, until: int, - since: Optional[int] = None) -> Tuple[str, List]: + since: Optional[int] = None) -> Tuple[str, List[List]]: """ Asyncronously gets trade history using fetch_trades, when the exchange uses time-based iteration (check `self._trades_pagination`) @@ -855,16 +884,18 @@ class Exchange: returns tuple: (pair, trades-list) """ - trades: List[Dict] = [] + trades: List[List] = [] + # DEFAULT_TRADES_COLUMNS: 0 -> timestamp + # DEFAULT_TRADES_COLUMNS: 1 -> id while True: t = await self._async_fetch_trades(pair, since=since) if len(t): - since = t[-1]['timestamp'] + since = t[-1][1] trades.extend(t) # Reached the end of the defined-download period - if until and t[-1]['timestamp'] > until: + if until and t[-1][0] > until: logger.debug( - f"Stopping because until was reached. {t[-1]['timestamp']} > {until}") + f"Stopping because until was reached. {t[-1][0]} > {until}") break else: break @@ -874,19 +905,24 @@ class Exchange: async def _async_get_trade_history(self, pair: str, since: Optional[int] = None, until: Optional[int] = None, - from_id: Optional[str] = None) -> Tuple[str, List[Dict]]: + from_id: Optional[str] = None) -> Tuple[str, List[List]]: """ Async wrapper handling downloading trades using either time or id based methods. """ + logger.debug(f"_async_get_trade_history(), pair: {pair}, " + f"since: {since}, until: {until}, from_id: {from_id}") + + if until is None: + until = ccxt.Exchange.milliseconds() + logger.debug(f"Exchange milliseconds: {until}") + if self._trades_pagination == 'time': return await self._async_get_trade_history_time( - pair=pair, since=since, - until=until or ccxt.Exchange.milliseconds()) + pair=pair, since=since, until=until) elif self._trades_pagination == 'id': return await self._async_get_trade_history_id( - pair=pair, since=since, - until=until or ccxt.Exchange.milliseconds(), from_id=from_id + pair=pair, since=since, until=until, from_id=from_id ) else: raise OperationalException(f"Exchange {self.name} does use neither time, " @@ -916,7 +952,7 @@ class Exchange: def check_order_canceled_empty(self, order: Dict) -> bool: """ Verify if an order has been cancelled without being partially filled - :param order: Order dict as returned from get_order() + :param order: Order dict as returned from fetch_order() :return: True if order has been cancelled without being filled, False otherwise. """ return order.get('status') in ('closed', 'canceled') and order.get('filled') == 0.0 @@ -931,12 +967,17 @@ class Exchange: except ccxt.InvalidOrder as e: raise InvalidOrderException( f'Could not cancel order. Message: {e}') from e + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e except (ccxt.NetworkError, ccxt.ExchangeError) as e: raise TemporaryError( f'Could not cancel order due to {e.__class__.__name__}. Message: {e}') from e except ccxt.BaseError as e: raise OperationalException(e) from e + # Assign method to fetch_stoploss_order to allow easy overriding in other classes + cancel_stoploss_order = cancel_order + def is_cancel_order_result_suitable(self, corder) -> bool: if not isinstance(corder, dict): return False @@ -948,7 +989,7 @@ class Exchange: """ Cancel order returning a result. Creates a fake result if cancel order returns a non-usable result - and get_order does not work (certain exchanges don't return cancelled orders) + and fetch_order does not work (certain exchanges don't return cancelled orders) :param order_id: Orderid to cancel :param pair: Pair corresponding to order_id :param amount: Amount to use for fake response @@ -959,17 +1000,17 @@ class Exchange: if self.is_cancel_order_result_suitable(corder): return corder except InvalidOrderException: - logger.warning(f"Could not cancel order {order_id}.") + logger.warning(f"Could not cancel order {order_id} for {pair}.") try: - order = self.get_order(order_id, pair) + order = self.fetch_order(order_id, pair) except InvalidOrderException: logger.warning(f"Could not fetch cancelled order {order_id}.") order = {'fee': {}, 'status': 'canceled', 'amount': amount, 'info': {}} return order - @retrier - def get_order(self, order_id: str, pair: str) -> Dict: + @retrier(retries=5) + def fetch_order(self, order_id: str, pair: str) -> Dict: if self._config['dry_run']: try: order = self._dry_run_open_orders[order_id] @@ -980,17 +1021,25 @@ class Exchange: f'Tried to get an invalid dry-run-order (id: {order_id}). Message: {e}') from e try: return self._api.fetch_order(order_id, pair) + except ccxt.OrderNotFound as e: + raise RetryableOrderError( + f'Order not found (pair: {pair} id: {order_id}). Message: {e}') from e except ccxt.InvalidOrder as e: raise InvalidOrderException( - f'Tried to get an invalid order (id: {order_id}). Message: {e}') from e + f'Tried to get an invalid order (pair: {pair} id: {order_id}). Message: {e}') from e + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e except (ccxt.NetworkError, ccxt.ExchangeError) as e: raise TemporaryError( f'Could not get order due to {e.__class__.__name__}. Message: {e}') from e except ccxt.BaseError as e: raise OperationalException(e) from e + # Assign method to fetch_stoploss_order to allow easy overriding in other classes + fetch_stoploss_order = fetch_order + @retrier - def get_order_book(self, pair: str, limit: int = 100) -> dict: + def fetch_l2_order_book(self, pair: str, limit: int = 100) -> dict: """ get order book level 2 from exchange @@ -1004,6 +1053,8 @@ class Exchange: raise OperationalException( f'Exchange {self._api.name} does not support fetching order book.' f'Message: {e}') from e + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e except (ccxt.NetworkError, ccxt.ExchangeError) as e: raise TemporaryError( f'Could not get order book due to {e.__class__.__name__}. Message: {e}') from e @@ -1040,10 +1091,11 @@ class Exchange: matched_trades = [trade for trade in my_trades if trade['order'] == order_id] return matched_trades - - except ccxt.NetworkError as e: + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e + except (ccxt.NetworkError, ccxt.ExchangeError) as e: raise TemporaryError( - f'Could not get trades due to networking error. Message: {e}') from e + f'Could not get trades due to {e.__class__.__name__}. Message: {e}') from e except ccxt.BaseError as e: raise OperationalException(e) from e @@ -1057,12 +1109,71 @@ class Exchange: return self._api.calculate_fee(symbol=symbol, type=type, side=side, amount=amount, price=price, takerOrMaker=taker_or_maker)['rate'] + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e except (ccxt.NetworkError, ccxt.ExchangeError) as e: raise TemporaryError( f'Could not get fee info due to {e.__class__.__name__}. Message: {e}') from e except ccxt.BaseError as e: raise OperationalException(e) from e + @staticmethod + def order_has_fee(order: Dict) -> bool: + """ + Verifies if the passed in order dict has the needed keys to extract fees, + and that these keys (currency, cost) are not empty. + :param order: Order or trade (one trade) dict + :return: True if the fee substructure contains currency and cost, false otherwise + """ + if not isinstance(order, dict): + return False + return ('fee' in order and order['fee'] is not None + and (order['fee'].keys() >= {'currency', 'cost'}) + and order['fee']['currency'] is not None + and order['fee']['cost'] is not None + ) + + def calculate_fee_rate(self, order: Dict) -> Optional[float]: + """ + Calculate fee rate if it's not given by the exchange. + :param order: Order or trade (one trade) dict + """ + if order['fee'].get('rate') is not None: + return order['fee'].get('rate') + fee_curr = order['fee']['currency'] + # Calculate fee based on order details + if fee_curr in self.get_pair_base_currency(order['symbol']): + # Base currency - divide by amount + return round( + order['fee']['cost'] / safe_value_fallback2(order, order, 'filled', 'amount'), 8) + elif fee_curr in self.get_pair_quote_currency(order['symbol']): + # Quote currency - divide by cost + return round(order['fee']['cost'] / order['cost'], 8) if order['cost'] else None + else: + # If Fee currency is a different currency + if not order['cost']: + # If cost is None or 0.0 -> falsy, return None + return None + try: + comb = self.get_valid_pair_combination(fee_curr, self._config['stake_currency']) + tick = self.fetch_ticker(comb) + + fee_to_quote_rate = safe_value_fallback2(tick, tick, 'last', 'ask') + return round((order['fee']['cost'] * fee_to_quote_rate) / order['cost'], 8) + except ExchangeError: + return None + + def extract_cost_curr_rate(self, order: Dict) -> Tuple[float, str, Optional[float]]: + """ + Extract tuple of cost, currency, rate. + Requires order_has_fee to run first! + :param order: Order or trade (one trade) dict + :return: Tuple with cost, currency, rate of the given fee dict + """ + return (order['fee']['cost'], + order['fee']['currency'], + self.calculate_fee_rate(order)) + def is_exchange_bad(exchange_name: str) -> bool: return exchange_name in BAD_EXCHANGES diff --git a/freqtrade/exchange/ftx.py b/freqtrade/exchange/ftx.py index 75915122b..01e8267ad 100644 --- a/freqtrade/exchange/ftx.py +++ b/freqtrade/exchange/ftx.py @@ -2,7 +2,13 @@ import logging from typing import Dict +import ccxt + +from freqtrade.exceptions import (DDosProtection, ExchangeError, + InvalidOrderException, OperationalException, + TemporaryError) from freqtrade.exchange import Exchange +from freqtrade.exchange.common import retrier logger = logging.getLogger(__name__) @@ -10,5 +16,111 @@ logger = logging.getLogger(__name__) class Ftx(Exchange): _ft_has: Dict = { + "stoploss_on_exchange": True, "ohlcv_candle_limit": 1500, } + + def stoploss_adjust(self, stop_loss: float, order: Dict) -> bool: + """ + Verify stop_loss against stoploss-order value (limit or price) + Returns True if adjustment is necessary. + """ + return order['type'] == 'stop' and stop_loss > float(order['price']) + + @retrier(retries=0) + def stoploss(self, pair: str, amount: float, stop_price: float, order_types: Dict) -> Dict: + """ + Creates a stoploss order. + depending on order_types.stoploss configuration, uses 'market' or limit order. + + Limit orders are defined by having orderPrice set, otherwise a market order is used. + """ + limit_price_pct = order_types.get('stoploss_on_exchange_limit_ratio', 0.99) + limit_rate = stop_price * limit_price_pct + + ordertype = "stop" + + stop_price = self.price_to_precision(pair, stop_price) + + if self._config['dry_run']: + dry_order = self.dry_run_order( + pair, ordertype, "sell", amount, stop_price) + return dry_order + + try: + params = self._params.copy() + if order_types.get('stoploss', 'market') == 'limit': + # set orderPrice to place limit order, otherwise it's a market order + params['orderPrice'] = limit_rate + + amount = self.amount_to_precision(pair, amount) + + order = self._api.create_order(symbol=pair, type=ordertype, side='sell', + amount=amount, price=stop_price, params=params) + logger.info('stoploss order added for %s. ' + 'stop price: %s.', pair, stop_price) + return order + except ccxt.InsufficientFunds as e: + raise ExchangeError( + f'Insufficient funds to create {ordertype} sell order on market {pair}. ' + f'Tried to create stoploss with amount {amount} at stoploss {stop_price}. ' + f'Message: {e}') from e + except ccxt.InvalidOrder as e: + raise InvalidOrderException( + f'Could not create {ordertype} sell order on market {pair}. ' + f'Tried to create stoploss with amount {amount} at stoploss {stop_price}. ' + f'Message: {e}') from e + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e + except (ccxt.NetworkError, ccxt.ExchangeError) as e: + raise TemporaryError( + f'Could not place sell order due to {e.__class__.__name__}. Message: {e}') from e + except ccxt.BaseError as e: + raise OperationalException(e) from e + + @retrier(retries=5) + def fetch_stoploss_order(self, order_id: str, pair: str) -> Dict: + if self._config['dry_run']: + try: + order = self._dry_run_open_orders[order_id] + return order + except KeyError as e: + # Gracefully handle errors with dry-run orders. + raise InvalidOrderException( + f'Tried to get an invalid dry-run-order (id: {order_id}). Message: {e}') from e + try: + orders = self._api.fetch_orders(pair, None, params={'type': 'stop'}) + + order = [order for order in orders if order['id'] == order_id] + if len(order) == 1: + return order[0] + else: + raise InvalidOrderException(f"Could not get stoploss order for id {order_id}") + + except ccxt.InvalidOrder as e: + raise InvalidOrderException( + f'Tried to get an invalid order (id: {order_id}). Message: {e}') from e + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e + except (ccxt.NetworkError, ccxt.ExchangeError) as e: + raise TemporaryError( + f'Could not get order due to {e.__class__.__name__}. Message: {e}') from e + except ccxt.BaseError as e: + raise OperationalException(e) from e + + @retrier + def cancel_stoploss_order(self, order_id: str, pair: str) -> Dict: + if self._config['dry_run']: + return {} + try: + return self._api.cancel_order(order_id, pair, params={'type': 'stop'}) + except ccxt.InvalidOrder as e: + raise InvalidOrderException( + f'Could not cancel order. Message: {e}') from e + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e + except (ccxt.NetworkError, ccxt.ExchangeError) as e: + raise TemporaryError( + f'Could not cancel order due to {e.__class__.__name__}. Message: {e}') from e + except ccxt.BaseError as e: + raise OperationalException(e) from e diff --git a/freqtrade/exchange/kraken.py b/freqtrade/exchange/kraken.py index 243f1a6d6..7b9d0f09b 100644 --- a/freqtrade/exchange/kraken.py +++ b/freqtrade/exchange/kraken.py @@ -4,10 +4,11 @@ from typing import Dict import ccxt -from freqtrade.exceptions import (DependencyException, InvalidOrderException, - OperationalException, TemporaryError) +from freqtrade.exceptions import (DDosProtection, ExchangeError, + InvalidOrderException, OperationalException, + TemporaryError) from freqtrade.exchange import Exchange -from freqtrade.exchange.exchange import retrier +from freqtrade.exchange.common import retrier logger = logging.getLogger(__name__) @@ -45,6 +46,8 @@ class Kraken(Exchange): balances[bal]['free'] = balances[bal]['total'] - balances[bal]['used'] return balances + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e except (ccxt.NetworkError, ccxt.ExchangeError) as e: raise TemporaryError( f'Could not get balance due to {e.__class__.__name__}. Message: {e}') from e @@ -58,6 +61,7 @@ class Kraken(Exchange): """ return order['type'] == 'stop-loss' and stop_loss > float(order['price']) + @retrier(retries=0) def stoploss(self, pair: str, amount: float, stop_price: float, order_types: Dict) -> Dict: """ Creates a stoploss market order. @@ -84,8 +88,8 @@ class Kraken(Exchange): 'stop price: %s.', pair, stop_price) return order except ccxt.InsufficientFunds as e: - raise DependencyException( - f'Insufficient funds to create {ordertype} sell order on market {pair}.' + raise ExchangeError( + f'Insufficient funds to create {ordertype} sell order on market {pair}. ' f'Tried to create stoploss with amount {amount} at stoploss {stop_price}. ' f'Message: {e}') from e except ccxt.InvalidOrder as e: @@ -93,6 +97,8 @@ class Kraken(Exchange): f'Could not create {ordertype} sell order on market {pair}. ' f'Tried to create stoploss with amount {amount} at stoploss {stop_price}. ' f'Message: {e}') from e + except ccxt.DDoSProtection as e: + raise DDosProtection(e) from e except (ccxt.NetworkError, ccxt.ExchangeError) as e: raise TemporaryError( f'Could not place sell order due to {e.__class__.__name__}. Message: {e}') from e diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 65ba46987..816d24e18 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -7,26 +7,27 @@ import traceback from datetime import datetime from math import isclose from threading import Lock -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional import arrow from cachetools import TTLCache -from requests.exceptions import RequestException from freqtrade import __version__, constants, persistence from freqtrade.configuration import validate_config_consistency from freqtrade.data.converter import order_book_to_dataframe from freqtrade.data.dataprovider import DataProvider from freqtrade.edge import Edge -from freqtrade.exceptions import DependencyException, InvalidOrderException +from freqtrade.exceptions import (DependencyException, ExchangeError, + InvalidOrderException, PricingError) from freqtrade.exchange import timeframe_to_minutes, timeframe_to_next_date -from freqtrade.misc import safe_value_fallback +from freqtrade.misc import safe_value_fallback, safe_value_fallback2 from freqtrade.pairlist.pairlistmanager import PairListManager from freqtrade.persistence import Trade from freqtrade.resolvers import ExchangeResolver, StrategyResolver from freqtrade.rpc import RPCManager, RPCMessageType from freqtrade.state import State from freqtrade.strategy.interface import IStrategy, SellType +from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper from freqtrade.wallets import Wallets logger = logging.getLogger(__name__) @@ -53,8 +54,11 @@ class FreqtradeBot: # Init objects self.config = config - self._sell_rate_cache = TTLCache(maxsize=100, ttl=5) - self._buy_rate_cache = TTLCache(maxsize=100, ttl=5) + # Cache values for 1800 to avoid frequent polling of the exchange for prices + # Caching only applies to RPC methods, so prices for open trades are still + # refreshed once every iteration. + self._sell_rate_cache = TTLCache(maxsize=100, ttl=1800) + self._buy_rate_cache = TTLCache(maxsize=100, ttl=1800) self.strategy: IStrategy = StrategyResolver.load_strategy(self.config) @@ -67,20 +71,20 @@ class FreqtradeBot: self.wallets = Wallets(self.config, self.exchange) - self.dataprovider = DataProvider(self.config, self.exchange) + self.pairlists = PairListManager(self.exchange, self.config) + + self.dataprovider = DataProvider(self.config, self.exchange, self.pairlists) # Attach Dataprovider to Strategy baseclass IStrategy.dp = self.dataprovider # Attach Wallets to Strategy baseclass IStrategy.wallets = self.wallets - self.pairlists = PairListManager(self.exchange, self.config) - # Initializing Edge only if enabled self.edge = Edge(self.config, self.exchange, self.strategy) if \ self.config.get('edge', {}).get('enabled', False) else None - self.active_pair_whitelist = self._refresh_whitelist() + self.active_pair_whitelist = self._refresh_active_whitelist() # Set initial bot state from config initial_state = self.config.get('initial_state') @@ -112,6 +116,11 @@ class FreqtradeBot: """ logger.info('Cleaning up modules ...') + if self.config['cancel_open_orders_on_exit']: + self.cancel_all_open_orders() + + self.check_for_open_trades() + self.rpc.cleanup() persistence.cleanup() @@ -132,18 +141,22 @@ class FreqtradeBot: :return: True if one or more trades has been created or closed, False otherwise """ - # Check whether markets have to be reloaded - self.exchange._reload_markets() + # Check whether markets have to be reloaded and reload them when it's needed + self.exchange.reload_markets() # Query trades from persistence layer trades = Trade.get_open_trades() - self.active_pair_whitelist = self._refresh_whitelist(trades) + self.active_pair_whitelist = self._refresh_active_whitelist(trades) # Refreshing candles - self.dataprovider.refresh(self._create_pair_whitelist(self.active_pair_whitelist), + self.dataprovider.refresh(self.pairlists.create_pair_list(self.active_pair_whitelist), self.strategy.informative_pairs()) + strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)() + + self.strategy.analyze(self.active_pair_whitelist) + with self._sell_lock: # Check and handle any timed out open orders self.check_handle_timedout() @@ -161,9 +174,35 @@ class FreqtradeBot: Trade.session.flush() - def _refresh_whitelist(self, trades: List[Trade] = []) -> List[str]: + def process_stopped(self) -> None: """ - Refresh whitelist from pairlist or edge and extend it with trades. + Close all orders that were left open + """ + if self.config['cancel_open_orders_on_exit']: + self.cancel_all_open_orders() + + def check_for_open_trades(self): + """ + Notify the user when the bot is stopped + and there are still open trades active. + """ + open_trades = Trade.get_trades([Trade.is_open == 1]).all() + + if len(open_trades) != 0: + msg = { + 'type': RPCMessageType.WARNING_NOTIFICATION, + 'status': f"{len(open_trades)} open trades active.\n\n" + f"Handle these trades manually on {self.exchange.name}, " + f"or '/start' the bot again and use '/stopbuy' " + f"to handle open trades gracefully. \n" + f"{'Trades are simulated.' if self.config['dry_run'] else ''}", + } + self.rpc.send_msg(msg) + + def _refresh_active_whitelist(self, trades: List[Trade] = []) -> List[str]: + """ + Refresh active whitelist from pairlist or edge and extend it with + pairs that have open trades. """ # Refresh whitelist self.pairlists.refresh_pairlist() @@ -180,12 +219,6 @@ class FreqtradeBot: _whitelist.extend([trade.pair for trade in trades if trade.pair not in _whitelist]) return _whitelist - def _create_pair_whitelist(self, pairs: List[str]) -> List[Tuple[str, str]]: - """ - Create pair-whitelist tuple with (pair, ticker_interval) - """ - return [(pair, self.config['ticker_interval']) for pair in pairs] - def get_free_open_trades(self): """ Return the number of free open trades slots or 0 if @@ -251,12 +284,19 @@ class FreqtradeBot: f"Getting price from order book {bid_strategy['price_side'].capitalize()} side." ) order_book_top = bid_strategy.get('order_book_top', 1) - order_book = self.exchange.get_order_book(pair, order_book_top) + order_book = self.exchange.fetch_l2_order_book(pair, order_book_top) logger.debug('order_book %s', order_book) # top 1 = index 0 - order_book_rate = order_book[f"{bid_strategy['price_side']}s"][order_book_top - 1][0] - logger.info(f'...top {order_book_top} order book buy rate {order_book_rate:.8f}') - used_rate = order_book_rate + try: + rate_from_l2 = order_book[f"{bid_strategy['price_side']}s"][order_book_top - 1][0] + except (IndexError, KeyError) as e: + logger.warning( + "Buy Price from orderbook could not be determined." + f"Orderbook: {order_book}" + ) + raise PricingError from e + logger.info(f'...top {order_book_top} order book buy rate {rate_from_l2:.8f}') + used_rate = rate_from_l2 else: logger.info(f"Using Last {bid_strategy['price_side'].capitalize()} / Last Price") ticker = self.exchange.fetch_ticker(pair) @@ -404,9 +444,8 @@ class FreqtradeBot: return False # running get_signal on historical data fetched - (buy, sell) = self.strategy.get_signal( - pair, self.strategy.ticker_interval, - self.dataprovider.ohlcv(pair, self.strategy.ticker_interval)) + analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(pair, self.strategy.timeframe) + (buy, sell) = self.strategy.get_signal(pair, self.strategy.timeframe, analyzed_df) if buy and not sell: stake_amount = self.get_trade_stake_amount(pair) @@ -437,7 +476,7 @@ class FreqtradeBot: """ conf_bids_to_ask_delta = conf.get('bids_to_ask_delta', 0) logger.info(f"Checking depth of market for {pair} ...") - order_book = self.exchange.get_order_book(pair, 1000) + order_book = self.exchange.fetch_l2_order_book(pair, 1000) order_book_data_frame = order_book_to_dataframe(order_book['bids'], order_book['asks']) order_book_bids = order_book_data_frame['b_size'].sum() order_book_asks = order_book_data_frame['a_size'].sum() @@ -479,6 +518,12 @@ class FreqtradeBot: amount = stake_amount / buy_limit_requested order_type = self.strategy.order_types['buy'] + if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)( + pair=pair, order_type=order_type, amount=amount, rate=buy_limit_requested, + time_in_force=time_in_force): + logger.info(f"User requested abortion of buying {pair}") + return False + amount = self.exchange.amount_to_precision(pair, amount) order = self.exchange.buy(pair=pair, ordertype=order_type, amount=amount, rate=buy_limit_requested, time_in_force=time_in_force) @@ -487,6 +532,7 @@ class FreqtradeBot: # we assume the order is executed at the price requested buy_limit_filled_price = buy_limit_requested + amount_requested = amount if order_status == 'expired' or order_status == 'rejected': order_tif = self.strategy.order_time_in_force['buy'] @@ -507,15 +553,15 @@ class FreqtradeBot: order['filled'], order['amount'], order['remaining'] ) stake_amount = order['cost'] - amount = order['amount'] - buy_limit_filled_price = order['price'] + amount = safe_value_fallback(order, 'filled', 'amount') + buy_limit_filled_price = safe_value_fallback(order, 'average', 'price') order_id = None # in case of FOK the order may be filled immediately and fully elif order_status == 'closed': stake_amount = order['cost'] - amount = order['amount'] - buy_limit_filled_price = order['price'] + amount = safe_value_fallback(order, 'filled', 'amount') + buy_limit_filled_price = safe_value_fallback(order, 'average', 'price') # Fee is applied twice because we make a LIMIT_BUY and LIMIT_SELL fee = self.exchange.get_fee(symbol=pair, taker_or_maker='maker') @@ -523,6 +569,7 @@ class FreqtradeBot: pair=pair, stake_amount=stake_amount, amount=amount, + amount_requested=amount_requested, fee_open=fee, fee_close=fee, open_rate=buy_limit_filled_price, @@ -531,7 +578,7 @@ class FreqtradeBot: exchange=self.exchange.id, open_order_id=order_id, strategy=self.strategy.get_strategy_name(), - ticker_interval=timeframe_to_minutes(self.config['ticker_interval']) + timeframe=timeframe_to_minutes(self.config['timeframe']) ) # Update fees if order is closed @@ -553,6 +600,7 @@ class FreqtradeBot: Sends rpc notification when a buy occured. """ msg = { + 'trade_id': trade.id, 'type': RPCMessageType.BUY_NOTIFICATION, 'exchange': self.exchange.name.capitalize(), 'pair': trade.pair, @@ -576,6 +624,7 @@ class FreqtradeBot: current_rate = self.get_buy_rate(trade.pair, False) msg = { + 'trade_id': trade.id, 'type': RPCMessageType.BUY_CANCEL_NOTIFICATION, 'exchange': self.exchange.name.capitalize(), 'pair': trade.pair, @@ -609,11 +658,11 @@ class FreqtradeBot: trades_closed += 1 continue # Check if we can sell our current pair - if trade.open_order_id is None and self.handle_trade(trade): + if trade.open_order_id is None and trade.is_open and self.handle_trade(trade): trades_closed += 1 except DependencyException as exception: - logger.warning('Unable to sell trade: %s', exception) + logger.warning('Unable to sell trade %s: %s', trade.pair, exception) # Updating wallets if any trade occured if trades_closed: @@ -626,7 +675,7 @@ class FreqtradeBot: """ Helper generator to query orderbook in loop (used for early sell-order placing) """ - order_book = self.exchange.get_order_book(pair, order_book_max) + order_book = self.exchange.fetch_l2_order_book(pair, order_book_max) for i in range(order_book_min, order_book_max + 1): yield order_book[side][i - 1][0] @@ -653,10 +702,15 @@ class FreqtradeBot: logger.info( f"Getting price from order book {ask_strategy['price_side'].capitalize()} side." ) - rate = next(self._order_book_gen(pair, f"{ask_strategy['price_side']}s")) - + try: + rate = next(self._order_book_gen(pair, f"{ask_strategy['price_side']}s")) + except (IndexError, KeyError) as e: + logger.warning("Sell Price at location from orderbook could not be determined.") + raise PricingError from e else: rate = self.exchange.fetch_ticker(pair)[ask_strategy['price_side']] + if rate is None: + raise PricingError(f"Sell-Rate for {pair} was empty.") self._sell_rate_cache[pair] = rate return rate @@ -676,23 +730,33 @@ class FreqtradeBot: if (config_ask_strategy.get('use_sell_signal', True) or config_ask_strategy.get('ignore_roi_if_buy_signal', False)): - (buy, sell) = self.strategy.get_signal( - trade.pair, self.strategy.ticker_interval, - self.dataprovider.ohlcv(trade.pair, self.strategy.ticker_interval)) + analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(trade.pair, + self.strategy.timeframe) + + (buy, sell) = self.strategy.get_signal(trade.pair, self.strategy.timeframe, analyzed_df) if config_ask_strategy.get('use_order_book', False): - logger.debug(f'Using order book for selling {trade.pair}...') - # logger.debug('Order book %s',orderBook) order_book_min = config_ask_strategy.get('order_book_min', 1) order_book_max = config_ask_strategy.get('order_book_max', 1) + logger.debug(f'Using order book between {order_book_min} and {order_book_max} ' + f'for selling {trade.pair}...') order_book = self._order_book_gen(trade.pair, f"{config_ask_strategy['price_side']}s", order_book_min=order_book_min, order_book_max=order_book_max) for i in range(order_book_min, order_book_max + 1): - sell_rate = next(order_book) + try: + sell_rate = next(order_book) + except (IndexError, KeyError) as e: + logger.warning( + f"Sell Price at location {i} from orderbook could not be determined." + ) + raise PricingError from e logger.debug(f" order book {config_ask_strategy['price_side']} top {i}: " f"{sell_rate:0.8f}") + # Assign sell-rate to cache - otherwise sell-rate is never updated in the cache, + # resulting in outdated RPC messages + self._sell_rate_cache[trade.pair] = sell_rate if self._check_and_execute_sell(trade, sell_rate, buy, sell): return True @@ -725,7 +789,7 @@ class FreqtradeBot: logger.warning('Selling the trade forcefully') self.execute_sell(trade, trade.stop_loss, sell_reason=SellType.EMERGENCY_SELL) - except DependencyException: + except ExchangeError: trade.stoploss_order_id = None logger.exception('Unable to place a stoploss order on exchange.') return False @@ -743,18 +807,18 @@ class FreqtradeBot: try: # First we check if there is already a stoploss on exchange - stoploss_order = self.exchange.get_order(trade.stoploss_order_id, trade.pair) \ - if trade.stoploss_order_id else None + stoploss_order = self.exchange.fetch_stoploss_order( + trade.stoploss_order_id, trade.pair) if trade.stoploss_order_id else None except InvalidOrderException as exception: logger.warning('Unable to fetch stoploss order: %s', exception) # We check if stoploss order is fulfilled - if stoploss_order and stoploss_order['status'] == 'closed': + if stoploss_order and stoploss_order['status'] in ('closed', 'triggered'): trade.sell_reason = SellType.STOPLOSS_ON_EXCHANGE.value - trade.update(stoploss_order) + self.update_trade_state(trade, stoploss_order, sl_order=True) # Lock pair for one candle to prevent immediate rebuys self.strategy.lock_pair(trade.pair, - timeframe_to_next_date(self.config['ticker_interval'])) + timeframe_to_next_date(self.config['timeframe'])) self._notify_sell(trade, "stoploss") return True @@ -765,10 +829,8 @@ class FreqtradeBot: return False # If buy order is fulfilled but there is no stoploss, we add a stoploss on exchange - if (not stoploss_order): - + if not stoploss_order: stoploss = self.edge.stoploss(pair=trade.pair) if self.edge else self.strategy.stoploss - stop_price = trade.open_rate * (1 + stoploss) if self.create_stoploss_order(trade=trade, stop_price=stop_price, rate=stop_price): @@ -776,7 +838,7 @@ class FreqtradeBot: return False # If stoploss order is canceled for some reason we add it - if stoploss_order and stoploss_order['status'] == 'canceled': + if stoploss_order and stoploss_order['status'] in ('canceled', 'cancelled'): if self.create_stoploss_order(trade=trade, stop_price=trade.stop_loss, rate=trade.stop_loss): return False @@ -809,7 +871,7 @@ class FreqtradeBot: logger.info('Trailing stoploss: cancelling current stoploss on exchange (id:{%s}) ' 'in order to add another one ...', order['id']) try: - self.exchange.cancel_order(order['id'], trade.pair) + self.exchange.cancel_stoploss_order(order['id'], trade.pair) except InvalidOrderException: logger.exception(f"Could not cancel stoploss order {order['id']} " f"for pair {trade.pair}") @@ -860,84 +922,122 @@ class FreqtradeBot: try: if not trade.open_order_id: continue - order = self.exchange.get_order(trade.open_order_id, trade.pair) - except (RequestException, DependencyException, InvalidOrderException): + order = self.exchange.fetch_order(trade.open_order_id, trade.pair) + except (ExchangeError, InvalidOrderException): logger.info('Cannot query order for %s due to %s', trade, traceback.format_exc()) continue - trade_state_update = self.update_trade_state(trade, order) + fully_cancelled = self.update_trade_state(trade, order) - if (order['side'] == 'buy' and ( - trade_state_update - or self._check_timed_out('buy', order))): - self.handle_timedout_limit_buy(trade, order) - self.wallets.update() - order_type = self.strategy.order_types['buy'] - self._notify_buy_cancel(trade, order_type) + if (order['side'] == 'buy' and (order['status'] == 'open' or fully_cancelled) and ( + fully_cancelled + or self._check_timed_out('buy', order) + or strategy_safe_wrapper(self.strategy.check_buy_timeout, + default_retval=False)(pair=trade.pair, + trade=trade, + order=order))): + self.handle_cancel_buy(trade, order, constants.CANCEL_REASON['TIMEOUT']) - elif (order['side'] == 'sell' and ( - trade_state_update - or self._check_timed_out('sell', order))): - reason = self.handle_timedout_limit_sell(trade, order) - self.wallets.update() - order_type = self.strategy.order_types['sell'] - self._notify_sell_cancel(trade, order_type, reason) + elif (order['side'] == 'sell' and (order['status'] == 'open' or fully_cancelled) and ( + fully_cancelled + or self._check_timed_out('sell', order) + or strategy_safe_wrapper(self.strategy.check_sell_timeout, + default_retval=False)(pair=trade.pair, + trade=trade, + order=order))): + self.handle_cancel_sell(trade, order, constants.CANCEL_REASON['TIMEOUT']) - def handle_timedout_limit_buy(self, trade: Trade, order: Dict) -> bool: + def cancel_all_open_orders(self) -> None: """ - Buy timeout - cancel order + Cancel all orders that are currently open + :return: None + """ + + for trade in Trade.get_open_order_trades(): + try: + order = self.exchange.fetch_order(trade.open_order_id, trade.pair) + except (DependencyException, InvalidOrderException): + logger.info('Cannot query order for %s due to %s', trade, traceback.format_exc()) + continue + + if order['side'] == 'buy': + self.handle_cancel_buy(trade, order, constants.CANCEL_REASON['ALL_CANCELLED']) + + elif order['side'] == 'sell': + self.handle_cancel_sell(trade, order, constants.CANCEL_REASON['ALL_CANCELLED']) + + def handle_cancel_buy(self, trade: Trade, order: Dict, reason: str) -> bool: + """ + Buy cancel - cancel order :return: True if order was fully cancelled """ - if order['status'] != 'canceled': - reason = "cancelled due to timeout" + was_trade_fully_canceled = False + + # Cancelled orders may have the status of 'canceled' or 'closed' + if order['status'] not in ('canceled', 'closed'): + reason = constants.CANCEL_REASON['TIMEOUT'] corder = self.exchange.cancel_order_with_result(trade.open_order_id, trade.pair, trade.amount) + # Avoid race condition where the order could not be cancelled coz its already filled. + # Simply bailing here is the only safe way - as this order will then be + # handled in the next iteration. + if corder.get('status') not in ('canceled', 'closed'): + logger.warning(f"Order {trade.open_order_id} for {trade.pair} not cancelled.") + return False else: # Order was cancelled already, so we can reuse the existing dict corder = order - reason = "cancelled on exchange" + reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE'] logger.info('Buy order %s for %s.', reason, trade) - if safe_value_fallback(corder, order, 'remaining', 'remaining') == order['amount']: + # Using filled to determine the filled amount + filled_amount = safe_value_fallback2(corder, order, 'filled', 'filled') + + if isclose(filled_amount, 0.0, abs_tol=constants.MATH_CLOSE_PREC): logger.info('Buy order fully cancelled. Removing %s from database.', trade) # if trade is not partially completed, just delete the trade Trade.session.delete(trade) Trade.session.flush() - return True + was_trade_fully_canceled = True + else: + # if trade is partially complete, edit the stake details for the trade + # and close the order + # cancel_order may not contain the full order dict, so we need to fallback + # to the order dict aquired before cancelling. + # we need to fall back to the values from order if corder does not contain these keys. + trade.amount = filled_amount + trade.stake_amount = trade.amount * trade.open_rate + self.update_trade_state(trade, corder, trade.amount) - # if trade is partially complete, edit the stake details for the trade - # and close the order - # cancel_order may not contain the full order dict, so we need to fallback - # to the order dict aquired before cancelling. - # we need to fall back to the values from order if corder does not contain these keys. - trade.amount = order['amount'] - safe_value_fallback(corder, order, - 'remaining', 'remaining') - trade.stake_amount = trade.amount * trade.open_rate - self.update_trade_state(trade, corder, trade.amount) + trade.open_order_id = None + logger.info('Partial buy order timeout for %s.', trade) + self.rpc.send_msg({ + 'type': RPCMessageType.STATUS_NOTIFICATION, + 'status': f'Remaining buy order for {trade.pair} cancelled due to timeout' + }) - trade.open_order_id = None - logger.info('Partial buy order timeout for %s.', trade) - self.rpc.send_msg({ - 'type': RPCMessageType.STATUS_NOTIFICATION, - 'status': f'Remaining buy order for {trade.pair} cancelled due to timeout' - }) - return False + self.wallets.update() + self._notify_buy_cancel(trade, order_type=self.strategy.order_types['buy']) + return was_trade_fully_canceled - def handle_timedout_limit_sell(self, trade: Trade, order: Dict) -> str: + def handle_cancel_sell(self, trade: Trade, order: Dict, reason: str) -> str: """ - Sell timeout - cancel order and update trade + Sell cancel - cancel order and update trade :return: Reason for cancel """ - # if trade is not partially completed, just cancel the trade + # if trade is not partially completed, just cancel the order if order['remaining'] == order['amount'] or order.get('filled') == 0.0: if not self.exchange.check_order_canceled_empty(order): - reason = "cancelled due to timeout" - # if trade is not partially completed, just delete the trade - self.exchange.cancel_order(trade.open_order_id, trade.pair) + try: + # if trade is not partially completed, just delete the order + self.exchange.cancel_order(trade.open_order_id, trade.pair) + except InvalidOrderException: + logger.exception(f"Could not cancel sell order {trade.open_order_id}") + return 'error cancelling order' logger.info('Sell order %s for %s.', reason, trade) else: - reason = "cancelled on exchange" + reason = constants.CANCEL_REASON['CANCELLED_ON_EXCHANGE'] logger.info('Sell order %s for %s.', reason, trade) trade.close_rate = None @@ -947,11 +1047,17 @@ class FreqtradeBot: trade.close_date = None trade.is_open = True trade.open_order_id = None + else: + # TODO: figure out how to handle partially complete sell orders + reason = constants.CANCEL_REASON['PARTIALLY_FILLED'] - return reason - - # TODO: figure out how to handle partially complete sell orders - return 'partially filled - keeping order open' + self.wallets.update() + self._notify_sell_cancel( + trade, + order_type=self.strategy.order_types['sell'], + reason=reason + ) + return reason def _safe_sell_amount(self, pair: str, amount: float) -> float: """ @@ -972,7 +1078,7 @@ class FreqtradeBot: if wallet_amount >= amount: return amount elif wallet_amount > amount * 0.98: - logger.info(f"{pair} - Falling back to wallet-amount.") + logger.info(f"{pair} - Falling back to wallet-amount {wallet_amount} -> {amount}.") return wallet_amount else: raise DependencyException( @@ -999,7 +1105,7 @@ class FreqtradeBot: # First cancelling stoploss on exchange ... if self.strategy.order_types.get('stoploss_on_exchange') and trade.stoploss_order_id: try: - self.exchange.cancel_order(trade.stoploss_order_id, trade.pair) + self.exchange.cancel_stoploss_order(trade.stoploss_order_id, trade.pair) except InvalidOrderException: logger.exception(f"Could not cancel stoploss order {trade.stoploss_order_id}") @@ -1009,12 +1115,20 @@ class FreqtradeBot: order_type = self.strategy.order_types.get("emergencysell", "market") amount = self._safe_sell_amount(trade.pair, trade.amount) + time_in_force = self.strategy.order_time_in_force['sell'] + + if not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)( + pair=trade.pair, trade=trade, order_type=order_type, amount=amount, rate=limit, + time_in_force=time_in_force, + sell_reason=sell_reason.value): + logger.info(f"User requested abortion of selling {trade.pair}") + return False # Execute sell and update trade record order = self.exchange.sell(pair=str(trade.pair), ordertype=order_type, amount=amount, rate=limit, - time_in_force=self.strategy.order_time_in_force['sell'] + time_in_force=time_in_force ) trade.open_order_id = order['id'] @@ -1022,11 +1136,11 @@ class FreqtradeBot: trade.sell_reason = sell_reason.value # In case of market sell orders the order can be closed immediately if order.get('status', 'unknown') == 'closed': - trade.update(order) + self.update_trade_state(trade, order) Trade.session.flush() # Lock pair for one candle to prevent immediate rebuys - self.strategy.lock_pair(trade.pair, timeframe_to_next_date(self.config['ticker_interval'])) + self.strategy.lock_pair(trade.pair, timeframe_to_next_date(self.config['timeframe'])) self._notify_sell(trade, order_type) @@ -1045,6 +1159,7 @@ class FreqtradeBot: msg = { 'type': RPCMessageType.SELL_NOTIFICATION, + 'trade_id': trade.id, 'exchange': trade.exchange.capitalize(), 'pair': trade.pair, 'gain': gain, @@ -1074,6 +1189,11 @@ class FreqtradeBot: """ Sends rpc notification when a sell cancel occured. """ + if trade.sell_order_status == reason: + return + else: + trade.sell_order_status = reason + profit_rate = trade.close_rate if trade.close_rate else trade.close_rate_requested profit_trade = trade.calc_profit(rate=profit_rate) current_rate = self.get_sell_rate(trade.pair, False) @@ -1082,6 +1202,7 @@ class FreqtradeBot: msg = { 'type': RPCMessageType.SELL_CANCEL_NOTIFICATION, + 'trade_id': trade.id, 'exchange': trade.exchange.capitalize(), 'pair': trade.pair, 'gain': gain, @@ -1113,7 +1234,7 @@ class FreqtradeBot: # def update_trade_state(self, trade: Trade, action_order: dict = None, - order_amount: float = None) -> bool: + order_amount: float = None, sl_order: bool = False) -> bool: """ Checks trades with open orders and updates the amount if necessary Handles closing both buy and sell orders. @@ -1121,84 +1242,126 @@ class FreqtradeBot: """ # Get order details for actual price per unit if trade.open_order_id: - # Update trade with order values - logger.info('Found open order for %s', trade) - try: - order = action_order or self.exchange.get_order(trade.open_order_id, trade.pair) - except InvalidOrderException as exception: - logger.warning('Unable to fetch order %s: %s', trade.open_order_id, exception) - return False - # Try update amount (binance-fix) - try: - new_amount = self.get_real_amount(trade, order, order_amount) - if not isclose(order['amount'], new_amount, abs_tol=constants.MATH_CLOSE_PREC): - order['amount'] = new_amount - order.pop('filled', None) - # Fee was applied, so set to 0 - trade.fee_open = 0 - trade.recalc_open_trade_price() - except DependencyException as exception: - logger.warning("Could not update trade amount: %s", exception) + order_id = trade.open_order_id + elif trade.stoploss_order_id and sl_order: + order_id = trade.stoploss_order_id + else: + return False + # Update trade with order values + logger.info('Found open order for %s', trade) + try: + order = action_order or self.exchange.fetch_order(order_id, trade.pair) + except InvalidOrderException as exception: + logger.warning('Unable to fetch order %s: %s', order_id, exception) + return False + # Try update amount (binance-fix) + try: + new_amount = self.get_real_amount(trade, order, order_amount) + if not isclose(safe_value_fallback(order, 'filled', 'amount'), new_amount, + abs_tol=constants.MATH_CLOSE_PREC): + order['amount'] = new_amount + order.pop('filled', None) + trade.recalc_open_trade_price() + except DependencyException as exception: + logger.warning("Could not update trade amount: %s", exception) - if self.exchange.check_order_canceled_empty(order): - # Trade has been cancelled on exchange - # Handling of this will happen in check_handle_timeout. - return True - trade.update(order) - - # Updating wallets when order is closed - if not trade.is_open: - self.wallets.update() + if self.exchange.check_order_canceled_empty(order): + # Trade has been cancelled on exchange + # Handling of this will happen in check_handle_timeout. + return True + trade.update(order) + # Updating wallets when order is closed + if not trade.is_open: + self.wallets.update() return False + def apply_fee_conditional(self, trade: Trade, trade_base_currency: str, + amount: float, fee_abs: float) -> float: + """ + Applies the fee to amount (either from Order or from Trades). + Can eat into dust if more than the required asset is available. + """ + self.wallets.update() + 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 - " + f"Eating Fee {fee_abs} into dust.") + elif fee_abs != 0: + real_amount = self.exchange.amount_to_precision(trade.pair, amount - fee_abs) + logger.info(f"Applying fee on amount for {trade} " + f"(from {amount} to {real_amount}).") + return real_amount + return amount + def get_real_amount(self, trade: Trade, order: Dict, order_amount: float = None) -> float: """ - Get real amount for the trade + Detect and update trade fee. + Calls trade.update_fee() uppon correct detection. + Returns modified amount if the fee was taken from the destination currency. Necessary for exchanges which charge fees in base currency (e.g. binance) + :return: identical (or new) amount for the trade """ + # Init variables if order_amount is None: - order_amount = order['amount'] + order_amount = safe_value_fallback(order, 'filled', 'amount') # Only run for closed orders - if trade.fee_open == 0 or order['status'] == 'open': + if trade.fee_updated(order.get('side', '')) or order['status'] == 'open': return order_amount trade_base_currency = self.exchange.get_pair_base_currency(trade.pair) # use fee from order-dict if possible - if ('fee' in order and order['fee'] is not None and - (order['fee'].keys() >= {'currency', 'cost'})): - if (order['fee']['currency'] is not None and - order['fee']['cost'] is not None and - trade_base_currency == order['fee']['currency']): - new_amount = order_amount - order['fee']['cost'] - logger.info("Applying fee on amount for %s (from %s to %s) from Order", - trade, order['amount'], new_amount) - return new_amount + if self.exchange.order_has_fee(order): + fee_cost, fee_currency, fee_rate = self.exchange.extract_cost_curr_rate(order) + logger.info(f"Fee for Trade {trade} [{order.get('side')}]: " + f"{fee_cost:.8g} {fee_currency} - rate: {fee_rate}") - # Fallback to Trades + trade.update_fee(fee_cost, fee_currency, fee_rate, order.get('side', '')) + if trade_base_currency == fee_currency: + # Apply fee to amount + return self.apply_fee_conditional(trade, trade_base_currency, + amount=order_amount, fee_abs=fee_cost) + return order_amount + return self.fee_detection_from_trades(trade, order, order_amount) + + def fee_detection_from_trades(self, trade: Trade, order: Dict, order_amount: float) -> float: + """ + fee-detection fallback to Trades. Parses result of fetch_my_trades to get correct fee. + """ trades = self.exchange.get_trades_for_order(trade.open_order_id, trade.pair, trade.open_date) if len(trades) == 0: logger.info("Applying fee on amount for %s failed: myTrade-Dict empty found", trade) return order_amount + fee_currency = None amount = 0 - fee_abs = 0 + fee_abs = 0.0 + fee_cost = 0.0 + trade_base_currency = self.exchange.get_pair_base_currency(trade.pair) + fee_rate_array: List[float] = [] for exectrade in trades: amount += exectrade['amount'] - if ("fee" in exectrade and exectrade['fee'] is not None and - (exectrade['fee'].keys() >= {'currency', 'cost'})): + if self.exchange.order_has_fee(exectrade): + fee_cost_, fee_currency, fee_rate_ = self.exchange.extract_cost_curr_rate(exectrade) + fee_cost += fee_cost_ + if fee_rate_ is not None: + fee_rate_array.append(fee_rate_) # only applies if fee is in quote currency! - if (exectrade['fee']['currency'] is not None and - exectrade['fee']['cost'] is not None and - trade_base_currency == exectrade['fee']['currency']): - fee_abs += exectrade['fee']['cost'] + if trade_base_currency == fee_currency: + fee_abs += fee_cost_ + # Ensure at least one trade was found: + if fee_currency: + # fee_rate should use mean + fee_rate = sum(fee_rate_array) / float(len(fee_rate_array)) if fee_rate_array else None + trade.update_fee(fee_cost, fee_currency, fee_rate, order.get('side', '')) if not isclose(amount, order_amount, abs_tol=constants.MATH_CLOSE_PREC): logger.warning(f"Amount {amount} does not match amount {trade.amount}") raise DependencyException("Half bought? Amounts don't match") - real_amount = amount - fee_abs + if fee_abs != 0: - logger.info(f"Applying fee on amount for {trade} " - f"(from {order_amount} to {real_amount}) from Trades") - return real_amount + return self.apply_fee_conditional(trade, trade_base_currency, + amount=amount, fee_abs=fee_abs) + else: + return amount diff --git a/freqtrade/loggers.py b/freqtrade/loggers.py index 153ce8c80..aa08ee8a7 100644 --- a/freqtrade/loggers.py +++ b/freqtrade/loggers.py @@ -11,7 +11,7 @@ from freqtrade.exceptions import OperationalException logger = logging.getLogger(__name__) -def _set_loggers(verbosity: int = 0) -> None: +def _set_loggers(verbosity: int = 0, api_verbosity: str = 'info') -> None: """ Set the logging level for third party libraries :return: None @@ -28,6 +28,10 @@ def _set_loggers(verbosity: int = 0) -> None: ) logging.getLogger('telegram').setLevel(logging.INFO) + logging.getLogger('werkzeug').setLevel( + logging.ERROR if api_verbosity == 'error' else logging.INFO + ) + def setup_logging(config: Dict[str, Any]) -> None: """ @@ -77,5 +81,5 @@ def setup_logging(config: Dict[str, Any]) -> None: format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', handlers=log_handlers ) - _set_loggers(verbosity) + _set_loggers(verbosity, config.get('api_server', {}).get('verbosity', 'info')) logger.info('Verbosity set to %s', verbosity) diff --git a/freqtrade/misc.py b/freqtrade/misc.py index ea7bab843..623f6cb8f 100644 --- a/freqtrade/misc.py +++ b/freqtrade/misc.py @@ -134,7 +134,21 @@ def round_dict(d, n): return {k: (round(v, n) if isinstance(v, float) else v) for k, v in d.items()} -def safe_value_fallback(dict1: dict, dict2: dict, key1: str, key2: str, default_value=None): +def safe_value_fallback(obj: dict, key1: str, key2: str, default_value=None): + """ + Search a value in obj, return this if it's not None. + Then search key2 in obj - return that if it's not none - then use default_value. + Else falls back to None. + """ + if key1 in obj and obj[key1] is not None: + return obj[key1] + else: + if key2 in obj and obj[key2] is not None: + return obj[key2] + return default_value + + +def safe_value_fallback2(dict1: dict, dict2: dict, key1: str, key2: str, default_value=None): """ Search a value in dict1, return this if it's not None. Fall back to dict2 - return key2 from dict2 if it's not None. @@ -163,3 +177,15 @@ def render_template(templatefile: str, arguments: dict = {}) -> str: ) template = env.get_template(templatefile) return template.render(**arguments) + + +def render_template_with_fallback(templatefile: str, templatefallbackfile: str, + arguments: dict = {}) -> str: + """ + Use templatefile if possible, otherwise fall back to templatefallbackfile + """ + from jinja2.exceptions import TemplateNotFound + try: + return render_template(templatefile, arguments) + except TemplateNotFound: + return render_template(templatefallbackfile, arguments) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index f29f599a6..214c92e0e 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -18,8 +18,10 @@ from freqtrade.data.converter import trim_dataframe from freqtrade.data.dataprovider import DataProvider from freqtrade.exceptions import OperationalException from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds -from freqtrade.optimize.optimize_reports import (show_backtest_results, +from freqtrade.optimize.optimize_reports import (generate_backtest_stats, + show_backtest_results, store_backtest_result) +from freqtrade.pairlist.pairlistmanager import PairListManager from freqtrade.persistence import Trade from freqtrade.resolvers import ExchangeResolver, StrategyResolver from freqtrade.state import RunMode @@ -63,11 +65,6 @@ class Backtesting: self.strategylist: List[IStrategy] = [] self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config) - if config.get('fee'): - self.fee = config['fee'] - else: - self.fee = self.exchange.get_fee(symbol=self.config['exchange']['pair_whitelist'][0]) - if self.config.get('runmode') != RunMode.HYPEROPT: self.dataprovider = DataProvider(self.config, self.exchange) IStrategy.dp = self.dataprovider @@ -84,12 +81,31 @@ class Backtesting: self.strategylist.append(StrategyResolver.load_strategy(self.config)) validate_config_consistency(self.config) - if "ticker_interval" not in self.config: + if "timeframe" not in self.config: raise OperationalException("Timeframe (ticker interval) needs to be set in either " - "configuration or as cli argument `--ticker-interval 5m`") - self.timeframe = str(self.config.get('ticker_interval')) + "configuration or as cli argument `--timeframe 5m`") + self.timeframe = str(self.config.get('timeframe')) self.timeframe_min = timeframe_to_minutes(self.timeframe) + self.pairlists = PairListManager(self.exchange, self.config) + if 'VolumePairList' in self.pairlists.name_list: + raise OperationalException("VolumePairList 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.pairlists.refresh_pairlist() + + if len(self.pairlists.whitelist) == 0: + raise OperationalException("No pair in whitelist.") + + if config.get('fee', None) is not None: + self.fee = config['fee'] + else: + self.fee = self.exchange.get_fee(symbol=self.pairlists.whitelist[0]) + # Get maximum required startup period self.required_startup = max([strat.startup_candle_count for strat in self.strategylist]) # Load one (first) strategy @@ -111,7 +127,7 @@ class Backtesting: data = history.load_data( datadir=self.config['datadir'], - pairs=self.config['exchange']['pair_whitelist'], + pairs=self.pairlists.whitelist, timeframe=self.timeframe, timerange=timerange, startup_candles=self.required_startup, @@ -401,4 +417,5 @@ class Backtesting: if self.config.get('export', False): store_backtest_result(self.config['exportfilename'], all_results) # Show backtest results - show_backtest_results(self.config, data, all_results) + stats = generate_backtest_stats(self.config, data, all_results) + show_backtest_results(self.config, stats) diff --git a/freqtrade/optimize/default_hyperopt_loss.py b/freqtrade/optimize/default_hyperopt_loss.py index 4ab9fbe44..9e780d0ea 100644 --- a/freqtrade/optimize/default_hyperopt_loss.py +++ b/freqtrade/optimize/default_hyperopt_loss.py @@ -42,8 +42,8 @@ class DefaultHyperOptLoss(IHyperOptLoss): * 0.25: Avoiding trade loss * 1.0 to total profit, compared to the expected value (`EXPECTED_MAX_PROFIT`) defined above """ - total_profit = results.profit_percent.sum() - trade_duration = results.trade_duration.mean() + total_profit = results['profit_percent'].sum() + trade_duration = results['trade_duration'].mean() trade_loss = 1 - 0.25 * exp(-(trade_count - TARGET_TRADES) ** 2 / 10 ** 5.8) profit_loss = max(0, 1 - total_profit / EXPECTED_MAX_PROFIT) diff --git a/freqtrade/optimize/hyperopt.py b/freqtrade/optimize/hyperopt.py index f2221d6a7..522b217f7 100644 --- a/freqtrade/optimize/hyperopt.py +++ b/freqtrade/optimize/hyperopt.py @@ -12,7 +12,7 @@ from math import ceil from collections import OrderedDict from operator import itemgetter from pathlib import Path -from pprint import pprint +from pprint import pformat from typing import Any, Dict, List, Optional import rapidjson @@ -49,9 +49,9 @@ logger = logging.getLogger(__name__) INITIAL_POINTS = 30 -# Keep no more than 2*SKOPT_MODELS_MAX_NUM models -# in the skopt models list -SKOPT_MODELS_MAX_NUM = 10 +# Keep no more than SKOPT_MODEL_QUEUE_SIZE models +# in the skopt model queue, to optimize memory consumption +SKOPT_MODEL_QUEUE_SIZE = 10 MAX_LOSS = 100000 # just a big enough number to be bad result in loss optimization @@ -75,8 +75,8 @@ class Hyperopt: self.custom_hyperoptloss = HyperOptLossResolver.load_hyperoptloss(self.config) self.calculate_loss = self.custom_hyperoptloss.hyperopt_loss_function - self.trials_file = (self.config['user_data_dir'] / - 'hyperopt_results' / 'hyperopt_results.pickle') + self.results_file = (self.config['user_data_dir'] / + 'hyperopt_results' / 'hyperopt_results.pickle') self.data_pickle_file = (self.config['user_data_dir'] / 'hyperopt_results' / 'hyperopt_tickerdata.pkl') self.total_epochs = config.get('epochs', 0) @@ -88,10 +88,10 @@ class Hyperopt: else: logger.info("Continuing on previous hyperopt results.") - self.num_trials_saved = 0 + self.num_epochs_saved = 0 # Previous evaluations - self.trials: List = [] + self.epochs: List = [] # Populate functions here (hasattr is slow so should not be run during "regular" operations) if hasattr(self.custom_hyperopt, 'populate_indicators'): @@ -132,7 +132,7 @@ class Hyperopt: """ Remove hyperopt pickle files to restart hyperopt. """ - for f in [self.data_pickle_file, self.trials_file]: + for f in [self.data_pickle_file, self.results_file]: p = Path(f) if p.is_file(): logger.info(f"Removing `{p}`.") @@ -151,27 +151,26 @@ class Hyperopt: # and the values are taken from the list of parameters. return {d.name: v for d, v in zip(dimensions, raw_params)} - def save_trials(self, final: bool = False) -> None: + def _save_results(self) -> None: """ - Save hyperopt trials to file + Save hyperopt results to file """ - num_trials = len(self.trials) - if num_trials > self.num_trials_saved: - logger.debug(f"Saving {num_trials} {plural(num_trials, 'epoch')}.") - dump(self.trials, self.trials_file) - self.num_trials_saved = num_trials - if final: - logger.info(f"{num_trials} {plural(num_trials, 'epoch')} " - f"saved to '{self.trials_file}'.") + num_epochs = len(self.epochs) + if num_epochs > self.num_epochs_saved: + logger.debug(f"Saving {num_epochs} {plural(num_epochs, 'epoch')}.") + dump(self.epochs, self.results_file) + self.num_epochs_saved = num_epochs + logger.debug(f"{self.num_epochs_saved} {plural(self.num_epochs_saved, 'epoch')} " + f"saved to '{self.results_file}'.") @staticmethod - def _read_trials(trials_file: Path) -> List: + def _read_results(results_file: Path) -> List: """ - Read hyperopt trials file + Read hyperopt results from file """ - logger.info("Reading Trials from '%s'", trials_file) - trials = load(trials_file) - return trials + logger.info("Reading epochs from '%s'", results_file) + data = load(results_file) + return data def _get_params_details(self, params: Dict) -> Dict: """ @@ -231,6 +230,9 @@ class Hyperopt: if space in ['buy', 'sell']: result_dict.setdefault('params', {}).update(space_params) elif space == 'roi': + # TODO: get rid of OrderedDict when support for python 3.6 will be + # dropped (dicts keep the order as the language feature) + # Convert keys in min_roi dict to strings because # rapidjson cannot dump dicts with integer keys... # OrderedDict is used to keep the numeric order of the items @@ -245,11 +247,24 @@ class Hyperopt: def _params_pretty_print(params, space: str, header: str) -> None: if space in params: space_params = Hyperopt._space_params(params, space, 5) + params_result = f"\n# {header}\n" if space == 'stoploss': - print(header, space_params.get('stoploss')) + params_result += f"stoploss = {space_params.get('stoploss')}" + elif space == 'roi': + # TODO: get rid of OrderedDict when support for python 3.6 will be + # dropped (dicts keep the order as the language feature) + minimal_roi_result = rapidjson.dumps( + OrderedDict( + (str(k), v) for k, v in space_params.items() + ), + default=str, indent=4, number_mode=rapidjson.NM_NATIVE) + params_result += f"minimal_roi = {minimal_roi_result}" else: - print(header) - pprint(space_params, indent=4) + params_result += f"{space}_params = {pformat(space_params, indent=4)}" + params_result = params_result.replace("}", "\n}").replace("{", "{\n ") + + params_result = params_result.replace("\n", "\n ") + print(params_result) @staticmethod def _space_params(params, space: str, r: int = None) -> Dict: @@ -304,8 +319,9 @@ class Hyperopt: trials.columns = ['Best', 'Epoch', 'Trades', 'Avg profit', 'Total profit', 'Profit', 'Avg duration', 'Objective', 'is_initial_point', 'is_best'] trials['is_profit'] = False - trials.loc[trials['is_initial_point'], 'Best'] = '*' + trials.loc[trials['is_initial_point'], 'Best'] = '* ' trials.loc[trials['is_best'], 'Best'] = 'Best' + trials.loc[trials['is_initial_point'] & trials['is_best'], 'Best'] = '* Best' trials.loc[trials['Total profit'] > 0, 'is_profit'] = True trials['Trades'] = trials['Trades'].astype(str) @@ -375,27 +391,35 @@ class Hyperopt: # Verification for overwrite if path.isfile(csv_file): - logger.error("CSV-File already exists!") + logger.error(f"CSV file already exists: {csv_file}") return try: io.open(csv_file, 'w+').close() except IOError: - logger.error("Filed to create CSV-File!") + logger.error(f"Failed to create CSV file: {csv_file}") return trials = json_normalize(results, max_level=1) trials['Best'] = '' trials['Stake currency'] = config['stake_currency'] - trials = trials[['Best', 'current_epoch', 'results_metrics.trade_count', - 'results_metrics.avg_profit', 'results_metrics.total_profit', - 'Stake currency', 'results_metrics.profit', 'results_metrics.duration', - 'loss', 'is_initial_point', 'is_best']] - trials.columns = ['Best', 'Epoch', 'Trades', 'Avg profit', 'Total profit', 'Stake currency', - 'Profit', 'Avg duration', 'Objective', 'is_initial_point', 'is_best'] + + base_metrics = ['Best', 'current_epoch', 'results_metrics.trade_count', + 'results_metrics.avg_profit', 'results_metrics.total_profit', + 'Stake currency', 'results_metrics.profit', 'results_metrics.duration', + 'loss', 'is_initial_point', 'is_best'] + param_metrics = [("params_dict."+param) for param in results[0]['params_dict'].keys()] + trials = trials[base_metrics + param_metrics] + + base_columns = ['Best', 'Epoch', 'Trades', 'Avg profit', 'Total profit', 'Stake currency', + 'Profit', 'Avg duration', 'Objective', 'is_initial_point', 'is_best'] + param_columns = list(results[0]['params_dict'].keys()) + trials.columns = base_columns + param_columns + trials['is_profit'] = False trials.loc[trials['is_initial_point'], 'Best'] = '*' trials.loc[trials['is_best'], 'Best'] = 'Best' + trials.loc[trials['is_initial_point'] & trials['is_best'], 'Best'] = '* Best' trials.loc[trials['Total profit'] > 0, 'is_profit'] = True trials['Epoch'] = trials['Epoch'].astype(str) trials['Trades'] = trials['Trades'].astype(str) @@ -418,7 +442,7 @@ class Hyperopt: trials = trials.drop(columns=['is_initial_point', 'is_best', 'is_profit']) trials.to_csv(csv_file, index=False, header=True, mode='w', encoding='UTF-8') - print("CSV-File created!") + logger.info(f"CSV file created: {csv_file}") def has_space(self, space: str) -> bool: """ @@ -570,43 +594,28 @@ class Hyperopt: n_initial_points=INITIAL_POINTS, acq_optimizer_kwargs={'n_jobs': cpu_count}, random_state=self.random_state, + model_queue_size=SKOPT_MODEL_QUEUE_SIZE, ) - def fix_optimizer_models_list(self) -> None: - """ - WORKAROUND: Since skopt is not actively supported, this resolves problems with skopt - memory usage, see also: https://github.com/scikit-optimize/scikit-optimize/pull/746 - - This may cease working when skopt updates if implementation of this intrinsic - part changes. - """ - n = len(self.opt.models) - SKOPT_MODELS_MAX_NUM - # Keep no more than 2*SKOPT_MODELS_MAX_NUM models in the skopt models list, - # remove the old ones. These are actually of no use, the current model - # from the estimator is the only one used in the skopt optimizer. - # Freqtrade code also does not inspect details of the models. - if n >= SKOPT_MODELS_MAX_NUM: - logger.debug(f"Fixing skopt models list, removing {n} old items...") - del self.opt.models[0:n] - def run_optimizer_parallel(self, parallel, asked, i) -> List: return parallel(delayed( wrap_non_picklable_objects(self.generate_optimizer))(v, i) for v in asked) @staticmethod - def load_previous_results(trials_file: Path) -> List: + def load_previous_results(results_file: Path) -> List: """ Load data for epochs from the file if we have one """ - trials: List = [] - if trials_file.is_file() and trials_file.stat().st_size > 0: - trials = Hyperopt._read_trials(trials_file) - if trials[0].get('is_best') is None: + epochs: List = [] + if results_file.is_file() and results_file.stat().st_size > 0: + epochs = Hyperopt._read_results(results_file) + # Detection of some old format, without 'is_best' field saved + if epochs[0].get('is_best') is None: raise OperationalException( "The file with Hyperopt results is incompatible with this version " "of Freqtrade and cannot be loaded.") - logger.info(f"Loaded {len(trials)} previous evaluations from disk.") - return trials + logger.info(f"Loaded {len(epochs)} previous evaluations from disk.") + return epochs def _set_random_state(self, random_state: Optional[int]) -> int: return random_state or random.randint(1, 2**16 - 1) @@ -632,8 +641,9 @@ class Hyperopt: # We don't need exchange instance anymore while running hyperopt self.backtesting.exchange = None # type: ignore + self.backtesting.pairlists = None # type: ignore - self.trials = self.load_previous_results(self.trials_file) + self.epochs = self.load_previous_results(self.results_file) cpus = cpu_count() logger.info(f"Found {cpus} CPU cores. Let's make them scream!") @@ -669,7 +679,7 @@ class Hyperopt: ' [', progressbar.ETA(), ', ', progressbar.Timer(), ']', ] with progressbar.ProgressBar( - maxval=self.total_epochs, redirect_stdout=False, redirect_stderr=False, + max_value=self.total_epochs, redirect_stdout=False, redirect_stderr=False, widgets=widgets ) as pbar: EVALS = ceil(self.total_epochs / jobs) @@ -682,7 +692,6 @@ class Hyperopt: asked = self.opt.ask(n_points=current_jobs) f_val = self.run_optimizer_parallel(parallel, asked, i) self.opt.tell(asked, [v['loss'] for v in f_val]) - self.fix_optimizer_models_list() # Calculate progressbar outputs for j, val in enumerate(f_val): @@ -703,23 +712,25 @@ class Hyperopt: if is_best: self.current_best_loss = val['loss'] - self.trials.append(val) + self.epochs.append(val) # Save results after each best epoch and every 100 epochs if is_best or current % 100 == 0: - self.save_trials() + self._save_results() pbar.update(current) except KeyboardInterrupt: print('User interrupted..') - self.save_trials(final=True) + self._save_results() + logger.info(f"{self.num_epochs_saved} {plural(self.num_epochs_saved, 'epoch')} " + f"saved to '{self.results_file}'.") - if self.trials: - sorted_trials = sorted(self.trials, key=itemgetter('loss')) - results = sorted_trials[0] - self.print_epoch_details(results, self.total_epochs, self.print_json) + if self.epochs: + sorted_epochs = sorted(self.epochs, key=itemgetter('loss')) + best_epoch = sorted_epochs[0] + self.print_epoch_details(best_epoch, self.total_epochs, self.print_json) else: # This is printed when Ctrl+C is pressed quickly, before first epochs have # a chance to be evaluated. diff --git a/freqtrade/optimize/hyperopt_interface.py b/freqtrade/optimize/hyperopt_interface.py index b3cedef2c..65069b984 100644 --- a/freqtrade/optimize/hyperopt_interface.py +++ b/freqtrade/optimize/hyperopt_interface.py @@ -31,13 +31,15 @@ class IHyperOpt(ABC): Class attributes you can use: ticker_interval -> int: value of the ticker interval to use for the strategy """ - ticker_interval: str + ticker_interval: str # DEPRECATED + timeframe: str def __init__(self, config: dict) -> None: self.config = config # Assign ticker_interval to be used in hyperopt - IHyperOpt.ticker_interval = str(config['ticker_interval']) + IHyperOpt.ticker_interval = str(config['timeframe']) # DEPRECATED + IHyperOpt.timeframe = str(config['timeframe']) @staticmethod def buy_strategy_generator(params: Dict[str, Any]) -> Callable: @@ -218,9 +220,10 @@ class IHyperOpt(ABC): # Why do I still need such shamanic mantras in modern python? def __getstate__(self): state = self.__dict__.copy() - state['ticker_interval'] = self.ticker_interval + state['timeframe'] = self.timeframe return state def __setstate__(self, state): self.__dict__.update(state) - IHyperOpt.ticker_interval = state['ticker_interval'] + IHyperOpt.ticker_interval = state['timeframe'] + IHyperOpt.timeframe = state['timeframe'] diff --git a/freqtrade/optimize/hyperopt_loss_interface.py b/freqtrade/optimize/hyperopt_loss_interface.py index 879a9f0e9..48407a8a8 100644 --- a/freqtrade/optimize/hyperopt_loss_interface.py +++ b/freqtrade/optimize/hyperopt_loss_interface.py @@ -14,7 +14,7 @@ class IHyperOptLoss(ABC): Interface for freqtrade hyperopt Loss functions. Defines the custom loss function (`hyperopt_loss_function()` which is evaluated every epoch.) """ - ticker_interval: str + timeframe: str @staticmethod @abstractmethod diff --git a/freqtrade/optimize/hyperopt_loss_onlyprofit.py b/freqtrade/optimize/hyperopt_loss_onlyprofit.py index a1c50e727..43176dbad 100644 --- a/freqtrade/optimize/hyperopt_loss_onlyprofit.py +++ b/freqtrade/optimize/hyperopt_loss_onlyprofit.py @@ -34,5 +34,5 @@ class OnlyProfitHyperOptLoss(IHyperOptLoss): """ Objective function, returns smaller number for better results. """ - total_profit = results.profit_percent.sum() + total_profit = results['profit_percent'].sum() return 1 - total_profit / EXPECTED_MAX_PROFIT diff --git a/freqtrade/optimize/optimize_reports.py b/freqtrade/optimize/optimize_reports.py index 646afb5df..d89860a73 100644 --- a/freqtrade/optimize/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports.py @@ -1,7 +1,7 @@ import logging from datetime import timedelta from pathlib import Path -from typing import Dict +from typing import Any, Dict, List from pandas import DataFrame from tabulate import tabulate @@ -18,10 +18,7 @@ def store_backtest_result(recordfilename: Path, all_results: Dict[str, DataFrame :param all_results: Dict of Dataframes, one results dataframe per strategy """ for strategy, results in all_results.items(): - records = [(t.pair, t.profit_percent, t.open_time.timestamp(), - t.close_time.timestamp(), t.open_index - 1, t.trade_duration, - t.open_rate, t.close_rate, t.open_at_end, t.sell_reason.value) - for index, t in results.iterrows()] + records = backtest_result_to_list(results) if records: filename = recordfilename @@ -34,153 +31,141 @@ def store_backtest_result(recordfilename: Path, all_results: Dict[str, DataFrame file_dump_json(filename, records) -def generate_text_table(data: Dict[str, Dict], stake_currency: str, max_open_trades: int, - results: DataFrame, skip_nan: bool = False) -> str: +def backtest_result_to_list(results: DataFrame) -> List[List]: """ - Generates and returns a text table for the given backtest data and the results dataframe + Converts a list of Backtest-results to list + :param results: Dataframe containing results for one strategy + :return: List of Lists containing the trades + """ + return [[t.pair, t.profit_percent, t.open_time.timestamp(), + t.close_time.timestamp(), t.open_index - 1, t.trade_duration, + t.open_rate, t.close_rate, t.open_at_end, t.sell_reason.value] + for index, t in results.iterrows()] + + +def _get_line_floatfmt() -> List[str]: + """ + Generate floatformat (goes in line with _generate_result_line()) + """ + return ['s', 'd', '.2f', '.2f', '.8f', '.2f', 'd', 'd', 'd', 'd'] + + +def _get_line_header(first_column: str, stake_currency: str) -> List[str]: + """ + Generate header lines (goes in line with _generate_result_line()) + """ + return [first_column, 'Buys', 'Avg Profit %', 'Cum Profit %', + f'Tot Profit {stake_currency}', 'Tot Profit %', 'Avg Duration', + 'Wins', 'Draws', 'Losses'] + + +def _generate_result_line(result: DataFrame, max_open_trades: int, first_column: str) -> Dict: + """ + Generate one result dict, with "first_column" as key. + """ + return { + 'key': first_column, + 'trades': len(result), + 'profit_mean': result['profit_percent'].mean(), + 'profit_mean_pct': result['profit_percent'].mean() * 100.0, + 'profit_sum': result['profit_percent'].sum(), + 'profit_sum_pct': result['profit_percent'].sum() * 100.0, + 'profit_total_abs': result['profit_abs'].sum(), + 'profit_total_pct': result['profit_percent'].sum() * 100.0 / max_open_trades, + '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(data: Dict[str, Dict], stake_currency: str, max_open_trades: int, + results: DataFrame, skip_nan: bool = False) -> List[Dict]: + """ + Generates and returns a list for the given backtest data and the results dataframe :param data: Dict of containing data that was used during backtesting. :param stake_currency: stake-currency - used to correctly name headers :param max_open_trades: Maximum allowed open trades :param results: Dataframe containing the backtest results :param skip_nan: Print "left open" open trades - :return: pretty printed table with tabulate as string + :return: List of Dicts containing the metrics per pair """ - floatfmt = ('s', 'd', '.2f', '.2f', '.8f', '.2f', 'd', '.1f', '.1f') tabular_data = [] - headers = [ - 'Pair', - 'Buys', - 'Avg Profit %', - 'Cum Profit %', - f'Tot Profit {stake_currency}', - 'Tot Profit %', - 'Avg Duration', - 'Wins', - 'Draws', - 'Losses' - ] + for pair in data: - result = results[results.pair == pair] - if skip_nan and result.profit_abs.isnull().all(): + result = results[results['pair'] == pair] + if skip_nan and result['profit_abs'].isnull().all(): continue - tabular_data.append([ - pair, - len(result.index), - result.profit_percent.mean() * 100.0, - result.profit_percent.sum() * 100.0, - result.profit_abs.sum(), - result.profit_percent.sum() * 100.0 / max_open_trades, - str(timedelta( - minutes=round(result.trade_duration.mean()))) if not result.empty else '0:00', - len(result[result.profit_abs > 0]), - len(result[result.profit_abs == 0]), - len(result[result.profit_abs < 0]) - ]) + tabular_data.append(_generate_result_line(result, max_open_trades, pair)) # Append Total - tabular_data.append([ - 'TOTAL', - len(results.index), - results.profit_percent.mean() * 100.0, - results.profit_percent.sum() * 100.0, - results.profit_abs.sum(), - results.profit_percent.sum() * 100.0 / max_open_trades, - str(timedelta( - minutes=round(results.trade_duration.mean()))) if not results.empty else '0:00', - len(results[results.profit_abs > 0]), - len(results[results.profit_abs == 0]), - len(results[results.profit_abs < 0]) - ]) - # 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") # type: ignore + tabular_data.append(_generate_result_line(results, max_open_trades, 'TOTAL')) + return tabular_data -def generate_text_table_sell_reason(stake_currency: str, max_open_trades: int, - results: DataFrame) -> str: +def generate_sell_reason_stats(max_open_trades: int, results: DataFrame) -> List[Dict]: """ Generate small table outlining Backtest results - :param stake_currency: Stakecurrency used :param max_open_trades: Max_open_trades parameter - :param results: Dataframe containing the backtest results - :return: pretty printed table with tabulate as string + :param results: Dataframe containing the backtest result for one strategy + :return: List of Dicts containing the metrics per Sell reason """ tabular_data = [] - headers = [ - "Sell Reason", - "Sells", - "Wins", - "Draws", - "Losses", - "Avg Profit %", - "Cum Profit %", - f"Tot Profit {stake_currency}", - "Tot Profit %", - ] + for reason, count in results['sell_reason'].value_counts().iteritems(): result = results.loc[results['sell_reason'] == reason] - wins = len(result[result['profit_abs'] > 0]) - draws = len(result[result['profit_abs'] == 0]) - loss = len(result[result['profit_abs'] < 0]) - profit_mean = round(result['profit_percent'].mean() * 100.0, 2) - profit_sum = round(result["profit_percent"].sum() * 100.0, 2) - profit_tot = result['profit_abs'].sum() + + profit_mean = result['profit_percent'].mean() + profit_sum = result["profit_percent"].sum() profit_percent_tot = round(result['profit_percent'].sum() * 100.0 / max_open_trades, 2) + tabular_data.append( - [ - reason.value, - count, - wins, - draws, - loss, - profit_mean, - profit_sum, - profit_tot, - profit_percent_tot, - ] + { + 'sell_reason': reason.value, + '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_pct_total': profit_percent_tot, + } ) - return tabulate(tabular_data, headers=headers, tablefmt="orgtbl", stralign="right") + return tabular_data -def generate_text_table_strategy(stake_currency: str, max_open_trades: str, - all_results: Dict) -> str: +def generate_strategy_metrics(stake_currency: str, max_open_trades: int, + all_results: Dict) -> List[Dict]: """ - Generate summary table per strategy + Generate summary per strategy :param stake_currency: stake-currency - used to correctly name headers :param max_open_trades: Maximum allowed open trades used for backtest :param all_results: Dict of containing results for all strategies - :return: pretty printed table with tabulate as string + :return: List of Dicts containing the metrics per Strategy """ - floatfmt = ('s', 'd', '.2f', '.2f', '.8f', '.2f', 'd', '.1f', '.1f') tabular_data = [] - headers = ['Strategy', 'Buys', 'Avg Profit %', 'Cum Profit %', - f'Tot Profit {stake_currency}', 'Tot Profit %', 'Avg Duration', - 'Wins', 'Draws', 'Losses'] for strategy, results in all_results.items(): - tabular_data.append([ - strategy, - len(results.index), - results.profit_percent.mean() * 100.0, - results.profit_percent.sum() * 100.0, - results.profit_abs.sum(), - results.profit_percent.sum() * 100.0 / max_open_trades, - str(timedelta( - minutes=round(results.trade_duration.mean()))) if not results.empty else '0:00', - len(results[results.profit_abs > 0]), - len(results[results.profit_abs == 0]), - len(results[results.profit_abs < 0]) - ]) - # 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") # type: ignore + tabular_data.append(_generate_result_line(results, max_open_trades, strategy)) + return tabular_data def generate_edge_table(results: dict) -> str: - floatfmt = ('s', '.10g', '.2f', '.2f', '.2f', '.2f', 'd', '.d') + 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', @@ -204,40 +189,145 @@ def generate_edge_table(results: dict) -> str: floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") # type: ignore -def show_backtest_results(config: Dict, btdata: Dict[str, DataFrame], - all_results: Dict[str, DataFrame]): +def generate_backtest_stats(config: Dict, btdata: Dict[str, DataFrame], + all_results: Dict[str, DataFrame]) -> Dict[str, Any]: + """ + :param config: Configuration object used for backtest + :param btdata: Backtest data + :param all_results: backtest result - dictionary with { Strategy: results}. + :return: + Dictionary containing results per strategy and a stratgy summary. + """ + stake_currency = config['stake_currency'] + max_open_trades = config['max_open_trades'] + result: Dict[str, Any] = {'strategy': {}} for strategy, results in all_results.items(): + pair_results = generate_pair_metrics(btdata, stake_currency=stake_currency, + max_open_trades=max_open_trades, + results=results, skip_nan=False) + sell_reason_stats = generate_sell_reason_stats(max_open_trades=max_open_trades, + results=results) + left_open_results = generate_pair_metrics(btdata, stake_currency=stake_currency, + max_open_trades=max_open_trades, + results=results.loc[results['open_at_end']], + skip_nan=True) + strat_stats = { + 'trades': backtest_result_to_list(results), + 'results_per_pair': pair_results, + 'sell_reason_summary': sell_reason_stats, + 'left_open_trades': left_open_results, + } + result['strategy'][strategy] = strat_stats + + strategy_results = generate_strategy_metrics(stake_currency=stake_currency, + max_open_trades=max_open_trades, + all_results=all_results) + + 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() + output = [[ + t['key'], t['trades'], t['profit_mean_pct'], t['profit_sum_pct'], t['profit_total_abs'], + t['profit_total_pct'], t['duration_avg'], 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_sell_reason(sell_reason_stats: List[Dict[str, Any]], stake_currency: str) -> str: + """ + Generate small table outlining Backtest results + :param sell_reason_stats: Sell reason metrics + :param stake_currency: Stakecurrency used + :return: pretty printed table with tabulate as string + """ + headers = [ + 'Sell Reason', + 'Sells', + 'Wins', + 'Draws', + 'Losses', + 'Avg Profit %', + 'Cum Profit %', + f'Tot Profit {stake_currency}', + 'Tot Profit %', + ] + + output = [[ + t['sell_reason'], t['trades'], t['wins'], t['draws'], t['losses'], + t['profit_mean_pct'], t['profit_sum_pct'], t['profit_total_abs'], t['profit_pct_total'], + ] for t in sell_reason_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 stake_currency: stake-currency - used to correctly name headers + :param max_open_trades: Maximum allowed open trades used for backtest + :param all_results: Dict of containing results for all strategies + :return: pretty printed table with tabulate as string + """ + floatfmt = _get_line_floatfmt() + headers = _get_line_header('Strategy', 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'], t['wins'], t['draws'], t['losses'] + ] for t in strategy_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 show_backtest_results(config: Dict, backtest_stats: Dict): + stake_currency = config['stake_currency'] + + for strategy, results in backtest_stats['strategy'].items(): + + # Print results print(f"Result for strategy {strategy}") - table = generate_text_table(btdata, stake_currency=config['stake_currency'], - max_open_trades=config['max_open_trades'], - results=results) + 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 = generate_text_table_sell_reason(stake_currency=config['stake_currency'], - max_open_trades=config['max_open_trades'], - results=results) + table = text_table_sell_reason(sell_reason_stats=results['sell_reason_summary'], + stake_currency=stake_currency) if isinstance(table, str): print(' SELL REASON STATS '.center(len(table.splitlines()[0]), '=')) print(table) - table = generate_text_table(btdata, - stake_currency=config['stake_currency'], - max_open_trades=config['max_open_trades'], - results=results.loc[results.open_at_end], skip_nan=True) + table = text_table_bt_results(results['left_open_trades'], stake_currency=stake_currency) if isinstance(table, str): print(' LEFT OPEN TRADES REPORT '.center(len(table.splitlines()[0]), '=')) print(table) if isinstance(table, str): print('=' * len(table.splitlines()[0])) print() - if len(all_results) > 1: + + if len(backtest_stats['strategy']) > 1: # Print Strategy summary table - table = generate_text_table_strategy(config['stake_currency'], - config['max_open_trades'], - all_results=all_results) + + table = text_table_strategy(backtest_stats['strategy_comparison'], stake_currency) print(' STRATEGY SUMMARY '.center(len(table.splitlines()[0]), '=')) print(table) print('=' * len(table.splitlines()[0])) diff --git a/freqtrade/pairlist/AgeFilter.py b/freqtrade/pairlist/AgeFilter.py new file mode 100644 index 000000000..7b6b126c3 --- /dev/null +++ b/freqtrade/pairlist/AgeFilter.py @@ -0,0 +1,84 @@ +""" +Minimum age (days listed) pair list filter +""" +import logging +import arrow +from typing import Any, Dict + +from freqtrade.exceptions import OperationalException +from freqtrade.misc import plural +from freqtrade.pairlist.IPairList import IPairList + + +logger = logging.getLogger(__name__) + + +class AgeFilter(IPairList): + + # Checked symbols cache (dictionary of ticker symbol => timestamp) + _symbolsChecked: Dict[str, int] = {} + + def __init__(self, exchange, pairlistmanager, + config: Dict[str, Any], pairlistconfig: Dict[str, Any], + pairlist_pos: int) -> None: + super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) + + self._min_days_listed = pairlistconfig.get('min_days_listed', 10) + + if self._min_days_listed < 1: + raise OperationalException("AgeFilter requires min_days_listed must be >= 1") + if self._min_days_listed > exchange.ohlcv_candle_limit: + raise OperationalException("AgeFilter requires min_days_listed must not exceed " + "exchange max request size " + f"({exchange.ohlcv_candle_limit})") + self._enabled = self._min_days_listed >= 1 + + @property + def needstickers(self) -> bool: + """ + Boolean property defining if tickers are necessary. + If no Pairlist requires tickers, an empty List is passed + as tickers argument to filter_pairlist + """ + return True + + def short_desc(self) -> str: + """ + Short whitelist method description - used for startup-messages + """ + return (f"{self.name} - Filtering pairs with age less than " + f"{self._min_days_listed} {plural(self._min_days_listed, 'day')}.") + + def _validate_pair(self, ticker: dict) -> bool: + """ + Validate age for the ticker + :param ticker: ticker dict as returned from ccxt.load_markets() + :return: True if the pair can stay, False if it should be removed + """ + + # Check symbol in cache + if ticker['symbol'] in self._symbolsChecked: + return True + + since_ms = int(arrow.utcnow() + .floor('day') + .shift(days=-self._min_days_listed) + .float_timestamp) * 1000 + + daily_candles = self._exchange.get_historic_ohlcv(pair=ticker['symbol'], + timeframe='1d', + since_ms=since_ms) + + if daily_candles is not None: + if len(daily_candles) > self._min_days_listed: + # 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[ticker['symbol']] = int(arrow.utcnow().float_timestamp) * 1000 + return True + else: + self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, " + f"because age {len(daily_candles)} is less than " + f"{self._min_days_listed} " + f"{plural(self._min_days_listed, 'day')}") + return False + return False diff --git a/freqtrade/pairlist/IPairList.py b/freqtrade/pairlist/IPairList.py index e089e546c..1cca00eba 100644 --- a/freqtrade/pairlist/IPairList.py +++ b/freqtrade/pairlist/IPairList.py @@ -1,9 +1,6 @@ """ -Static List provider - -Provides lists as configured in config.json - - """ +PairList Handler base class +""" import logging from abc import ABC, abstractmethod, abstractproperty from copy import deepcopy @@ -11,8 +8,10 @@ from typing import Any, Dict, List from cachetools import TTLCache, cached +from freqtrade.exceptions import OperationalException from freqtrade.exchange import market_is_active + logger = logging.getLogger(__name__) @@ -23,11 +22,13 @@ class IPairList(ABC): pairlist_pos: int) -> None: """ :param exchange: Exchange instance - :param pairlistmanager: Instanciating Pairlist manager + :param pairlistmanager: Instantiated Pairlist manager :param config: Global bot configuration - :param pairlistconfig: Configuration for this pairlist - can be empty. - :param pairlist_pos: Position of the filter in the pairlist-filter-list + :param pairlistconfig: Configuration for this Pairlist Handler - can be empty. + :param pairlist_pos: Position of the Pairlist Handler in the chain """ + self._enabled = True + self._exchange = exchange self._pairlistmanager = pairlistmanager self._config = config @@ -67,7 +68,7 @@ class IPairList(ABC): def needstickers(self) -> bool: """ Boolean property defining if tickers are necessary. - If no Pairlist requries tickers, an empty List is passed + If no Pairlist requires tickers, an empty List is passed as tickers argument to filter_pairlist """ @@ -78,49 +79,68 @@ class IPairList(ABC): -> Please overwrite in subclasses """ - @abstractmethod + def _validate_pair(self, ticker) -> bool: + """ + Check one pair against Pairlist Handler's specific conditions. + + Either implement it in the Pairlist Handler or override the generic + filter_pairlist() method. + + :param ticker: ticker dict as returned from ccxt.load_markets() + :return: True if the pair can stay, false if it should be removed + """ + raise NotImplementedError() + + def gen_pairlist(self, cached_pairlist: List[str], tickers: Dict) -> List[str]: + """ + Generate the pairlist. + + This method is called once by the pairlistmanager in the refresh_pairlist() + method to supply the starting pairlist for the chain of the Pairlist Handlers. + Pairlist Filters (those Pairlist Handlers that cannot be used at the first + position in the chain) shall not override this base implementation -- + it will raise the exception if a Pairlist Handler is used at the first + position in the chain. + + :param cached_pairlist: Previously generated pairlist (cached) + :param tickers: Tickers (from exchange.get_tickers()). + :return: List of pairs + """ + raise OperationalException("This Pairlist Handler should not be used " + "at the first position in the list of Pairlist Handlers.") + def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: """ Filters and sorts pairlist and returns the whitelist again. + Called on each bot iteration - please use internal caching if necessary - -> Please overwrite in subclasses + This generic implementation calls self._validate_pair() for each pair + in the pairlist. + + Some Pairlist Handlers override this generic implementation and employ + own filtration. + :param pairlist: pairlist to filter or sort :param tickers: Tickers (from exchange.get_tickers()). May be cached. :return: new whitelist """ + if self._enabled: + # Copy list since we're modifying this list + for p in deepcopy(pairlist): + # Filter out assets + if not self._validate_pair(tickers[p]): + pairlist.remove(p) - @staticmethod - def verify_blacklist(pairlist: List[str], blacklist: List[str], - aswarning: bool) -> List[str]: - """ - Verify and remove items from pairlist - returning a filtered pairlist. - Logs a warning or info depending on `aswarning`. - Pairlists explicitly using this method shall use `aswarning=False`! - :param pairlist: Pairlist to validate - :param blacklist: Blacklist to validate pairlist against - :param aswarning: Log message as Warning or info - :return: pairlist - blacklisted pairs - """ - for pair in deepcopy(pairlist): - if pair in blacklist: - if aswarning: - logger.warning(f"Pair {pair} in your blacklist. Removing it from whitelist...") - else: - logger.info(f"Pair {pair} in your blacklist. Removing it from whitelist...") - pairlist.remove(pair) return pairlist - def _verify_blacklist(self, pairlist: List[str], aswarning: bool = True) -> List[str]: + def verify_blacklist(self, pairlist: List[str], logmethod) -> List[str]: """ Proxy method to verify_blacklist for easy access for child classes. - Logs a warning or info depending on `aswarning`. - Pairlists explicitly using this method shall use aswarning=False! :param pairlist: Pairlist to validate - :param aswarning: Log message as Warning or info. + :param logmethod: Function that'll be called, `logger.info` or `logger.warning`. :return: pairlist - blacklisted pairs """ - return IPairList.verify_blacklist(pairlist, self._pairlistmanager.blacklist, - aswarning=aswarning) + return self._pairlistmanager.verify_blacklist(pairlist, logmethod) def _whitelist_for_active_markets(self, pairlist: List[str]) -> List[str]: """ @@ -130,6 +150,9 @@ class IPairList(ABC): black_listed """ markets = self._exchange.markets + if not markets: + raise OperationalException( + 'Markets not loaded. Make sure that exchange is initialized correctly.') sanitized_whitelist: List[str] = [] for pair in pairlist: diff --git a/freqtrade/pairlist/PrecisionFilter.py b/freqtrade/pairlist/PrecisionFilter.py index 2a2ba46b7..3061d3d01 100644 --- a/freqtrade/pairlist/PrecisionFilter.py +++ b/freqtrade/pairlist/PrecisionFilter.py @@ -1,19 +1,37 @@ +""" +Precision pair list filter +""" import logging -from copy import deepcopy -from typing import Dict, List +from typing import Any, Dict from freqtrade.pairlist.IPairList import IPairList +from freqtrade.exceptions import OperationalException logger = logging.getLogger(__name__) class PrecisionFilter(IPairList): + def __init__(self, exchange, pairlistmanager, + config: Dict[str, Any], pairlistconfig: Dict[str, Any], + pairlist_pos: int) -> None: + super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) + + if 'stoploss' not in self._config: + raise OperationalException( + 'PrecisionFilter can only work with stoploss defined. Please add the ' + 'stoploss key to your configuration (overwrites eventual strategy settings).') + self._stoploss = self._config['stoploss'] + self._enabled = self._stoploss != 0 + + # Precalculate sanitized stoploss value to avoid recalculation for every pair + self._stoploss = 1 - abs(self._stoploss) + @property def needstickers(self) -> bool: """ Boolean property defining if tickers are necessary. - If no Pairlist requries tickers, an empty List is passed + If no Pairlist requires tickers, an empty List is passed as tickers argument to filter_pairlist """ return True @@ -24,41 +42,25 @@ class PrecisionFilter(IPairList): """ return f"{self.name} - Filtering untradable pairs." - def _validate_precision_filter(self, ticker: dict, stoploss: float) -> bool: + def _validate_pair(self, ticker: dict) -> bool: """ Check if pair has enough room to add a stoploss to avoid "unsellable" buys of very low value pairs. :param ticker: ticker dict as returned from ccxt.load_markets() - :param stoploss: stoploss value as set in the configuration - (already cleaned to be 1 - stoploss) - :return: True if the pair can stay, false if it should be removed + :return: True if the pair can stay, False if it should be removed """ - stop_price = ticker['ask'] * stoploss + stop_price = ticker['ask'] * self._stoploss + # Adjust stop-prices to precision sp = self._exchange.price_to_precision(ticker["symbol"], stop_price) + stop_gap_price = self._exchange.price_to_precision(ticker["symbol"], stop_price * 0.99) logger.debug(f"{ticker['symbol']} - {sp} : {stop_gap_price}") + if sp <= stop_gap_price: self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, " f"because stop price {sp} would be <= stop limit {stop_gap_price}") return False + return True - - def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: - """ - Filters and sorts pairlists and assigns and returns them again. - """ - stoploss = self._config.get('stoploss') - if stoploss is not None: - # Precalculate sanitized stoploss value to avoid recalculation for every pair - stoploss = 1 - abs(stoploss) - # Copy list since we're modifying this list - for p in deepcopy(pairlist): - ticker = tickers.get(p) - # Filter out assets which would not allow setting a stoploss - if not ticker or (stoploss and not self._validate_precision_filter(ticker, stoploss)): - pairlist.remove(p) - continue - - return pairlist diff --git a/freqtrade/pairlist/PriceFilter.py b/freqtrade/pairlist/PriceFilter.py index 2f7e98e24..b3b2f43dc 100644 --- a/freqtrade/pairlist/PriceFilter.py +++ b/freqtrade/pairlist/PriceFilter.py @@ -1,9 +1,12 @@ +""" +Price pair list filter +""" import logging -from copy import deepcopy -from typing import Any, Dict, List +from typing import Any, Dict from freqtrade.pairlist.IPairList import IPairList + logger = logging.getLogger(__name__) @@ -15,12 +18,17 @@ class PriceFilter(IPairList): super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) self._low_price_ratio = pairlistconfig.get('low_price_ratio', 0) + self._min_price = pairlistconfig.get('min_price', 0) + self._max_price = pairlistconfig.get('max_price', 0) + self._enabled = ((self._low_price_ratio != 0) or + (self._min_price != 0) or + (self._max_price != 0)) @property def needstickers(self) -> bool: """ Boolean property defining if tickers are necessary. - If no Pairlist requries tickers, an empty List is passed + If no Pairlist requires tickers, an empty List is passed as tickers argument to filter_pairlist """ return True @@ -29,45 +37,52 @@ class PriceFilter(IPairList): """ Short whitelist method description - used for startup-messages """ - return f"{self.name} - Filtering pairs priced below {self._low_price_ratio * 100}%." + active_price_filters = [] + if self._low_price_ratio != 0: + active_price_filters.append(f"below {self._low_price_ratio * 100}%") + if self._min_price != 0: + active_price_filters.append(f"below {self._min_price:.8f}") + if self._max_price != 0: + active_price_filters.append(f"above {self._max_price:.8f}") - def _validate_ticker_lowprice(self, ticker) -> bool: + if len(active_price_filters): + return f"{self.name} - Filtering pairs priced {' or '.join(active_price_filters)}." + + return f"{self.name} - No price filters configured." + + def _validate_pair(self, ticker) -> bool: """ Check if if one price-step (pip) is > than a certain barrier. :param ticker: ticker dict as returned from ccxt.load_markets() :return: True if the pair can stay, false if it should be removed """ - if ticker['last'] is None: - + if ticker['last'] is None or ticker['last'] == 0: self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, because " "ticker['last'] is empty (Usually no trade in the last 24h).") return False - compare = ticker['last'] + self._exchange.price_get_one_pip(ticker['symbol'], - ticker['last']) - changeperc = (compare - ticker['last']) / ticker['last'] - if changeperc > self._low_price_ratio: - self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, " - f"because 1 unit is {changeperc * 100:.3f}%") - return False + + # Perform low_price_ratio check. + if self._low_price_ratio != 0: + compare = self._exchange.price_get_one_pip(ticker['symbol'], ticker['last']) + changeperc = compare / ticker['last'] + if changeperc > self._low_price_ratio: + self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, " + f"because 1 unit is {changeperc * 100:.3f}%") + return False + + # Perform min_price check. + if self._min_price != 0: + if ticker['last'] < self._min_price: + self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, " + f"because last price < {self._min_price:.8f}") + return False + + # Perform max_price check. + if self._max_price != 0: + if ticker['last'] > self._max_price: + self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, " + f"because last price > {self._max_price:.8f}") + return False + return True - - def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: - """ - Filters and sorts pairlist and returns the whitelist again. - Called on each bot iteration - please use internal caching if necessary - :param pairlist: pairlist to filter or sort - :param tickers: Tickers (from exchange.get_tickers()). May be cached. - :return: new whitelist - """ - # Copy list since we're modifying this list - for p in deepcopy(pairlist): - ticker = tickers.get(p) - if not ticker: - pairlist.remove(p) - - # Filter out assets which would not allow setting a stoploss - if self._low_price_ratio and not self._validate_ticker_lowprice(ticker): - pairlist.remove(p) - - return pairlist diff --git a/freqtrade/pairlist/ShuffleFilter.py b/freqtrade/pairlist/ShuffleFilter.py new file mode 100644 index 000000000..eb4f6dcc3 --- /dev/null +++ b/freqtrade/pairlist/ShuffleFilter.py @@ -0,0 +1,51 @@ +""" +Shuffle pair list filter +""" +import logging +import random +from typing import Any, Dict, List + +from freqtrade.pairlist.IPairList import IPairList + + +logger = logging.getLogger(__name__) + + +class ShuffleFilter(IPairList): + + def __init__(self, exchange, pairlistmanager, + config: Dict[str, Any], pairlistconfig: Dict[str, Any], + pairlist_pos: int) -> None: + super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) + + self._seed = pairlistconfig.get('seed') + self._random = random.Random(self._seed) + + @property + def needstickers(self) -> bool: + """ + Boolean property defining if tickers are necessary. + If no Pairlist requires tickers, an empty List is passed + as tickers argument to filter_pairlist + """ + return False + + def short_desc(self) -> str: + """ + Short whitelist method description - used for startup-messages + """ + return (f"{self.name} - Shuffling pairs" + + (f", seed = {self._seed}." if self._seed is not None else ".")) + + def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: + """ + Filters and sorts pairlist and returns the whitelist again. + Called on each bot iteration - please use internal caching if necessary + :param pairlist: pairlist to filter or sort + :param tickers: Tickers (from exchange.get_tickers()). May be cached. + :return: new whitelist + """ + # Shuffle is done inplace + self._random.shuffle(pairlist) + + return pairlist diff --git a/freqtrade/pairlist/SpreadFilter.py b/freqtrade/pairlist/SpreadFilter.py index 49731ef11..2527a3131 100644 --- a/freqtrade/pairlist/SpreadFilter.py +++ b/freqtrade/pairlist/SpreadFilter.py @@ -1,25 +1,30 @@ +""" +Spread pair list filter +""" import logging -from copy import deepcopy -from typing import Dict, List +from typing import Any, Dict from freqtrade.pairlist.IPairList import IPairList + logger = logging.getLogger(__name__) class SpreadFilter(IPairList): - def __init__(self, exchange, pairlistmanager, config, pairlistconfig: dict, + def __init__(self, exchange, pairlistmanager, + config: Dict[str, Any], pairlistconfig: Dict[str, Any], pairlist_pos: int) -> None: super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) self._max_spread_ratio = pairlistconfig.get('max_spread_ratio', 0.005) + self._enabled = self._max_spread_ratio != 0 @property def needstickers(self) -> bool: """ Boolean property defining if tickers are necessary. - If no Pairlist requries tickers, an empty List is passed + If no Pairlist requires tickers, an empty List is passed as tickers argument to filter_pairlist """ return True @@ -31,29 +36,19 @@ class SpreadFilter(IPairList): return (f"{self.name} - Filtering pairs with ask/bid diff above " f"{self._max_spread_ratio * 100}%.") - def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: - + def _validate_pair(self, ticker: dict) -> bool: """ - Filters and sorts pairlist and returns the whitelist again. - Called on each bot iteration - please use internal caching if necessary - :param pairlist: pairlist to filter or sort - :param tickers: Tickers (from exchange.get_tickers()). May be cached. - :return: new whitelist + Validate spread for the ticker + :param ticker: ticker dict as returned from ccxt.load_markets() + :return: True if the pair can stay, False if it should be removed """ - # Copy list since we're modifying this list - - spread = None - for p in deepcopy(pairlist): - ticker = tickers.get(p) - assert ticker is not None - if 'bid' in ticker and 'ask' in ticker: - spread = 1 - ticker['bid'] / ticker['ask'] - if not ticker or spread > self._max_spread_ratio: - self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, " - f"because spread {spread * 100:.3f}% >" - f"{self._max_spread_ratio * 100}%") - pairlist.remove(p) + if 'bid' in ticker and 'ask' in ticker: + spread = 1 - ticker['bid'] / ticker['ask'] + if spread > self._max_spread_ratio: + self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, " + f"because spread {spread * 100:.3f}% >" + f"{self._max_spread_ratio * 100}%") + return False else: - pairlist.remove(p) - - return pairlist + return True + return False diff --git a/freqtrade/pairlist/StaticPairList.py b/freqtrade/pairlist/StaticPairList.py index 0050fbd5c..aa6268ba3 100644 --- a/freqtrade/pairlist/StaticPairList.py +++ b/freqtrade/pairlist/StaticPairList.py @@ -1,24 +1,34 @@ """ -Static List provider +Static Pair List provider -Provides lists as configured in config.json - - """ +Provides pair white list as it configured in config +""" import logging -from typing import Dict, List +from typing import Any, Dict, List +from freqtrade.exceptions import OperationalException from freqtrade.pairlist.IPairList import IPairList + logger = logging.getLogger(__name__) class StaticPairList(IPairList): + def __init__(self, exchange, pairlistmanager, + config: Dict[str, Any], pairlistconfig: Dict[str, Any], + pairlist_pos: int) -> None: + super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) + + if self._pairlist_pos != 0: + raise OperationalException(f"{self.name} can only be used in the first position " + "in the list of Pairlist Handlers.") + @property def needstickers(self) -> bool: """ Boolean property defining if tickers are necessary. - If no Pairlist requries tickers, an empty List is passed + If no Pairlist requires tickers, an empty List is passed as tickers argument to filter_pairlist """ return False @@ -30,6 +40,15 @@ class StaticPairList(IPairList): """ return f"{self.name}" + def gen_pairlist(self, cached_pairlist: List[str], tickers: Dict) -> List[str]: + """ + Generate the pairlist + :param cached_pairlist: Previously generated pairlist (cached) + :param tickers: Tickers (from exchange.get_tickers()). + :return: List of pairs + """ + return self._whitelist_for_active_markets(self._config['exchange']['pair_whitelist']) + def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: """ Filters and sorts pairlist and returns the whitelist again. @@ -38,4 +57,4 @@ class StaticPairList(IPairList): :param tickers: Tickers (from exchange.get_tickers()). May be cached. :return: new whitelist """ - return self._whitelist_for_active_markets(self._config['exchange']['pair_whitelist']) + return pairlist diff --git a/freqtrade/pairlist/VolumePairList.py b/freqtrade/pairlist/VolumePairList.py index 65f43245c..35dce93eb 100644 --- a/freqtrade/pairlist/VolumePairList.py +++ b/freqtrade/pairlist/VolumePairList.py @@ -1,9 +1,8 @@ """ Volume PairList provider -Provides lists as configured in config.json - - """ +Provides dynamic pair list based on trade volumes +""" import logging from datetime import datetime from typing import Any, Dict, List @@ -11,21 +10,26 @@ from typing import Any, Dict, List from freqtrade.exceptions import OperationalException from freqtrade.pairlist.IPairList import IPairList + logger = logging.getLogger(__name__) + SORT_VALUES = ['askVolume', 'bidVolume', 'quoteVolume'] class VolumePairList(IPairList): - def __init__(self, exchange, pairlistmanager, config: Dict[str, Any], pairlistconfig: dict, + def __init__(self, exchange, pairlistmanager, + config: Dict[str, Any], pairlistconfig: Dict[str, Any], pairlist_pos: int) -> None: super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) if 'number_assets' not in self._pairlistconfig: raise OperationalException( - f'`number_assets` not specified. Please check your configuration ' + '`number_assets` not specified. Please check your configuration ' 'for "pairlist.config.number_assets"') + + self._stake_currency = config['stake_currency'] self._number_pairs = self._pairlistconfig['number_assets'] self._sort_key = self._pairlistconfig.get('sort_key', 'quoteVolume') self._min_value = self._pairlistconfig.get('min_value', 0) @@ -33,18 +37,24 @@ class VolumePairList(IPairList): if not self._exchange.exchange_has('fetchTickers'): raise OperationalException( - 'Exchange does not support dynamic whitelist.' - 'Please edit your config and restart the bot' + 'Exchange does not support dynamic whitelist. ' + 'Please edit your config and restart the bot.' ) + if not self._validate_keys(self._sort_key): raise OperationalException( f'key {self._sort_key} not in {SORT_VALUES}') + if self._sort_key != 'quoteVolume': + logger.warning( + "DEPRECATED: using any key other than quoteVolume for VolumePairList is deprecated." + ) + @property def needstickers(self) -> bool: """ Boolean property defining if tickers are necessary. - If no Pairlist requries tickers, an empty List is passed + If no Pairlist requires tickers, an empty List is passed as tickers argument to filter_pairlist """ return True @@ -58,6 +68,31 @@ class VolumePairList(IPairList): """ return f"{self.name} - top {self._pairlistconfig['number_assets']} volume pairs." + def gen_pairlist(self, cached_pairlist: List[str], tickers: Dict) -> List[str]: + """ + Generate the pairlist + :param cached_pairlist: Previously generated pairlist (cached) + :param tickers: Tickers (from exchange.get_tickers()). + :return: List of pairs + """ + # Generate dynamic whitelist + # Must always run if this pairlist is not the first in the list. + if self._last_refresh + self.refresh_period < datetime.now().timestamp(): + self._last_refresh = int(datetime.now().timestamp()) + + # Use fresh pairlist + # Check if pair quote currency equals to the stake currency. + filtered_tickers = [ + v for k, v in tickers.items() + if (self._exchange.get_pair_quote_currency(k) == self._stake_currency + and v[self._sort_key] is not None)] + pairlist = [s['symbol'] for s in filtered_tickers] + else: + # Use the cached pairlist if it's not time yet to refresh + pairlist = cached_pairlist + + return pairlist + def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: """ Filters and sorts pairlist and returns the whitelist again. @@ -66,48 +101,21 @@ class VolumePairList(IPairList): :param tickers: Tickers (from exchange.get_tickers()). May be cached. :return: new whitelist """ - # Generate dynamic whitelist - # Must always run if this pairlist is not the first in the list. - if (self._pairlist_pos != 0 or - (self._last_refresh + self.refresh_period < datetime.now().timestamp())): + # Use the incoming pairlist. + filtered_tickers = [v for k, v in tickers.items() if k in pairlist] - self._last_refresh = int(datetime.now().timestamp()) - pairs = self._gen_pair_whitelist(pairlist, tickers, - self._config['stake_currency'], - self._sort_key, self._min_value) - else: - pairs = pairlist - self.log_on_refresh(logger.info, f"Searching {self._number_pairs} pairs: {pairs}") - return pairs + if self._min_value > 0: + filtered_tickers = [ + v for v in filtered_tickers if v[self._sort_key] > self._min_value] - def _gen_pair_whitelist(self, pairlist: List[str], tickers: Dict, - base_currency: str, key: str, min_val: int) -> List[str]: - """ - Updates the whitelist with with a dynamically generated list - :param base_currency: base currency as str - :param key: sort key (defaults to 'quoteVolume') - :param tickers: Tickers (from exchange.get_tickers()). - :return: List of pairs - """ - if self._pairlist_pos == 0: - # If VolumePairList is the first in the list, use fresh pairlist - # Check if pair quote currency equals to the stake currency. - filtered_tickers = [v for k, v in tickers.items() - if (self._exchange.get_pair_quote_currency(k) == base_currency - and v[key] is not None)] - else: - # If other pairlist is in front, use the incomming pairlist. - filtered_tickers = [v for k, v in tickers.items() if k in pairlist] - - if min_val > 0: - filtered_tickers = list(filter(lambda t: t[key] > min_val, filtered_tickers)) - - sorted_tickers = sorted(filtered_tickers, reverse=True, key=lambda t: t[key]) + sorted_tickers = sorted(filtered_tickers, reverse=True, key=lambda t: t[self._sort_key]) # Validate whitelist to only have active market pairs pairs = self._whitelist_for_active_markets([s['symbol'] for s in sorted_tickers]) - pairs = self._verify_blacklist(pairs, aswarning=False) - # Limit to X number of pairs + pairs = self.verify_blacklist(pairs, logger.info) + # Limit pairlist to the requested number of pairs pairs = pairs[:self._number_pairs] + self.log_on_refresh(logger.info, f"Searching {self._number_pairs} pairs: {pairs}") + return pairs diff --git a/freqtrade/pairlist/pairlistmanager.py b/freqtrade/pairlist/pairlistmanager.py index 5b4c5b602..81e52768e 100644 --- a/freqtrade/pairlist/pairlistmanager.py +++ b/freqtrade/pairlist/pairlistmanager.py @@ -1,10 +1,8 @@ """ -Static List provider - -Provides lists as configured in config.json - - """ +PairList manager class +""" import logging +from copy import deepcopy from typing import Dict, List from cachetools import TTLCache, cached @@ -12,6 +10,8 @@ from cachetools import TTLCache, cached from freqtrade.exceptions import OperationalException from freqtrade.pairlist.IPairList import IPairList from freqtrade.resolvers import PairListResolver +from freqtrade.constants import ListPairsWithTimeframes + logger = logging.getLogger(__name__) @@ -23,24 +23,25 @@ class PairListManager(): self._config = config self._whitelist = self._config['exchange'].get('pair_whitelist') self._blacklist = self._config['exchange'].get('pair_blacklist', []) - self._pairlists: List[IPairList] = [] + self._pairlist_handlers: List[IPairList] = [] self._tickers_needed = False - for pl in self._config.get('pairlists', None): - if 'method' not in pl: - logger.warning(f"No method in {pl}") + for pairlist_handler_config in self._config.get('pairlists', None): + if 'method' not in pairlist_handler_config: + logger.warning(f"No method found in {pairlist_handler_config}, ignoring.") continue - pairl = PairListResolver.load_pairlist(pl.get('method'), - exchange=exchange, - pairlistmanager=self, - config=config, - pairlistconfig=pl, - pairlist_pos=len(self._pairlists) - ) - self._tickers_needed = pairl.needstickers or self._tickers_needed - self._pairlists.append(pairl) + pairlist_handler = PairListResolver.load_pairlist( + pairlist_handler_config['method'], + exchange=exchange, + pairlistmanager=self, + config=config, + pairlistconfig=pairlist_handler_config, + pairlist_pos=len(self._pairlist_handlers) + ) + self._tickers_needed |= pairlist_handler.needstickers + self._pairlist_handlers.append(pairlist_handler) - if not self._pairlists: - raise OperationalException("No Pairlist defined!") + if not self._pairlist_handlers: + raise OperationalException("No Pairlist Handlers defined") @property def whitelist(self) -> List[str]: @@ -60,15 +61,15 @@ class PairListManager(): @property def name_list(self) -> List[str]: """ - Get list of loaded pairlists names + Get list of loaded Pairlist Handler names """ - return [p.name for p in self._pairlists] + return [p.name for p in self._pairlist_handlers] def short_desc(self) -> List[Dict]: """ - List of short_desc for each pairlist + List of short_desc for each Pairlist Handler """ - return [{p.name: p.short_desc()} for p in self._pairlists] + return [{p.name: p.short_desc()} for p in self._pairlist_handlers] @cached(TTLCache(maxsize=1, ttl=1800)) def _get_cached_tickers(self): @@ -76,21 +77,60 @@ class PairListManager(): def refresh_pairlist(self) -> None: """ - Run pairlist through all configured pairlists. + Run pairlist through all configured Pairlist Handlers. """ - - pairlist = self._whitelist.copy() - - # tickers should be cached to avoid calling the exchange on each call. + # Tickers should be cached to avoid calling the exchange on each call. tickers: Dict = {} if self._tickers_needed: tickers = self._get_cached_tickers() - # Process all pairlists in chain - for pl in self._pairlists: - pairlist = pl.filter_pairlist(pairlist, tickers) + # Adjust whitelist if filters are using tickers + pairlist = self._prepare_whitelist(self._whitelist.copy(), tickers) - # Validation against blacklist happens after the pairlists to ensure blacklist is respected. - pairlist = IPairList.verify_blacklist(pairlist, self.blacklist, True) + # Generate the pairlist with first Pairlist Handler in the chain + pairlist = self._pairlist_handlers[0].gen_pairlist(self._whitelist, tickers) + + # Process all Pairlist Handlers in the chain + for pairlist_handler in self._pairlist_handlers: + pairlist = pairlist_handler.filter_pairlist(pairlist, tickers) + + # Validation against blacklist happens after the chain of Pairlist Handlers + # to ensure blacklist is respected. + pairlist = self.verify_blacklist(pairlist, logger.warning) self._whitelist = pairlist + + def _prepare_whitelist(self, pairlist: List[str], tickers) -> List[str]: + """ + Prepare sanitized pairlist for Pairlist Handlers that use tickers data - remove + pairs that do not have ticker available + """ + if self._tickers_needed: + # Copy list since we're modifying this list + for p in deepcopy(pairlist): + if p not in tickers: + pairlist.remove(p) + + return pairlist + + def verify_blacklist(self, pairlist: List[str], logmethod) -> List[str]: + """ + Verify and remove items from pairlist - returning a filtered pairlist. + Logs a warning or info depending on `aswarning`. + Pairlist Handlers explicitly using this method shall use + `logmethod=logger.info` to avoid spamming with warning messages + :param pairlist: Pairlist to validate + :param logmethod: Function that'll be called, `logger.info` or `logger.warning`. + :return: pairlist - blacklisted pairs + """ + for pair in deepcopy(pairlist): + if pair in self._blacklist: + logmethod(f"Pair {pair} in your blacklist. Removing it from whitelist...") + pairlist.remove(pair) + return pairlist + + def create_pair_list(self, pairs: List[str], timeframe: str = None) -> ListPairsWithTimeframes: + """ + Create list of pair tuples with (pair, timeframe) + """ + return [(pair, timeframe or self._config['timeframe']) for pair in pairs] diff --git a/freqtrade/persistence.py b/freqtrade/persistence.py index fb314f439..28753ed48 100644 --- a/freqtrade/persistence.py +++ b/freqtrade/persistence.py @@ -17,6 +17,7 @@ from sqlalchemy.orm.session import sessionmaker from sqlalchemy.pool import StaticPool from freqtrade.exceptions import OperationalException +from freqtrade.misc import safe_value_fallback logger = logging.getLogger(__name__) @@ -86,11 +87,15 @@ def check_migrate(engine) -> None: logger.debug(f'trying {table_back_name}') # Check for latest column - if not has_column(cols, 'close_profit_abs'): + if not has_column(cols, 'amount_requested'): logger.info(f'Running database migration - backup available as {table_back_name}') fee_open = get_column_def(cols, 'fee_open', 'fee') + fee_open_cost = get_column_def(cols, 'fee_open_cost', 'null') + fee_open_currency = get_column_def(cols, 'fee_open_currency', 'null') fee_close = get_column_def(cols, 'fee_close', 'fee') + fee_close_cost = get_column_def(cols, 'fee_close_cost', 'null') + fee_close_currency = get_column_def(cols, 'fee_close_currency', 'null') open_rate_requested = get_column_def(cols, 'open_rate_requested', 'null') close_rate_requested = get_column_def(cols, 'close_rate_requested', 'null') stop_loss = get_column_def(cols, 'stop_loss', '0.0') @@ -103,12 +108,19 @@ def check_migrate(engine) -> None: min_rate = get_column_def(cols, 'min_rate', 'null') sell_reason = get_column_def(cols, 'sell_reason', 'null') strategy = get_column_def(cols, 'strategy', 'null') - ticker_interval = get_column_def(cols, 'ticker_interval', 'null') + # If ticker-interval existed use that, else null. + if has_column(cols, 'ticker_interval'): + timeframe = get_column_def(cols, 'timeframe', 'ticker_interval') + else: + timeframe = get_column_def(cols, 'timeframe', 'null') + open_trade_price = get_column_def(cols, 'open_trade_price', f'amount * open_rate * (1 + {fee_open})') close_profit_abs = get_column_def( cols, 'close_profit_abs', f"(amount * close_rate * (1 - {fee_close})) - {open_trade_price}") + sell_order_status = get_column_def(cols, 'sell_order_status', 'null') + amount_requested = get_column_def(cols, 'amount_requested', 'amount') # Schema migration necessary engine.execute(f"alter table trades rename to {table_back_name}") @@ -120,13 +132,15 @@ def check_migrate(engine) -> None: # Copy data back - following the correct schema engine.execute(f"""insert into trades - (id, exchange, pair, is_open, fee_open, fee_close, open_rate, + (id, exchange, pair, is_open, + fee_open, fee_open_cost, fee_open_currency, + fee_close, fee_close_cost, fee_open_currency, open_rate, open_rate_requested, close_rate, close_rate_requested, close_profit, - stake_amount, amount, open_date, close_date, open_order_id, + stake_amount, amount, amount_requested, open_date, close_date, open_order_id, stop_loss, stop_loss_pct, initial_stop_loss, initial_stop_loss_pct, stoploss_order_id, stoploss_last_update, - max_rate, min_rate, sell_reason, strategy, - ticker_interval, open_trade_price, close_profit_abs + max_rate, min_rate, sell_reason, sell_order_status, strategy, + timeframe, open_trade_price, close_profit_abs ) select id, lower(exchange), case @@ -136,16 +150,19 @@ def check_migrate(engine) -> None: else pair end pair, - is_open, {fee_open} fee_open, {fee_close} fee_close, + is_open, {fee_open} fee_open, {fee_open_cost} fee_open_cost, + {fee_open_currency} fee_open_currency, {fee_close} fee_close, + {fee_close_cost} fee_close_cost, {fee_close_currency} fee_close_currency, open_rate, {open_rate_requested} open_rate_requested, close_rate, {close_rate_requested} close_rate_requested, close_profit, - stake_amount, amount, open_date, close_date, open_order_id, + stake_amount, amount, {amount_requested}, open_date, close_date, open_order_id, {stop_loss} stop_loss, {stop_loss_pct} stop_loss_pct, {initial_stop_loss} initial_stop_loss, {initial_stop_loss_pct} initial_stop_loss_pct, {stoploss_order_id} stoploss_order_id, {stoploss_last_update} stoploss_last_update, {max_rate} max_rate, {min_rate} min_rate, {sell_reason} sell_reason, - {strategy} strategy, {ticker_interval} ticker_interval, + {sell_order_status} sell_order_status, + {strategy} strategy, {timeframe} timeframe, {open_trade_price} open_trade_price, {close_profit_abs} close_profit_abs from {table_back_name} """) @@ -185,7 +202,11 @@ class Trade(_DECL_BASE): pair = Column(String, nullable=False, index=True) is_open = Column(Boolean, nullable=False, default=True, index=True) fee_open = Column(Float, nullable=False, default=0.0) + fee_open_cost = Column(Float, nullable=True) + fee_open_currency = Column(String, nullable=True) fee_close = Column(Float, nullable=False, default=0.0) + fee_close_cost = Column(Float, nullable=True) + fee_close_currency = Column(String, nullable=True) open_rate = Column(Float) open_rate_requested = Column(Float) # open_trade_price - calculated via _calc_open_trade_price @@ -196,6 +217,7 @@ class Trade(_DECL_BASE): close_profit_abs = Column(Float) stake_amount = Column(Float, nullable=False) amount = Column(Float) + amount_requested = Column(Float) open_date = Column(DateTime, nullable=False, default=datetime.utcnow) close_date = Column(DateTime) open_order_id = Column(String) @@ -216,8 +238,9 @@ class Trade(_DECL_BASE): # Lowest price reached min_rate = Column(Float, nullable=True) sell_reason = Column(String, nullable=True) + sell_order_status = Column(String, nullable=True) strategy = Column(String, nullable=True) - ticker_interval = Column(Integer, nullable=True) + timeframe = Column(Integer, nullable=True) def __init__(self, **kwargs): super().__init__(**kwargs) @@ -234,32 +257,58 @@ class Trade(_DECL_BASE): 'trade_id': self.id, 'pair': self.pair, 'is_open': self.is_open, + 'exchange': self.exchange, + 'amount': round(self.amount, 8), + 'amount_requested': round(self.amount_requested, 8) if self.amount_requested else None, + 'stake_amount': round(self.stake_amount, 8), + 'strategy': self.strategy, + 'ticker_interval': self.timeframe, # DEPRECATED + 'timeframe': self.timeframe, + 'fee_open': self.fee_open, + 'fee_open_cost': self.fee_open_cost, + 'fee_open_currency': self.fee_open_currency, 'fee_close': self.fee_close, + 'fee_close_cost': self.fee_close_cost, + 'fee_close_currency': self.fee_close_currency, + 'open_date_hum': arrow.get(self.open_date).humanize(), 'open_date': self.open_date.strftime("%Y-%m-%d %H:%M:%S"), + 'open_timestamp': int(self.open_date.timestamp() * 1000), + 'open_rate': self.open_rate, + 'open_rate_requested': self.open_rate_requested, + 'open_trade_price': round(self.open_trade_price, 8), + 'close_date_hum': (arrow.get(self.close_date).humanize() if self.close_date else None), 'close_date': (self.close_date.strftime("%Y-%m-%d %H:%M:%S") if self.close_date else None), - 'open_rate': self.open_rate, - 'open_rate_requested': self.open_rate_requested, - 'open_trade_price': self.open_trade_price, + 'close_timestamp': int(self.close_date.timestamp() * 1000) if self.close_date else None, 'close_rate': self.close_rate, 'close_rate_requested': self.close_rate_requested, - 'amount': round(self.amount, 8), - 'stake_amount': round(self.stake_amount, 8), 'close_profit': self.close_profit, + 'close_profit_abs': self.close_profit_abs, + 'sell_reason': self.sell_reason, - 'stop_loss': self.stop_loss, + 'sell_order_status': self.sell_order_status, + 'stop_loss': self.stop_loss, # Deprecated - should not be used + 'stop_loss_abs': self.stop_loss, + 'stop_loss_ratio': self.stop_loss_pct if self.stop_loss_pct else None, 'stop_loss_pct': (self.stop_loss_pct * 100) if self.stop_loss_pct else None, - 'initial_stop_loss': self.initial_stop_loss, + 'stoploss_order_id': self.stoploss_order_id, + 'stoploss_last_update': (self.stoploss_last_update.strftime("%Y-%m-%d %H:%M:%S") + if self.stoploss_last_update else None), + 'stoploss_last_update_timestamp': (int(self.stoploss_last_update.timestamp() * 1000) + if self.stoploss_last_update else None), + 'initial_stop_loss': self.initial_stop_loss, # Deprecated - should not be used + 'initial_stop_loss_abs': self.initial_stop_loss, + 'initial_stop_loss_ratio': (self.initial_stop_loss_pct + if self.initial_stop_loss_pct else None), 'initial_stop_loss_pct': (self.initial_stop_loss_pct * 100 if self.initial_stop_loss_pct else None), 'min_rate': self.min_rate, 'max_rate': self.max_rate, - 'strategy': self.strategy, - 'ticker_interval': self.ticker_interval, + 'open_order_id': self.open_order_id, } @@ -315,27 +364,27 @@ class Trade(_DECL_BASE): def update(self, order: Dict) -> None: """ Updates this entity with amount and actual open/close rates. - :param order: order retrieved by exchange.get_order() + :param order: order retrieved by exchange.fetch_order() :return: None """ order_type = order['type'] # Ignore open and cancelled orders - if order['status'] == 'open' or order['price'] is None: + if order['status'] == 'open' or safe_value_fallback(order, 'average', 'price') is None: return logger.info('Updating trade (id=%s) ...', self.id) if order_type in ('market', 'limit') and order['side'] == 'buy': # Update open rate and actual amount - self.open_rate = Decimal(order['price']) - self.amount = Decimal(order.get('filled', order['amount'])) + self.open_rate = Decimal(safe_value_fallback(order, 'average', 'price')) + self.amount = Decimal(safe_value_fallback(order, 'filled', 'amount')) self.recalc_open_trade_price() logger.info('%s_BUY has been fulfilled for %s.', order_type.upper(), self) self.open_order_id = None elif order_type in ('market', 'limit') and order['side'] == 'sell': - self.close(order['price']) + self.close(safe_value_fallback(order, 'average', 'price')) logger.info('%s_SELL has been fulfilled for %s.', order_type.upper(), self) - elif order_type in ('stop_loss_limit', 'stop-loss'): + elif order_type in ('stop_loss_limit', 'stop-loss', 'stop'): self.stoploss_order_id = None self.close_rate_requested = self.stop_loss logger.info('%s is hit for %s.', order_type.upper(), self) @@ -354,12 +403,42 @@ class Trade(_DECL_BASE): self.close_profit_abs = self.calc_profit() self.close_date = datetime.utcnow() self.is_open = False + self.sell_order_status = 'closed' self.open_order_id = None logger.info( 'Marking %s as closed as the trade is fulfilled and found no open orders for it.', self ) + def update_fee(self, fee_cost: float, fee_currency: Optional[str], fee_rate: Optional[float], + side: str) -> None: + """ + Update Fee parameters. Only acts once per side + """ + if side == 'buy' and self.fee_open_currency is None: + self.fee_open_cost = fee_cost + self.fee_open_currency = fee_currency + if fee_rate is not None: + self.fee_open = fee_rate + # Assume close-fee will fall into the same fee category and take an educated guess + self.fee_close = fee_rate + elif side == 'sell' and self.fee_close_currency is None: + self.fee_close_cost = fee_cost + self.fee_close_currency = fee_currency + if fee_rate is not None: + self.fee_close = fee_rate + + def fee_updated(self, side: str) -> bool: + """ + Verify if this side (buy / sell) has already been updated + """ + if side == 'buy': + return self.fee_open_currency is not None + elif side == 'sell': + return self.fee_close_currency is not None + else: + return False + def _calc_open_trade_price(self) -> float: """ Calculate the open_rate including open_fee. @@ -494,6 +573,7 @@ class Trade(_DECL_BASE): def get_best_pair(): """ Get best pair with closed trade. + :returns: Tuple containing (pair, profit_sum) """ best_pair = Trade.session.query( Trade.pair, func.sum(Trade.close_profit).label('profit_sum') diff --git a/freqtrade/plot/plotting.py b/freqtrade/plot/plotting.py index 60f838db2..a933c6a76 100644 --- a/freqtrade/plot/plotting.py +++ b/freqtrade/plot/plotting.py @@ -10,10 +10,13 @@ from freqtrade.data.btanalysis import (calculate_max_drawdown, create_cum_profit, extract_trades_of_period, load_trades) from freqtrade.data.converter import trim_dataframe -from freqtrade.exchange import timeframe_to_prev_date +from freqtrade.data.dataprovider import DataProvider from freqtrade.data.history import load_data +from freqtrade.exceptions import OperationalException +from freqtrade.exchange import timeframe_to_prev_date from freqtrade.misc import pair_to_filename -from freqtrade.resolvers import StrategyResolver +from freqtrade.resolvers import ExchangeResolver, StrategyResolver +from freqtrade.strategy import IStrategy logger = logging.getLogger(__name__) @@ -44,7 +47,7 @@ def init_plotscript(config): data = load_data( datadir=config.get("datadir"), pairs=pairs, - timeframe=config.get('ticker_interval', '5m'), + timeframe=config.get('timeframe', '5m'), timerange=timerange, data_format=config.get('dataformat_ohlcv', 'json'), ) @@ -161,7 +164,7 @@ def plot_trades(fig, trades: pd.DataFrame) -> make_subplots: # Trades can be empty if trades is not None and len(trades) > 0: # Create description for sell summarizing the trade - trades['desc'] = trades.apply(lambda row: f"{round(row['profitperc'] * 100, 1)}%, " + trades['desc'] = trades.apply(lambda row: f"{round(row['profit_percent'] * 100, 1)}%, " f"{row['sell_reason']}, {row['duration']} min", axis=1) trade_buys = go.Scatter( @@ -180,9 +183,9 @@ def plot_trades(fig, trades: pd.DataFrame) -> make_subplots: ) trade_sells = go.Scatter( - x=trades.loc[trades['profitperc'] > 0, "close_time"], - y=trades.loc[trades['profitperc'] > 0, "close_rate"], - text=trades.loc[trades['profitperc'] > 0, "desc"], + x=trades.loc[trades['profit_percent'] > 0, "close_time"], + y=trades.loc[trades['profit_percent'] > 0, "close_rate"], + text=trades.loc[trades['profit_percent'] > 0, "desc"], mode='markers', name='Sell - Profit', marker=dict( @@ -193,9 +196,9 @@ def plot_trades(fig, trades: pd.DataFrame) -> make_subplots: ) ) trade_sells_loss = go.Scatter( - x=trades.loc[trades['profitperc'] <= 0, "close_time"], - y=trades.loc[trades['profitperc'] <= 0, "close_rate"], - text=trades.loc[trades['profitperc'] <= 0, "desc"], + x=trades.loc[trades['profit_percent'] <= 0, "close_time"], + y=trades.loc[trades['profit_percent'] <= 0, "close_rate"], + text=trades.loc[trades['profit_percent'] <= 0, "desc"], mode='markers', name='Sell - Loss', marker=dict( @@ -414,9 +417,12 @@ def generate_profit_graph(pairs: str, data: Dict[str, pd.DataFrame], for pair in pairs: profit_col = f'cum_profit_{pair}' - df_comb = create_cum_profit(df_comb, trades[trades['pair'] == pair], profit_col, timeframe) - - fig = add_profit(fig, 3, df_comb, profit_col, f"Profit {pair}") + try: + df_comb = create_cum_profit(df_comb, trades[trades['pair'] == pair], profit_col, + timeframe) + fig = add_profit(fig, 3, df_comb, profit_col, f"Profit {pair}") + except ValueError: + pass return fig @@ -463,6 +469,8 @@ def load_and_plot_trades(config: Dict[str, Any]): """ strategy = StrategyResolver.load_strategy(config) + exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config) + IStrategy.dp = DataProvider(config, exchange) plot_elements = init_plotscript(config) trades = plot_elements['trades'] pair_counter = 0 @@ -483,7 +491,7 @@ def load_and_plot_trades(config: Dict[str, Any]): plot_config=strategy.plot_config if hasattr(strategy, 'plot_config') else {} ) - store_plot_file(fig, filename=generate_plot_filename(pair, config['ticker_interval']), + store_plot_file(fig, filename=generate_plot_filename(pair, config['timeframe']), directory=config['user_data_dir'] / "plot") logger.info('End of plotting process. %s plots generated', pair_counter) @@ -504,10 +512,13 @@ def plot_profit(config: Dict[str, Any]) -> None: trades = trades[(trades['pair'].isin(plot_elements["pairs"])) & (~trades['close_time'].isnull()) ] + if len(trades) == 0: + raise OperationalException("No trades found, cannot generate Profit-plot without " + "trades from either Backtest result or database.") # Create an average close price of all the pairs that were involved. # this could be useful to gauge the overall market trend fig = generate_profit_graph(plot_elements["pairs"], plot_elements["ohlcv"], - trades, config.get('ticker_interval', '5m')) + trades, config.get('timeframe', '5m')) store_plot_file(fig, filename='freqtrade-profit-plot.html', directory=config['user_data_dir'] / "plot", auto_open=True) diff --git a/freqtrade/resolvers/hyperopt_resolver.py b/freqtrade/resolvers/hyperopt_resolver.py index ddf461252..abbfee6ed 100644 --- a/freqtrade/resolvers/hyperopt_resolver.py +++ b/freqtrade/resolvers/hyperopt_resolver.py @@ -42,14 +42,14 @@ class HyperOptResolver(IResolver): extra_dir=config.get('hyperopt_path')) if not hasattr(hyperopt, 'populate_indicators'): - logger.warning("Hyperopt class does not provide populate_indicators() method. " - "Using populate_indicators from the strategy.") + logger.info("Hyperopt class does not provide populate_indicators() method. " + "Using populate_indicators from the strategy.") if not hasattr(hyperopt, 'populate_buy_trend'): - logger.warning("Hyperopt class does not provide populate_buy_trend() method. " - "Using populate_buy_trend from the strategy.") + logger.info("Hyperopt class does not provide populate_buy_trend() method. " + "Using populate_buy_trend from the strategy.") if not hasattr(hyperopt, 'populate_sell_trend'): - logger.warning("Hyperopt class does not provide populate_sell_trend() method. " - "Using populate_sell_trend from the strategy.") + logger.info("Hyperopt class does not provide populate_sell_trend() method. " + "Using populate_sell_trend from the strategy.") return hyperopt @@ -77,8 +77,9 @@ class HyperOptLossResolver(IResolver): config, kwargs={}, extra_dir=config.get('hyperopt_path')) - # Assign ticker_interval to be used in hyperopt - hyperoptloss.__class__.ticker_interval = str(config['ticker_interval']) + # Assign timeframe to be used in hyperopt + hyperoptloss.__class__.ticker_interval = str(config['timeframe']) + hyperoptloss.__class__.timeframe = str(config['timeframe']) if not hasattr(hyperoptloss, 'hyperopt_loss_function'): raise OperationalException( diff --git a/freqtrade/resolvers/strategy_resolver.py b/freqtrade/resolvers/strategy_resolver.py index cddc7c9cd..121a04877 100644 --- a/freqtrade/resolvers/strategy_resolver.py +++ b/freqtrade/resolvers/strategy_resolver.py @@ -50,39 +50,51 @@ class StrategyResolver(IResolver): if 'ask_strategy' not in config: config['ask_strategy'] = {} + if hasattr(strategy, 'ticker_interval') and not hasattr(strategy, 'timeframe'): + # Assign ticker_interval to timeframe to keep compatibility + if 'timeframe' not in config: + logger.warning( + "DEPRECATED: Please migrate to using 'timeframe' instead of 'ticker_interval'." + ) + strategy.timeframe = strategy.ticker_interval + # Set attributes # Check if we need to override configuration - # (Attribute name, default, ask_strategy) - attributes = [("minimal_roi", {"0": 10.0}, False), - ("ticker_interval", None, False), - ("stoploss", None, False), - ("trailing_stop", None, False), - ("trailing_stop_positive", None, False), - ("trailing_stop_positive_offset", 0.0, False), - ("trailing_only_offset_is_reached", None, False), - ("process_only_new_candles", None, False), - ("order_types", None, False), - ("order_time_in_force", None, False), - ("stake_currency", None, False), - ("stake_amount", None, False), - ("startup_candle_count", None, False), - ("unfilledtimeout", None, False), - ("use_sell_signal", True, True), - ("sell_profit_only", False, True), - ("ignore_roi_if_buy_signal", False, True), + # (Attribute name, default, subkey) + attributes = [("minimal_roi", {"0": 10.0}, None), + ("timeframe", None, None), + ("stoploss", None, None), + ("trailing_stop", None, None), + ("trailing_stop_positive", None, None), + ("trailing_stop_positive_offset", 0.0, None), + ("trailing_only_offset_is_reached", None, None), + ("process_only_new_candles", None, None), + ("order_types", None, None), + ("order_time_in_force", None, None), + ("stake_currency", None, None), + ("stake_amount", None, None), + ("startup_candle_count", None, None), + ("unfilledtimeout", None, None), + ("use_sell_signal", True, 'ask_strategy'), + ("sell_profit_only", False, 'ask_strategy'), + ("ignore_roi_if_buy_signal", False, 'ask_strategy'), + ("disable_dataframe_checks", False, None), ] - for attribute, default, ask_strategy in attributes: - if ask_strategy: - StrategyResolver._override_attribute_helper(strategy, config['ask_strategy'], + for attribute, default, subkey in attributes: + if subkey: + StrategyResolver._override_attribute_helper(strategy, config.get(subkey, {}), attribute, default) else: StrategyResolver._override_attribute_helper(strategy, config, attribute, default) + # Assign deprecated variable - to not break users code relying on this. + strategy.ticker_interval = strategy.timeframe + # Loop this list again to have output combined - for attribute, _, exp in attributes: - if exp and attribute in config['ask_strategy']: - logger.info("Strategy using %s: %s", attribute, config['ask_strategy'][attribute]) + for attribute, _, subkey in attributes: + if subkey and attribute in config[subkey]: + logger.info("Strategy using %s: %s", attribute, config[subkey][attribute]) elif attribute in config: logger.info("Strategy using %s: %s", attribute, config[attribute]) diff --git a/freqtrade/rpc/api_server.py b/freqtrade/rpc/api_server.py index 0335bb151..06926ac35 100644 --- a/freqtrade/rpc/api_server.py +++ b/freqtrade/rpc/api_server.py @@ -2,15 +2,22 @@ import logging import threading from datetime import date, datetime from ipaddress import IPv4Address -from typing import Dict, Callable, Any +from typing import Any, Callable, Dict from arrow import Arrow from flask import Flask, jsonify, request from flask.json import JSONEncoder +from flask_cors import CORS +from flask_jwt_extended import (JWTManager, create_access_token, + create_refresh_token, get_jwt_identity, + jwt_refresh_token_required, + verify_jwt_in_request_optional) +from werkzeug.security import safe_str_cmp from werkzeug.serving import make_server from freqtrade.__init__ import __version__ from freqtrade.rpc.rpc import RPC, RPCException +from freqtrade.rpc.fiat_convert import CryptoToFiatConverter logger = logging.getLogger(__name__) @@ -38,9 +45,9 @@ class ArrowJSONEncoder(JSONEncoder): def require_login(func: Callable[[Any, Any], Any]): def func_wrapper(obj, *args, **kwargs): - + verify_jwt_in_request_optional() auth = request.authorization - if auth and obj.check_auth(auth.username, auth.password): + if get_jwt_identity() or auth and obj.check_auth(auth.username, auth.password): return func(obj, *args, **kwargs) else: return jsonify({"error": "Unauthorized"}), 401 @@ -49,7 +56,7 @@ def require_login(func: Callable[[Any, Any], Any]): # Type should really be Callable[[ApiServer], Any], but that will create a circular dependency -def rpc_catch_errors(func: Callable[[Any], Any]): +def rpc_catch_errors(func: Callable[..., Any]): def func_wrapper(obj, *args, **kwargs): @@ -70,8 +77,8 @@ class ApiServer(RPC): """ def check_auth(self, username, password): - return (username == self._config['api_server'].get('username') and - password == self._config['api_server'].get('password')) + return (safe_str_cmp(username, self._config['api_server'].get('username')) and + safe_str_cmp(password, self._config['api_server'].get('password'))) def __init__(self, freqtrade) -> None: """ @@ -83,11 +90,25 @@ class ApiServer(RPC): self._config = freqtrade.config self.app = Flask(__name__) + self._cors = CORS(self.app, + resources={r"/api/*": { + "supports_credentials": True, + "origins": self._config['api_server'].get('CORS_origins', [])}} + ) + + # Setup the Flask-JWT-Extended extension + self.app.config['JWT_SECRET_KEY'] = self._config['api_server'].get( + 'jwt_secret_key', 'super-secret') + + self.jwt = JWTManager(self.app) self.app.json_encoder = ArrowJSONEncoder # Register application handling self.register_rest_rpc_urls() + if self._config.get('fiat_display_currency', None): + self._fiat_converter = CryptoToFiatConverter() + thread = threading.Thread(target=self.run, daemon=True) thread.start() @@ -148,13 +169,17 @@ class ApiServer(RPC): self.app.register_error_handler(404, self.page_not_found) # Actions to control the bot + self.app.add_url_rule(f'{BASE_URI}/token/login', 'login', + view_func=self._token_login, methods=['POST']) + self.app.add_url_rule(f'{BASE_URI}/token/refresh', 'token_refresh', + view_func=self._token_refresh, methods=['POST']) self.app.add_url_rule(f'{BASE_URI}/start', 'start', view_func=self._start, methods=['POST']) self.app.add_url_rule(f'{BASE_URI}/stop', 'stop', view_func=self._stop, methods=['POST']) self.app.add_url_rule(f'{BASE_URI}/stopbuy', 'stopbuy', view_func=self._stopbuy, methods=['POST']) - self.app.add_url_rule(f'{BASE_URI}/reload_conf', 'reload_conf', - view_func=self._reload_conf, methods=['POST']) + self.app.add_url_rule(f'{BASE_URI}/reload_config', 'reload_config', + view_func=self._reload_config, methods=['POST']) # Info commands self.app.add_url_rule(f'{BASE_URI}/balance', 'balance', view_func=self._balance, methods=['GET']) @@ -175,6 +200,8 @@ class ApiServer(RPC): view_func=self._ping, methods=['GET']) self.app.add_url_rule(f'{BASE_URI}/trades', 'trades', view_func=self._trades, methods=['GET']) + self.app.add_url_rule(f'{BASE_URI}/trades/', 'trades_delete', + view_func=self._trades_delete, methods=['DELETE']) # Combined actions and infos self.app.add_url_rule(f'{BASE_URI}/blacklist', 'blacklist', view_func=self._blacklist, methods=['GET', 'POST']) @@ -199,6 +226,37 @@ class ApiServer(RPC): 'code': 404 }), 404 + @require_login + @rpc_catch_errors + def _token_login(self): + """ + Handler for /token/login + Returns a JWT token + """ + auth = request.authorization + if auth and self.check_auth(auth.username, auth.password): + keystuff = {'u': auth.username} + ret = { + 'access_token': create_access_token(identity=keystuff), + 'refresh_token': create_refresh_token(identity=keystuff), + } + return self.rest_dump(ret) + + return jsonify({"error": "Unauthorized"}), 401 + + @jwt_refresh_token_required + @rpc_catch_errors + def _token_refresh(self): + """ + Handler for /token/refresh + Returns a JWT token based on a JWT refresh token + """ + current_user = get_jwt_identity() + new_token = create_access_token(identity=current_user, fresh=False) + + ret = {'access_token': new_token} + return self.rest_dump(ret) + @require_login @rpc_catch_errors def _start(self): @@ -254,12 +312,12 @@ class ApiServer(RPC): @require_login @rpc_catch_errors - def _reload_conf(self): + def _reload_config(self): """ - Handler for /reload_conf. + Handler for /reload_config. Triggers a config file reload """ - msg = self._rpc_reload_conf() + msg = self._rpc_reload_config() return self.rest_dump(msg) @require_login @@ -310,7 +368,6 @@ class ApiServer(RPC): Returns a cumulative profit statistics :return: stats """ - logger.info("LocalRPC - Profit Command Called") stats = self._rpc_trade_statistics(self._config['stake_currency'], self._config.get('fiat_display_currency') @@ -327,8 +384,6 @@ class ApiServer(RPC): Returns a cumulative performance statistics :return: stats """ - logger.info("LocalRPC - performance Command Called") - stats = self._rpc_performance() return self.rest_dump(stats) @@ -371,6 +426,19 @@ class ApiServer(RPC): results = self._rpc_trade_history(limit) return self.rest_dump(results) + @require_login + @rpc_catch_errors + def _trades_delete(self, tradeid): + """ + Handler for DELETE /trades/ endpoint. + Removes the trade from the database (tries to cancel open orders first!) + get: + param: + tradeid: Numeric trade-id assigned to the trade. + """ + result = self._rpc_delete(tradeid) + return self.rest_dump(result) + @require_login @rpc_catch_errors def _whitelist(self): diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 8645e466e..8a1ff7e96 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -6,12 +6,14 @@ from abc import abstractmethod from datetime import date, datetime, timedelta from enum import Enum from math import isnan -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional, Tuple, Union import arrow from numpy import NAN, mean -from freqtrade.exceptions import DependencyException, TemporaryError +from freqtrade.exceptions import (ExchangeError, InvalidOrderException, + PricingError) +from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs from freqtrade.misc import shorten_date from freqtrade.persistence import Trade from freqtrade.rpc.fiat_convert import CryptoToFiatConverter @@ -94,15 +96,23 @@ class RPC: 'dry_run': config['dry_run'], 'stake_currency': config['stake_currency'], 'stake_amount': config['stake_amount'], + 'max_open_trades': config['max_open_trades'], 'minimal_roi': config['minimal_roi'].copy(), 'stoploss': config['stoploss'], 'trailing_stop': config['trailing_stop'], 'trailing_stop_positive': config.get('trailing_stop_positive'), 'trailing_stop_positive_offset': config.get('trailing_stop_positive_offset'), 'trailing_only_offset_is_reached': config.get('trailing_only_offset_is_reached'), - 'ticker_interval': config['ticker_interval'], + 'ticker_interval': config['timeframe'], # DEPRECATED + 'timeframe': config['timeframe'], + 'timeframe_ms': timeframe_to_msecs(config['timeframe']), + 'timeframe_min': timeframe_to_minutes(config['timeframe']), 'exchange': config['exchange']['name'], 'strategy': config['strategy'], + 'forcebuy_enabled': config.get('forcebuy_enable', False), + 'ask_strategy': config.get('ask_strategy', {}), + 'bid_strategy': config.get('bid_strategy', {}), + 'state': str(self._freqtrade.state) } return val @@ -120,21 +130,36 @@ class RPC: for trade in trades: order = None if trade.open_order_id: - order = self._freqtrade.exchange.get_order(trade.open_order_id, trade.pair) + order = self._freqtrade.exchange.fetch_order(trade.open_order_id, trade.pair) # calculate profit and send message to user try: current_rate = self._freqtrade.get_sell_rate(trade.pair, False) - except DependencyException: + except (ExchangeError, PricingError): current_rate = NAN current_profit = trade.calc_profit_ratio(current_rate) + current_profit_abs = trade.calc_profit(current_rate) + # Calculate guaranteed profit (in case of trailing stop) + stoploss_entry_dist = trade.calc_profit(trade.stop_loss) + stoploss_entry_dist_ratio = trade.calc_profit_ratio(trade.stop_loss) + # calculate distance to stoploss + stoploss_current_dist = trade.stop_loss - current_rate + stoploss_current_dist_ratio = stoploss_current_dist / current_rate + fmt_close_profit = (f'{round(trade.close_profit * 100, 2):.2f}%' - if trade.close_profit else None) + if trade.close_profit is not None else None) trade_dict = trade.to_json() trade_dict.update(dict( base_currency=self._freqtrade.config['stake_currency'], - close_profit=fmt_close_profit, + close_profit=trade.close_profit if trade.close_profit is not None else None, + close_profit_pct=fmt_close_profit, current_rate=current_rate, - current_profit=round(current_profit * 100, 2), + current_profit=current_profit, + current_profit_pct=round(current_profit * 100, 2), + current_profit_abs=current_profit_abs, + stoploss_current_dist=stoploss_current_dist, + stoploss_current_dist_ratio=round(stoploss_current_dist_ratio, 8), + stoploss_entry_dist=stoploss_entry_dist, + stoploss_entry_dist_ratio=round(stoploss_entry_dist_ratio, 8), open_order='({} {} rem={:.8f})'.format( order['type'], order['side'], order['remaining'] ) if order else None, @@ -153,7 +178,7 @@ class RPC: # calculate profit and send message to user try: current_rate = self._freqtrade.get_sell_rate(trade.pair, False) - except DependencyException: + except (PricingError, ExchangeError): current_rate = NAN trade_percent = (100 * trade.calc_profit_ratio(current_rate)) trade_profit = trade.calc_profit(current_rate) @@ -183,7 +208,7 @@ class RPC: def _rpc_daily_profit( self, timescale: int, - stake_currency: str, fiat_display_currency: str) -> List[List[Any]]: + stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]: today = datetime.utcnow().date() profit_days: Dict[date, Dict] = {} @@ -203,35 +228,34 @@ class RPC: 'trades': len(trades) } - return [ - [ - key, - '{value:.8f} {symbol}'.format( - value=float(value['amount']), - symbol=stake_currency - ), - '{value:.3f} {symbol}'.format( + data = [ + { + 'date': key, + 'abs_profit': f'{float(value["amount"]):.8f}', + 'fiat_value': '{value:.3f}'.format( value=self._fiat_converter.convert_amount( value['amount'], stake_currency, fiat_display_currency ) if self._fiat_converter else 0, - symbol=fiat_display_currency ), - '{value} trade{s}'.format( - value=value['trades'], - s='' if value['trades'] < 2 else 's' - ), - ] + 'trade_count': f'{value["trades"]}', + } for key, value in profit_days.items() ] + return { + 'stake_currency': stake_currency, + 'fiat_display_currency': fiat_display_currency, + 'data': data + } def _rpc_trade_history(self, limit: int) -> Dict: """ Returns the X last trades """ if limit > 0: - trades = Trade.get_trades().order_by(Trade.id.desc()).limit(limit) + trades = Trade.get_trades([Trade.is_open.is_(False)]).order_by( + Trade.id.desc()).limit(limit) else: - trades = Trade.get_trades().order_by(Trade.id.desc()).all() + trades = Trade.get_trades([Trade.is_open.is_(False)]).order_by(Trade.id.desc()).all() output = [trade.to_json() for trade in trades] @@ -250,6 +274,8 @@ class RPC: profit_closed_coin = [] profit_closed_ratio = [] durations = [] + winning_trades = 0 + losing_trades = 0 for trade in trades: current_rate: float = 0.0 @@ -263,11 +289,15 @@ class RPC: profit_ratio = trade.close_profit profit_closed_coin.append(trade.close_profit_abs) profit_closed_ratio.append(profit_ratio) + if trade.close_profit >= 0: + winning_trades += 1 + else: + losing_trades += 1 else: # Get current rate try: current_rate = self._freqtrade.get_sell_rate(trade.pair, False) - except DependencyException: + except (PricingError, ExchangeError): current_rate = NAN profit_ratio = trade.calc_profit_ratio(rate=current_rate) @@ -278,15 +308,11 @@ class RPC: best_pair = Trade.get_best_pair() - if not best_pair: - raise RPCException('no closed trade') - - bp_pair, bp_rate = best_pair - # Prepare data to display profit_closed_coin_sum = round(sum(profit_closed_coin), 8) - profit_closed_percent = (round(mean(profit_closed_ratio) * 100, 2) if profit_closed_ratio - else 0.0) + profit_closed_ratio_mean = mean(profit_closed_ratio) if profit_closed_ratio else 0.0 + profit_closed_ratio_sum = sum(profit_closed_ratio) if profit_closed_ratio else 0.0 + profit_closed_fiat = self._fiat_converter.convert_amount( profit_closed_coin_sum, stake_currency, @@ -294,27 +320,43 @@ class RPC: ) if self._fiat_converter else 0 profit_all_coin_sum = round(sum(profit_all_coin), 8) - profit_all_percent = round(mean(profit_all_ratio) * 100, 2) if profit_all_ratio else 0.0 + profit_all_ratio_mean = mean(profit_all_ratio) if profit_all_ratio else 0.0 + profit_all_ratio_sum = sum(profit_all_ratio) if profit_all_ratio else 0.0 profit_all_fiat = self._fiat_converter.convert_amount( profit_all_coin_sum, stake_currency, 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 num = float(len(durations) or 1) return { 'profit_closed_coin': profit_closed_coin_sum, - 'profit_closed_percent': profit_closed_percent, + 'profit_closed_percent': round(profit_closed_ratio_mean * 100, 2), # DEPRECATED + 'profit_closed_percent_mean': round(profit_closed_ratio_mean * 100, 2), + 'profit_closed_ratio_mean': profit_closed_ratio_mean, + 'profit_closed_percent_sum': round(profit_closed_ratio_sum * 100, 2), + 'profit_closed_ratio_sum': profit_closed_ratio_sum, 'profit_closed_fiat': profit_closed_fiat, 'profit_all_coin': profit_all_coin_sum, - 'profit_all_percent': profit_all_percent, + 'profit_all_percent': round(profit_all_ratio_mean * 100, 2), # DEPRECATED + 'profit_all_percent_mean': round(profit_all_ratio_mean * 100, 2), + 'profit_all_ratio_mean': profit_all_ratio_mean, + 'profit_all_percent_sum': round(profit_all_ratio_sum * 100, 2), + 'profit_all_ratio_sum': profit_all_ratio_sum, 'profit_all_fiat': profit_all_fiat, 'trade_count': len(trades), - 'first_trade_date': arrow.get(trades[0].open_date).humanize(), - 'latest_trade_date': arrow.get(trades[-1].open_date).humanize(), + 'closed_trade_count': len([t for t in trades if not t.is_open]), + 'first_trade_date': arrow.get(first_date).humanize() 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_timestamp': int(last_date.timestamp() * 1000) if last_date else 0, 'avg_duration': str(timedelta(seconds=sum(durations) / num)).split('.')[0], - 'best_pair': bp_pair, - 'best_rate': round(bp_rate * 100, 2), + 'best_pair': best_pair[0] if best_pair else '', + 'best_rate': round(best_pair[1] * 100, 2) if best_pair else 0, + 'winning_trades': winning_trades, + 'losing_trades': losing_trades, } def _rpc_balance(self, stake_currency: str, fiat_display_currency: str) -> Dict: @@ -323,7 +365,7 @@ class RPC: total = 0.0 try: tickers = self._freqtrade.exchange.get_tickers() - except (TemporaryError, DependencyException): + except (ExchangeError): raise RPCException('Error getting current tickers.') self._freqtrade.wallets.update(require_update=False) @@ -344,7 +386,7 @@ class RPC: if pair.startswith(stake_currency): rate = 1.0 / rate est_stake = rate * balance.total - except (TemporaryError, DependencyException): + except (ExchangeError): logger.warning(f" Could not get rate for pair {coin}.") continue total = total + (est_stake or 0) @@ -390,9 +432,9 @@ class RPC: return {'status': 'already stopped'} - def _rpc_reload_conf(self) -> Dict[str, str]: - """ Handler for reload_conf. """ - self._freqtrade.state = State.RELOAD_CONF + def _rpc_reload_config(self) -> Dict[str, str]: + """ Handler for reload_config. """ + self._freqtrade.state = State.RELOAD_CONFIG return {'status': 'reloading config ...'} def _rpc_stopbuy(self) -> Dict[str, str]: @@ -403,7 +445,7 @@ class RPC: # Set 'max_open_trades' to 0 self._freqtrade.config['max_open_trades'] = 0 - return {'status': 'No more buy will occur from now. Run /reload_conf to reset.'} + return {'status': 'No more buy will occur from now. Run /reload_config to reset.'} def _rpc_forcesell(self, trade_id: str) -> Dict[str, str]: """ @@ -413,7 +455,7 @@ class RPC: def _exec_forcesell(trade: Trade) -> None: # Check if there is there is an open order if trade.open_order_id: - order = self._freqtrade.exchange.get_order(trade.open_order_id, trade.pair) + order = self._freqtrade.exchange.fetch_order(trade.open_order_id, trade.pair) # Cancel open LIMIT_BUY orders and close trade if order and order['status'] == 'open' \ @@ -482,7 +524,7 @@ class RPC: # check if valid pair # check if pair already has an open pair - trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair.is_(pair)]).first() + trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair == pair]).first() if trade: raise RPCException(f'position for {pair} already open - id: {trade.id}') @@ -491,11 +533,51 @@ class RPC: # execute buy if self._freqtrade.execute_buy(pair, stakeamount, price): - trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair.is_(pair)]).first() + trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair == pair]).first() return trade else: return None + def _rpc_delete(self, trade_id: str) -> Dict[str, Union[str, int]]: + """ + Handler for delete . + Delete the given trade and close eventually existing open orders. + """ + with self._freqtrade._sell_lock: + c_count = 0 + trade = Trade.get_trades(trade_filter=[Trade.id == trade_id]).first() + if not trade: + logger.warning('delete trade: Invalid argument received') + raise RPCException('invalid argument') + + # Try cancelling regular order if that exists + if trade.open_order_id: + try: + self._freqtrade.exchange.cancel_order(trade.open_order_id, trade.pair) + c_count += 1 + except (ExchangeError, InvalidOrderException): + pass + + # cancel stoploss on exchange ... + if (self._freqtrade.strategy.order_types.get('stoploss_on_exchange') + and trade.stoploss_order_id): + try: + self._freqtrade.exchange.cancel_stoploss_order(trade.stoploss_order_id, + trade.pair) + c_count += 1 + except (ExchangeError, InvalidOrderException): + pass + + Trade.session.delete(trade) + Trade.session.flush() + self._freqtrade.wallets.update() + return { + 'result': 'success', + 'trade_id': trade_id, + 'result_msg': f'Deleted trade {trade_id}. Closed {c_count} open orders.', + 'cancel_order_count': c_count, + } + def _rpc_performance(self) -> List[Dict[str, Any]]: """ Handler for performance. @@ -528,21 +610,31 @@ class RPC: def _rpc_blacklist(self, add: List[str] = None) -> Dict: """ Returns the currently active blacklist""" + errors = {} if add: stake_currency = self._freqtrade.config.get('stake_currency') for pair in add: - if (self._freqtrade.exchange.get_pair_quote_currency(pair) == stake_currency - and pair not in self._freqtrade.pairlists.blacklist): - self._freqtrade.pairlists.blacklist.append(pair) + if self._freqtrade.exchange.get_pair_quote_currency(pair) == stake_currency: + if pair not in self._freqtrade.pairlists.blacklist: + self._freqtrade.pairlists.blacklist.append(pair) + else: + errors[pair] = { + 'error_msg': f'Pair {pair} already in pairlist.'} + + else: + errors[pair] = { + 'error_msg': f"Pair {pair} does not match stake currency." + } res = {'method': self._freqtrade.pairlists.name_list, 'length': len(self._freqtrade.pairlists.blacklist), 'blacklist': self._freqtrade.pairlists.blacklist, + 'errors': errors, } return res def _rpc_edge(self) -> List[Dict[str, Any]]: """ Returns information related to Edge """ if not self._freqtrade.edge: - raise RPCException(f'Edge is not enabled.') + raise RPCException('Edge is not enabled.') return self._freqtrade.edge.accepted_pairs() diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index 670275991..2cb44fec8 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -72,7 +72,7 @@ class RPCManager: minimal_roi = config['minimal_roi'] stoploss = config['stoploss'] trailing_stop = config['trailing_stop'] - ticker_interval = config['ticker_interval'] + timeframe = config['timeframe'] exchange_name = config['exchange']['name'] strategy_name = config.get('strategy', '') self.send_msg({ @@ -81,7 +81,7 @@ class RPCManager: f'*Stake per trade:* `{stake_amount} {stake_currency}`\n' f'*Minimum ROI:* `{minimal_roi}`\n' f'*{"Trailing " if trailing_stop else ""}Stoploss:* `{stoploss}`\n' - f'*Ticker Interval:* `{ticker_interval}`\n' + f'*Timeframe:* `{timeframe}`\n' f'*Strategy:* `{strategy_name}`' }) self.send_msg({ diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index a21f7556c..f1d3cde21 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -3,7 +3,9 @@ """ This module manage Telegram communication """ +import json import logging +import arrow from typing import Any, Callable, Dict from tabulate import tabulate @@ -19,7 +21,6 @@ logger = logging.getLogger(__name__) logger.debug('Included module rpc.telegram ...') - MAX_TELEGRAM_MESSAGE_LENGTH = 4096 @@ -29,6 +30,7 @@ def authorized_only(command_handler: Callable[..., None]) -> Callable[..., Any]: :param command_handler: Telegram CommandHandler :return: decorated function """ + def wrapper(self, *args, **kwargs): """ Decorator logic """ update = kwargs.get('update') or args[0] @@ -91,11 +93,13 @@ class Telegram(RPC): CommandHandler('stop', self._stop), CommandHandler('forcesell', self._forcesell), CommandHandler('forcebuy', self._forcebuy), + CommandHandler('trades', self._trades), + CommandHandler('delete', self._delete_trade), CommandHandler('performance', self._performance), CommandHandler('daily', self._daily), CommandHandler('count', self._count), - CommandHandler('reload_conf', self._reload_conf), - CommandHandler('show_config', self._show_config), + CommandHandler(['reload_config', 'reload_conf'], self._reload_config), + CommandHandler(['show_config', 'show_conf'], self._show_config), CommandHandler('stopbuy', self._stopbuy), CommandHandler('whitelist', self._whitelist), CommandHandler('blacklist', self._blacklist), @@ -133,7 +137,7 @@ class Telegram(RPC): else: msg['stake_amount_fiat'] = 0 - message = ("*{exchange}:* Buying {pair}\n" + message = ("\N{LARGE BLUE CIRCLE} *{exchange}:* Buying {pair}\n" "*Amount:* `{amount:.8f}`\n" "*Open Rate:* `{limit:.8f}`\n" "*Current Rate:* `{current_rate:.8f}`\n" @@ -144,7 +148,8 @@ class Telegram(RPC): message += ")`" elif msg['type'] == RPCMessageType.BUY_CANCEL_NOTIFICATION: - message = "*{exchange}:* Cancelling Open Buy Order for {pair}".format(**msg) + message = ("\N{WARNING SIGN} *{exchange}:* " + "Cancelling Open Buy Order for {pair}".format(**msg)) elif msg['type'] == RPCMessageType.SELL_NOTIFICATION: msg['amount'] = round(msg['amount'], 8) @@ -153,7 +158,9 @@ class Telegram(RPC): microsecond=0) - msg['open_date'].replace(microsecond=0) msg['duration_min'] = msg['duration'].total_seconds() / 60 - message = ("*{exchange}:* Selling {pair}\n" + msg['emoji'] = self._get_sell_emoji(msg) + + message = ("{emoji} *{exchange}:* Selling {pair}\n" "*Amount:* `{amount:.8f}`\n" "*Open Rate:* `{open_rate:.8f}`\n" "*Current Rate:* `{current_rate:.8f}`\n" @@ -165,21 +172,21 @@ class Telegram(RPC): # Check if all sell properties are available. # This might not be the case if the message origin is triggered by /forcesell if (all(prop in msg for prop in ['gain', 'fiat_currency', 'stake_currency']) - and self._fiat_converter): + and self._fiat_converter): msg['profit_fiat'] = self._fiat_converter.convert_amount( msg['profit_amount'], msg['stake_currency'], msg['fiat_currency']) message += (' `({gain}: {profit_amount:.8f} {stake_currency}' ' / {profit_fiat:.3f} {fiat_currency})`').format(**msg) elif msg['type'] == RPCMessageType.SELL_CANCEL_NOTIFICATION: - message = ("*{exchange}:* Cancelling Open Sell Order " + message = ("\N{WARNING SIGN} *{exchange}:* Cancelling Open Sell Order " "for {pair}. Reason: {reason}").format(**msg) elif msg['type'] == RPCMessageType.STATUS_NOTIFICATION: message = '*Status:* `{status}`'.format(**msg) elif msg['type'] == RPCMessageType.WARNING_NOTIFICATION: - message = '*Warning:* `{status}`'.format(**msg) + message = '\N{WARNING SIGN} *Warning:* `{status}`'.format(**msg) elif msg['type'] == RPCMessageType.CUSTOM_NOTIFICATION: message = '{status}'.format(**msg) @@ -189,6 +196,20 @@ class Telegram(RPC): self._send_msg(message) + def _get_sell_emoji(self, msg): + """ + Get emoji for sell-side + """ + + if float(msg['profit_percent']) >= 5.0: + return "\N{ROCKET}" + elif float(msg['profit_percent']) >= 0.0: + return "\N{EIGHT SPOKED ASTERISK}" + elif msg['sell_reason'] == "stop_loss": + return"\N{WARNING SIGN}" + else: + return "\N{CROSS MARK}" + @authorized_only def _status(self, update: Update, context: CallbackContext) -> None: """ @@ -215,22 +236,28 @@ class Telegram(RPC): "*Open Rate:* `{open_rate:.8f}`", "*Close Rate:* `{close_rate}`" if r['close_rate'] else "", "*Current Rate:* `{current_rate:.8f}`", - "*Close Profit:* `{close_profit}`" if r['close_profit'] else "", - "*Current Profit:* `{current_profit:.2f}%`", + ("*Close Profit:* `{close_profit_pct}`" + if r['close_profit_pct'] is not None else ""), + "*Current Profit:* `{current_profit_pct:.2f}%`", # Adding initial stoploss only if it is different from stoploss "*Initial Stoploss:* `{initial_stop_loss:.8f}` " + - ("`({initial_stop_loss_pct:.2f}%)`" if r['initial_stop_loss_pct'] else "") - if r['stop_loss'] != r['initial_stop_loss'] else "", + ("`({initial_stop_loss_pct:.2f}%)`") if ( + r['stop_loss'] != r['initial_stop_loss'] + and r['initial_stop_loss_pct'] is not None) else "", # Adding stoploss and stoploss percentage only if it is not None "*Stoploss:* `{stop_loss:.8f}` " + ("`({stop_loss_pct:.2f}%)`" if r['stop_loss_pct'] else ""), - - "*Open Order:* `{open_order}`" if r['open_order'] else "" ] + if r['open_order']: + if r['sell_order_status']: + lines.append("*Open Order:* `{open_order}` - `{sell_order_status}`") + else: + lines.append("*Open Order:* `{open_order}`") + # Filter empty lines using list-comprehension - messages.append("\n".join([l for l in lines if l]).format(**r)) + messages.append("\n".join([line for line in lines if line]).format(**r)) for msg in messages: self._send_msg(msg) @@ -276,14 +303,18 @@ class Telegram(RPC): stake_cur, fiat_disp_cur ) - stats_tab = tabulate(stats, - headers=[ - 'Day', - f'Profit {stake_cur}', - f'Profit {fiat_disp_cur}', - f'Trades' - ], - tablefmt='simple') + stats_tab = tabulate( + [[day['date'], + f"{day['abs_profit']} {stats['stake_currency']}", + f"{day['fiat_value']} {stats['fiat_display_currency']}", + f"{day['trade_count']} trades"] for day in stats['data']], + headers=[ + 'Day', + f'Profit {stake_cur}', + f'Profit {fiat_disp_cur}', + 'Trades', + ], + tablefmt='simple') message = f'Daily Profit over the last {timescale} days:\n
{stats_tab}
' self._send_msg(message, parse_mode=ParseMode.HTML) except RPCException as e: @@ -301,38 +332,50 @@ class Telegram(RPC): stake_cur = self._config['stake_currency'] fiat_disp_cur = self._config.get('fiat_display_currency', '') - try: - stats = self._rpc_trade_statistics( - stake_cur, - fiat_disp_cur) - profit_closed_coin = stats['profit_closed_coin'] - profit_closed_percent = stats['profit_closed_percent'] - profit_closed_fiat = stats['profit_closed_fiat'] - profit_all_coin = stats['profit_all_coin'] - 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'] - avg_duration = stats['avg_duration'] - best_pair = stats['best_pair'] - best_rate = stats['best_rate'] + stats = self._rpc_trade_statistics( + stake_cur, + fiat_disp_cur) + profit_closed_coin = stats['profit_closed_coin'] + profit_closed_percent_mean = stats['profit_closed_percent_mean'] + profit_closed_percent_sum = stats['profit_closed_percent_sum'] + profit_closed_fiat = stats['profit_closed_fiat'] + profit_all_coin = stats['profit_all_coin'] + profit_all_percent_mean = stats['profit_all_percent_mean'] + profit_all_percent_sum = stats['profit_all_percent_sum'] + 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'] + avg_duration = stats['avg_duration'] + best_pair = stats['best_pair'] + best_rate = stats['best_rate'] + if stats['trade_count'] == 0: + markdown_msg = 'No trades yet.' + else: # Message to display - markdown_msg = "*ROI:* Close trades\n" \ - f"∙ `{profit_closed_coin:.8f} {stake_cur} "\ - f"({profit_closed_percent:.2f}%)`\n" \ - f"∙ `{profit_closed_fiat:.3f} {fiat_disp_cur}`\n" \ - f"*ROI:* All trades\n" \ - f"∙ `{profit_all_coin:.8f} {stake_cur} ({profit_all_percent:.2f}%)`\n" \ - f"∙ `{profit_all_fiat:.3f} {fiat_disp_cur}`\n" \ - f"*Total Trade Count:* `{trade_count}`\n" \ - f"*First Trade opened:* `{first_trade_date}`\n" \ - f"*Latest Trade opened:* `{latest_trade_date}`\n" \ - f"*Avg. Duration:* `{avg_duration}`\n" \ - f"*Best Performing:* `{best_pair}: {best_rate:.2f}%`" - self._send_msg(markdown_msg) - except RPCException as e: - self._send_msg(str(e)) + if stats['closed_trade_count'] > 0: + markdown_msg = ("*ROI:* Closed trades\n" + f"∙ `{profit_closed_coin:.8f} {stake_cur} " + f"({profit_closed_percent_mean:.2f}%) " + f"({profit_closed_percent_sum} \N{GREEK CAPITAL LETTER SIGMA}%)`\n" + f"∙ `{profit_closed_fiat:.3f} {fiat_disp_cur}`\n") + else: + markdown_msg = "`No closed trade` \n" + + markdown_msg += (f"*ROI:* All trades\n" + f"∙ `{profit_all_coin:.8f} {stake_cur} " + f"({profit_all_percent_mean:.2f}%) " + f"({profit_all_percent_sum} \N{GREEK CAPITAL LETTER SIGMA}%)`\n" + f"∙ `{profit_all_fiat:.3f} {fiat_disp_cur}`\n" + f"*Total Trade Count:* `{trade_count}`\n" + f"*First Trade opened:* `{first_trade_date}`\n" + f"*Latest Trade opened:* `{latest_trade_date}\n`" + f"*Win / Loss:* `{stats['winning_trades']} / {stats['losing_trades']}`" + ) + if stats['closed_trade_count'] > 0: + markdown_msg += (f"\n*Avg. Duration:* `{avg_duration}`\n" + f"*Best Performing:* `{best_pair}: {best_rate:.2f}%`") + self._send_msg(markdown_msg) @authorized_only def _balance(self, update: Update, context: CallbackContext) -> None: @@ -348,14 +391,14 @@ class Telegram(RPC): "This mode is still experimental!\n" "Starting capital: " f"`{self._config['dry_run_wallet']}` {self._config['stake_currency']}.\n" - ) + ) for currency in result['currencies']: if currency['est_stake'] > 0.0001: - curr_output = "*{currency}:*\n" \ - "\t`Available: {free: .8f}`\n" \ - "\t`Balance: {balance: .8f}`\n" \ - "\t`Pending: {used: .8f}`\n" \ - "\t`Est. {stake}: {est_stake: .8f}`\n".format(**currency) + curr_output = ("*{currency}:*\n" + "\t`Available: {free: .8f}`\n" + "\t`Balance: {balance: .8f}`\n" + "\t`Pending: {used: .8f}`\n" + "\t`Est. {stake}: {est_stake: .8f}`\n").format(**currency) else: curr_output = "*{currency}:* not showing <1$ amount \n".format(**currency) @@ -366,9 +409,9 @@ class Telegram(RPC): else: output += curr_output - output += "\n*Estimated Value*:\n" \ - "\t`{stake}: {total: .8f}`\n" \ - "\t`{symbol}: {value: .2f}`\n".format(**result) + output += ("\n*Estimated Value*:\n" + "\t`{stake}: {total: .8f}`\n" + "\t`{symbol}: {value: .2f}`\n").format(**result) self._send_msg(output) except RPCException as e: self._send_msg(str(e)) @@ -398,15 +441,15 @@ class Telegram(RPC): self._send_msg('Status: `{status}`'.format(**msg)) @authorized_only - def _reload_conf(self, update: Update, context: CallbackContext) -> None: + def _reload_config(self, update: Update, context: CallbackContext) -> None: """ - Handler for /reload_conf. + Handler for /reload_config. Triggers a config file reload :param bot: telegram bot :param update: message update :return: None """ - msg = self._rpc_reload_conf() + msg = self._rpc_reload_config() self._send_msg('Status: `{status}`'.format(**msg)) @authorized_only @@ -456,6 +499,62 @@ class Telegram(RPC): except RPCException as e: self._send_msg(str(e)) + @authorized_only + def _trades(self, update: Update, context: CallbackContext) -> None: + """ + Handler for /trades + Returns last n recent trades. + :param bot: telegram bot + :param update: message update + :return: None + """ + stake_cur = self._config['stake_currency'] + try: + nrecent = int(context.args[0]) + except (TypeError, ValueError, IndexError): + nrecent = 10 + try: + trades = self._rpc_trade_history( + nrecent + ) + trades_tab = tabulate( + [[arrow.get(trade['open_date']).humanize(), + trade['pair'], + f"{(100 * trade['close_profit']):.2f}% ({trade['close_profit_abs']})"] + for trade in trades['trades']], + headers=[ + 'Open Date', + 'Pair', + f'Profit ({stake_cur})', + ], + 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) + except RPCException as e: + self._send_msg(str(e)) + + @authorized_only + def _delete_trade(self, update: Update, context: CallbackContext) -> None: + """ + Handler for /delete . + Delete the given trade + :param bot: telegram bot + :param update: message update + :return: None + """ + + trade_id = context.args[0] if len(context.args) > 0 else None + try: + msg = self._rpc_delete(trade_id) + self._send_msg(( + '`{result_msg}`\n' + 'Please make sure to take care of this asset on the exchange manually.' + ).format(**msg)) + + except RPCException as e: + self._send_msg(str(e)) + @authorized_only def _performance(self, update: Update, context: CallbackContext) -> None: """ @@ -524,6 +623,11 @@ class Telegram(RPC): try: blacklist = self._rpc_blacklist(context.args) + errmsgs = [] + for pair, error in blacklist['errors'].items(): + errmsgs.append(f"Error adding `{pair}` to blacklist: `{error['error_msg']}`") + if errmsgs: + self._send_msg('\n'.join(errmsgs)) message = f"Blacklist contains {blacklist['length']} pairs\n" message += f"`{', '.join(blacklist['blacklist'])}`" @@ -556,32 +660,34 @@ class Telegram(RPC): :param update: message update :return: None """ - forcebuy_text = "*/forcebuy []:* `Instantly buys the given pair. " \ - "Optionally takes a rate at which to buy.` \n" - message = "*/start:* `Starts the trader`\n" \ - "*/stop:* `Stops the trader`\n" \ - "*/status [table]:* `Lists all open trades`\n" \ - " *table :* `will display trades in a table`\n" \ - " `pending buy orders are marked with an asterisk (*)`\n" \ - " `pending sell orders are marked with a double asterisk (**)`\n" \ - "*/profit:* `Lists cumulative profit from all finished trades`\n" \ - "*/forcesell |all:* `Instantly sells the given trade or all trades, " \ - "regardless of profit`\n" \ - f"{forcebuy_text if self._config.get('forcebuy_enable', False) else '' }" \ - "*/performance:* `Show performance of each finished trade grouped by pair`\n" \ - "*/daily :* `Shows profit or loss per day, over the last n days`\n" \ - "*/count:* `Show number of trades running compared to allowed number of trades`" \ - "\n" \ - "*/balance:* `Show account balance per currency`\n" \ - "*/stopbuy:* `Stops buying, but handles open trades gracefully` \n" \ - "*/reload_conf:* `Reload configuration file` \n" \ - "*/show_config:* `Show running configuration` \n" \ - "*/whitelist:* `Show current whitelist` \n" \ - "*/blacklist [pair]:* `Show current blacklist, or adds one or more pairs " \ - "to the blacklist.` \n" \ - "*/edge:* `Shows validated pairs by Edge if it is enabeld` \n" \ - "*/help:* `This help message`\n" \ - "*/version:* `Show version`" + forcebuy_text = ("*/forcebuy []:* `Instantly buys the given pair. " + "Optionally takes a rate at which to buy.` \n") + message = ("*/start:* `Starts the trader`\n" + "*/stop:* `Stops the trader`\n" + "*/status [table]:* `Lists all open trades`\n" + " *table :* `will display trades in a table`\n" + " `pending buy orders are marked with an asterisk (*)`\n" + " `pending sell orders are marked with a double asterisk (**)`\n" + "*/trades [limit]:* `Lists last closed trades (limited to 10 by default)`\n" + "*/profit:* `Lists cumulative profit from all finished trades`\n" + "*/forcesell |all:* `Instantly sells the given trade or all trades, " + "regardless of profit`\n" + f"{forcebuy_text if self._config.get('forcebuy_enable', False) else ''}" + "*/delete :* `Instantly delete the given trade in the database`\n" + "*/performance:* `Show performance of each finished trade grouped by pair`\n" + "*/daily :* `Shows profit or loss per day, over the last n days`\n" + "*/count:* `Show number of trades running compared to allowed number of trades`" + "\n" + "*/balance:* `Show account balance per currency`\n" + "*/stopbuy:* `Stops buying, but handles open trades gracefully` \n" + "*/reload_config:* `Reload configuration file` \n" + "*/show_config:* `Show running configuration` \n" + "*/whitelist:* `Show current whitelist` \n" + "*/blacklist [pair]:* `Show current blacklist, or adds one or more pairs " + "to the blacklist.` \n" + "*/edge:* `Shows validated pairs by Edge if it is enabled` \n" + "*/help:* `This help message`\n" + "*/version:* `Show version`") self._send_msg(message) @@ -621,10 +727,14 @@ class Telegram(RPC): f"*Mode:* `{'Dry-run' if val['dry_run'] else 'Live'}`\n" f"*Exchange:* `{val['exchange']}`\n" f"*Stake per trade:* `{val['stake_amount']} {val['stake_currency']}`\n" + f"*Max open Trades:* `{val['max_open_trades']}`\n" f"*Minimum ROI:* `{val['minimal_roi']}`\n" + f"*Ask strategy:* ```\n{json.dumps(val['ask_strategy'])}```\n" + f"*Bid strategy:* ```\n{json.dumps(val['bid_strategy'])}```\n" f"{sl_info}" - f"*Ticker Interval:* `{val['ticker_interval']}`\n" - f"*Strategy:* `{val['strategy']}`" + f"*Timeframe:* `{val['timeframe']}`\n" + f"*Strategy:* `{val['strategy']}`\n" + f"*Current state:* `{val['state']}`" ) def _send_msg(self, msg: str, parse_mode: ParseMode = ParseMode.MARKDOWN) -> None: diff --git a/freqtrade/rpc/webhook.py b/freqtrade/rpc/webhook.py index 1309663d4..322d990ee 100644 --- a/freqtrade/rpc/webhook.py +++ b/freqtrade/rpc/webhook.py @@ -47,9 +47,9 @@ class Webhook(RPC): valuedict = self._config['webhook'].get('webhooksell', None) elif msg['type'] == RPCMessageType.SELL_CANCEL_NOTIFICATION: valuedict = self._config['webhook'].get('webhooksellcancel', None) - elif msg['type'] in(RPCMessageType.STATUS_NOTIFICATION, - RPCMessageType.CUSTOM_NOTIFICATION, - RPCMessageType.WARNING_NOTIFICATION): + elif msg['type'] in (RPCMessageType.STATUS_NOTIFICATION, + RPCMessageType.CUSTOM_NOTIFICATION, + RPCMessageType.WARNING_NOTIFICATION): valuedict = self._config['webhook'].get('webhookstatus', None) else: raise NotImplementedError('Unknown message type: {}'.format(msg['type'])) diff --git a/freqtrade/state.py b/freqtrade/state.py index 415f6f5f2..8ddff71d9 100644 --- a/freqtrade/state.py +++ b/freqtrade/state.py @@ -12,7 +12,10 @@ class State(Enum): """ RUNNING = 1 STOPPED = 2 - RELOAD_CONF = 3 + RELOAD_CONFIG = 3 + + def __str__(self): + return f"{self.name.lower()}" class RunMode(Enum): diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index c6f711b74..f3c5e154d 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -3,21 +3,22 @@ IStrategy interface This module defines the interface to apply for strategies """ import logging +import warnings from abc import ABC, abstractmethod from datetime import datetime, timezone from enum import Enum from typing import Dict, List, NamedTuple, Optional, Tuple -import warnings import arrow from pandas import DataFrame +from freqtrade.constants import ListPairsWithTimeframes from freqtrade.data.dataprovider import DataProvider +from freqtrade.exceptions import StrategyError, OperationalException from freqtrade.exchange import timeframe_to_minutes from freqtrade.persistence import Trade +from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper from freqtrade.wallets import Wallets -from freqtrade.exceptions import DependencyException - logger = logging.getLogger(__name__) @@ -60,7 +61,7 @@ class IStrategy(ABC): Attributes you can use: minimal_roi -> Dict: Minimal ROI designed for the strategy stoploss -> float: optimal stoploss designed for the strategy - ticker_interval -> str: value of the timeframe (ticker interval) to use with the strategy + timeframe -> str: value of the timeframe (ticker interval) to use with the strategy """ # Strategy interface version # Default to version 2 @@ -83,8 +84,9 @@ class IStrategy(ABC): trailing_stop_positive_offset: float = 0.0 trailing_only_offset_is_reached = False - # associated ticker interval - ticker_interval: str + # associated timeframe + ticker_interval: str # DEPRECATED + timeframe: str # Optional order types order_types: Dict = { @@ -104,6 +106,9 @@ class IStrategy(ABC): # run "populate_indicators" only for new candle process_only_new_candles: bool = False + # Disable checking the dataframe (converts the error into a warning message) + disable_dataframe_checks: bool = False + # Count of candles the strategy requires before producing valid signals startup_candle_count: int = 0 @@ -149,7 +154,100 @@ class IStrategy(ABC): :return: DataFrame with sell column """ - def informative_pairs(self) -> List[Tuple[str, str]]: + def check_buy_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool: + """ + Check buy timeout function callback. + This method can be used to override the buy-timeout. + It is called whenever a limit buy order has been created, + and is not yet fully filled. + Configuration options in `unfilledtimeout` will be verified before this, + so ensure to set these timeouts high enough. + + When not implemented by a strategy, this simply returns False. + :param pair: Pair the trade is for + :param trade: trade object. + :param order: Order dictionary as returned from CCXT. + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return bool: When True is returned, then the buy-order is cancelled. + """ + return False + + def check_sell_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool: + """ + Check sell timeout function callback. + This method can be used to override the sell-timeout. + It is called whenever a limit sell order has been created, + and is not yet fully filled. + Configuration options in `unfilledtimeout` will be verified before this, + so ensure to set these timeouts high enough. + + When not implemented by a strategy, this simply returns False. + :param pair: Pair the trade is for + :param trade: trade object. + :param order: Order dictionary as returned from CCXT. + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return bool: When True is returned, then the sell-order is cancelled. + """ + return False + + def bot_loop_start(self, **kwargs) -> None: + """ + Called at the start of the bot iteration (one loop). + Might be used to perform pair-independent tasks + (e.g. gather some remote resource for comparison) + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + """ + pass + + def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float, + time_in_force: str, **kwargs) -> bool: + """ + Called right before placing a buy order. + Timing for this function is critical, so avoid doing heavy computations or + network requests in this method. + + For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ + + When not implemented by a strategy, returns True (always confirming). + + :param pair: Pair that's about to be bought. + :param order_type: Order type (as configured in order_types). usually limit or market. + :param amount: Amount in target (quote) currency that's going to be traded. + :param rate: Rate that's going to be used when using limit orders + :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return bool: When True is returned, then the buy-order is placed on the exchange. + False aborts the process + """ + return True + + def confirm_trade_exit(self, pair: str, trade: Trade, order_type: str, amount: float, + rate: float, time_in_force: str, sell_reason: str, **kwargs) -> bool: + """ + Called right before placing a regular sell order. + Timing for this function is critical, so avoid doing heavy computations or + network requests in this method. + + For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ + + When not implemented by a strategy, returns True (always confirming). + + :param pair: Pair that's about to be sold. + :param trade: trade object. + :param order_type: Order type (as configured in order_types). usually limit or market. + :param amount: Amount in quote currency. + :param rate: Rate that's going to be used when using limit orders + :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). + :param sell_reason: Sell reason. + Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss', + 'sell_signal', 'force_sell', 'emergency_sell'] + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return bool: When True is returned, then the sell-order is placed on the exchange. + False aborts the process + """ + return True + + def informative_pairs(self) -> ListPairsWithTimeframes: """ Define additional, informative pair/interval combinations to be cached from the exchange. These pair/interval combinations are non-tradeable, unless they are part @@ -162,6 +260,10 @@ class IStrategy(ABC): """ return [] +### +# END - Intended to be overridden by strategy +### + def get_strategy_name(self) -> str: """ Returns strategy class name @@ -231,6 +333,8 @@ class IStrategy(ABC): # Defs that only make change on new candle data. dataframe = self.analyze_ticker(dataframe, metadata) self._last_candle_seen_per_pair[pair] = dataframe.iloc[-1]['date'] + if self.dp: + self.dp._set_cached_df(pair, self.timeframe, dataframe) else: logger.debug("Skipping TA Analysis for already analyzed candle") dataframe['buy'] = 0 @@ -242,14 +346,53 @@ class IStrategy(ABC): return dataframe + def analyze_pair(self, pair: str) -> None: + """ + Fetch data for this pair from dataprovider and analyze. + Stores the dataframe into the dataprovider. + The analyzed dataframe is then accessible via `dp.get_analyzed_dataframe()`. + :param pair: Pair to analyze. + """ + if not self.dp: + raise OperationalException("DataProvider not found.") + dataframe = self.dp.ohlcv(pair, self.timeframe) + if not isinstance(dataframe, DataFrame) or dataframe.empty: + logger.warning('Empty candle (OHLCV) data for pair %s', pair) + return + + try: + df_len, df_close, df_date = self.preserve_df(dataframe) + + dataframe = strategy_safe_wrapper( + self._analyze_ticker_internal, message="" + )(dataframe, {'pair': pair}) + + self.assert_df(dataframe, df_len, df_close, df_date) + except StrategyError as error: + logger.warning(f"Unable to analyze candle (OHLCV) data for pair {pair}: {error}") + return + + if dataframe.empty: + logger.warning('Empty dataframe for pair %s', pair) + return + + def analyze(self, pairs: List[str]) -> None: + """ + Analyze all pairs using analyze_pair(). + :param pairs: List of pairs to analyze + """ + for pair in pairs: + self.analyze_pair(pair) + @staticmethod def preserve_df(dataframe: DataFrame) -> Tuple[int, float, datetime]: """ keep some data for dataframes """ return len(dataframe), dataframe["close"].iloc[-1], dataframe["date"].iloc[-1] - @staticmethod - def assert_df(dataframe: DataFrame, df_len: int, df_close: float, df_date: datetime): - """ make sure data is unmodified """ + def assert_df(self, dataframe: DataFrame, df_len: int, df_close: float, df_date: datetime): + """ + Ensure dataframe (length, last candle) was not modified, and has all elements we need. + """ message = "" if df_len != len(dataframe): message = "length" @@ -258,68 +401,42 @@ class IStrategy(ABC): elif df_date != dataframe["date"].iloc[-1]: message = "last date" if message: - raise DependencyException("Dataframe returned from strategy has mismatching " - f"{message}.") + if self.disable_dataframe_checks: + logger.warning(f"Dataframe returned from strategy has mismatching {message}.") + else: + raise StrategyError(f"Dataframe returned from strategy has mismatching {message}.") - def get_signal(self, pair: str, interval: str, dataframe: DataFrame) -> Tuple[bool, bool]: + def get_signal(self, pair: str, timeframe: str, dataframe: DataFrame) -> Tuple[bool, bool]: """ - Calculates current signal based several technical analysis indicators + Calculates current signal based based on the buy / sell columns of the dataframe. + Used by Bot to get the signal to buy or sell :param pair: pair in format ANT/BTC - :param interval: Interval to use (in min) - :param dataframe: Dataframe to analyze + :param timeframe: timeframe to use + :param dataframe: Analyzed dataframe to get signal from. :return: (Buy, Sell) A bool-tuple indicating buy/sell signal """ if not isinstance(dataframe, DataFrame) or dataframe.empty: - logger.warning('Empty candle (OHLCV) data for pair %s', pair) + logger.warning(f'Empty candle (OHLCV) data for pair {pair}') return False, False latest_date = dataframe['date'].max() - try: - df_len, df_close, df_date = self.preserve_df(dataframe) - dataframe = self._analyze_ticker_internal(dataframe, {'pair': pair}) - self.assert_df(dataframe, df_len, df_close, df_date) - except ValueError as error: - logger.warning('Unable to analyze candle (OHLCV) data for pair %s: %s', - pair, str(error)) - return False, False - except DependencyException as error: - logger.warning("Unable to analyze candle (OHLCV) data for pair %s: %s", - pair, str(error)) - return False, False - except Exception as error: - logger.exception( - 'Unexpected error when analyzing candle (OHLCV) data for pair %s: %s', - pair, - str(error) - ) - return False, False - - if dataframe.empty: - logger.warning('Empty dataframe for pair %s', pair) - return False, False - 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) # Check if dataframe is out of date - signal_date = arrow.get(latest['date']) - interval_minutes = timeframe_to_minutes(interval) + timeframe_minutes = timeframe_to_minutes(timeframe) offset = self.config.get('exchange', {}).get('outdated_offset', 5) - if signal_date < (arrow.utcnow().shift(minutes=-(interval_minutes * 2 + offset))): + if latest_date < (arrow.utcnow().shift(minutes=-(timeframe_minutes * 2 + offset))): logger.warning( 'Outdated history for pair %s. Last tick is %s minutes old', - pair, - (arrow.utcnow() - signal_date).seconds // 60 + pair, (arrow.utcnow() - latest_date).seconds // 60 ) return False, False (buy, sell) = latest[SignalType.BUY.value] == 1, latest[SignalType.SELL.value] == 1 - logger.debug( - 'trigger: %s (pair=%s) buy=%s sell=%s', - latest['date'], - pair, - str(buy), - str(sell) - ) + logger.debug('trigger: %s (pair=%s) buy=%s sell=%s', + latest['date'], pair, str(buy), str(sell)) return buy, sell def should_sell(self, trade: Trade, rate: float, date: datetime, buy: bool, @@ -465,7 +582,8 @@ class IStrategy(ABC): def ohlcvdata_to_dataframe(self, data: Dict[str, DataFrame]) -> Dict[str, DataFrame]: """ - Creates a dataframe and populates indicators for given candle (OHLCV) data + Populates indicators for given candle (OHLCV) data (for multiple pairs) + Does not run advice_buy or advise_sell! Used by optimize operations only, not during dry / live runs. Using .copy() to get a fresh copy of the dataframe for every strategy run. Has positive effects on memory usage for whatever reason - also when diff --git a/freqtrade/strategy/strategy_wrapper.py b/freqtrade/strategy/strategy_wrapper.py new file mode 100644 index 000000000..8fc548074 --- /dev/null +++ b/freqtrade/strategy/strategy_wrapper.py @@ -0,0 +1,35 @@ +import logging + +from freqtrade.exceptions import StrategyError + +logger = logging.getLogger(__name__) + + +def strategy_safe_wrapper(f, message: str = "", default_retval=None, supress_error=False): + """ + Wrapper around user-provided methods and functions. + Caches all exceptions and returns either the default_retval (if it's not None) or raises + a StrategyError exception, which then needs to be handled by the calling method. + """ + def wrapper(*args, **kwargs): + try: + return f(*args, **kwargs) + except ValueError as error: + logger.warning( + f"{message}" + f"Strategy caused the following exception: {error}" + f"{f}" + ) + if default_retval is None and not supress_error: + raise StrategyError(str(error)) from error + return default_retval + except Exception as error: + logger.exception( + f"{message}" + f"Unexpected error {error} calling {f}" + ) + if default_retval is None and not supress_error: + raise StrategyError(str(error)) from error + return default_retval + + return wrapper diff --git a/freqtrade/templates/base_config.json.j2 b/freqtrade/templates/base_config.json.j2 index 134719273..b362690f9 100644 --- a/freqtrade/templates/base_config.json.j2 +++ b/freqtrade/templates/base_config.json.j2 @@ -4,8 +4,9 @@ "stake_amount": {{ stake_amount }}, "tradable_balance_ratio": 0.99, "fiat_display_currency": "{{ fiat_display_currency }}", - "ticker_interval": "{{ ticker_interval }}", + "timeframe": "{{ timeframe }}", "dry_run": {{ dry_run | lower }}, + "cancel_open_orders_on_exit": false, "unfilledtimeout": { "buy": 10, "sell": 30 @@ -52,6 +53,16 @@ "token": "{{ telegram_token }}", "chat_id": "{{ telegram_chat_id }}" }, + "api_server": { + "enabled": false, + "listen_ip_address": "127.0.0.1", + "listen_port": 8080, + "verbosity": "info", + "jwt_secret_key": "somethingrandom", + "CORS_origins": [], + "username": "", + "password": "" + }, "initial_state": "running", "forcebuy_enable": false, "internals": { diff --git a/freqtrade/templates/base_strategy.py.j2 b/freqtrade/templates/base_strategy.py.j2 index 97a189ff4..ce2c6d5c0 100644 --- a/freqtrade/templates/base_strategy.py.j2 +++ b/freqtrade/templates/base_strategy.py.j2 @@ -51,8 +51,8 @@ class {{ strategy }}(IStrategy): # trailing_stop_positive = 0.01 # trailing_stop_positive_offset = 0.0 # Disabled / not configured - # Optimal ticker interval for the strategy. - ticker_interval = '5m' + # Optimal timeframe for the strategy. + timeframe = '5m' # Run "populate_indicators()" only for new candle. process_only_new_candles = False @@ -137,3 +137,4 @@ class {{ strategy }}(IStrategy): ), 'sell'] = 1 return dataframe + {{ additional_methods | indent(4) }} diff --git a/freqtrade/templates/sample_strategy.py b/freqtrade/templates/sample_strategy.py index f78489173..e269848d2 100644 --- a/freqtrade/templates/sample_strategy.py +++ b/freqtrade/templates/sample_strategy.py @@ -53,7 +53,7 @@ class SampleStrategy(IStrategy): # trailing_stop_positive_offset = 0.0 # Disabled / not configured # Optimal ticker interval for the strategy. - ticker_interval = '5m' + timeframe = '5m' # Run "populate_indicators()" only for new candle. process_only_new_candles = False diff --git a/freqtrade/templates/subtemplates/strategy_methods_advanced.j2 b/freqtrade/templates/subtemplates/strategy_methods_advanced.j2 new file mode 100644 index 000000000..5ca6e6971 --- /dev/null +++ b/freqtrade/templates/subtemplates/strategy_methods_advanced.j2 @@ -0,0 +1,101 @@ + +def bot_loop_start(self, **kwargs) -> None: + """ + Called at the start of the bot iteration (one loop). + Might be used to perform pair-independent tasks + (e.g. gather some remote ressource for comparison) + + For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ + + When not implemented by a strategy, this simply does nothing. + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + """ + pass + +def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float, + time_in_force: str, **kwargs) -> bool: + """ + Called right before placing a buy order. + Timing for this function is critical, so avoid doing heavy computations or + network requests in this method. + + For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ + + When not implemented by a strategy, returns True (always confirming). + + :param pair: Pair that's about to be bought. + :param order_type: Order type (as configured in order_types). usually limit or market. + :param amount: Amount in target (quote) currency that's going to be traded. + :param rate: Rate that's going to be used when using limit orders + :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return bool: When True is returned, then the buy-order is placed on the exchange. + False aborts the process + """ + return True + +def confirm_trade_exit(self, pair: str, trade: 'Trade', order_type: str, amount: float, + rate: float, time_in_force: str, sell_reason: str, **kwargs) -> bool: + """ + Called right before placing a regular sell order. + Timing for this function is critical, so avoid doing heavy computations or + network requests in this method. + + For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ + + When not implemented by a strategy, returns True (always confirming). + + :param pair: Pair that's about to be sold. + :param trade: trade object. + :param order_type: Order type (as configured in order_types). usually limit or market. + :param amount: Amount in quote currency. + :param rate: Rate that's going to be used when using limit orders + :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). + :param sell_reason: Sell reason. + Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss', + 'sell_signal', 'force_sell', 'emergency_sell'] + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return bool: When True is returned, then the sell-order is placed on the exchange. + False aborts the process + """ + return True + +def check_buy_timeout(self, pair: str, trade: 'Trade', order: dict, **kwargs) -> bool: + """ + Check buy timeout function callback. + This method can be used to override the buy-timeout. + It is called whenever a limit buy order has been created, + and is not yet fully filled. + Configuration options in `unfilledtimeout` will be verified before this, + so ensure to set these timeouts high enough. + + For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ + + When not implemented by a strategy, this simply returns False. + :param pair: Pair the trade is for + :param trade: trade object. + :param order: Order dictionary as returned from CCXT. + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return bool: When True is returned, then the buy-order is cancelled. + """ + return False + +def check_sell_timeout(self, pair: str, trade: 'Trade', order: dict, **kwargs) -> bool: + """ + Check sell timeout function callback. + This method can be used to override the sell-timeout. + It is called whenever a limit sell order has been created, + and is not yet fully filled. + Configuration options in `unfilledtimeout` will be verified before this, + so ensure to set these timeouts high enough. + + For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ + + When not implemented by a strategy, this simply returns False. + :param pair: Pair the trade is for + :param trade: trade object. + :param order: Order dictionary as returned from CCXT. + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return bool: When True is returned, then the sell-order is cancelled. + """ + return False diff --git a/freqtrade/templates/subtemplates/strategy_methods_empty.j2 b/freqtrade/templates/subtemplates/strategy_methods_empty.j2 new file mode 100644 index 000000000..e69de29bb diff --git a/freqtrade/worker.py b/freqtrade/worker.py index 4c28ecaeb..2fc206bd5 100755 --- a/freqtrade/worker.py +++ b/freqtrade/worker.py @@ -37,9 +37,7 @@ class Worker: self._heartbeat_msg: float = 0 # Tell systemd that we completed initialization phase - if self._sd_notify: - logger.debug("sd_notify: READY=1") - self._sd_notify.notify("READY=1") + self._notify("READY=1") def _init(self, reconfig: bool) -> None: """ @@ -60,11 +58,20 @@ class Worker: self._sd_notify = sdnotify.SystemdNotifier() if \ self._config.get('internals', {}).get('sd_notify', False) else None + def _notify(self, message: str) -> None: + """ + Removes the need to verify in all occurances if sd_notify is enabled + :param message: Message to send to systemd if it's enabled. + """ + if self._sd_notify: + logger.debug(f"sd_notify: {message}") + self._sd_notify.notify(message) + def run(self) -> None: state = None while True: state = self._worker(old_state=state) - if state == State.RELOAD_CONF: + if state == State.RELOAD_CONFIG: self._reconfigure() def _worker(self, old_state: Optional[State]) -> State: @@ -83,23 +90,22 @@ class Worker: if state == State.RUNNING: self.freqtrade.startup() + if state == State.STOPPED: + self.freqtrade.check_for_open_trades() + # Reset heartbeat timestamp to log the heartbeat message at # first throttling iteration when the state changes self._heartbeat_msg = 0 if state == State.STOPPED: # Ping systemd watchdog before sleeping in the stopped state - if self._sd_notify: - logger.debug("sd_notify: WATCHDOG=1\\nSTATUS=State: STOPPED.") - self._sd_notify.notify("WATCHDOG=1\nSTATUS=State: STOPPED.") + self._notify("WATCHDOG=1\nSTATUS=State: STOPPED.") self._throttle(func=self._process_stopped, throttle_secs=self._throttle_secs) elif state == State.RUNNING: # Ping systemd watchdog before throttling - if self._sd_notify: - logger.debug("sd_notify: WATCHDOG=1\\nSTATUS=State: RUNNING.") - self._sd_notify.notify("WATCHDOG=1\nSTATUS=State: RUNNING.") + self._notify("WATCHDOG=1\nSTATUS=State: RUNNING.") self._throttle(func=self._process_running, throttle_secs=self._throttle_secs) @@ -131,8 +137,7 @@ class Worker: return result def _process_stopped(self) -> None: - # Maybe do here something in the future... - pass + self.freqtrade.process_stopped() def _process_running(self) -> None: try: @@ -155,9 +160,7 @@ class Worker: replaces it with the new instance """ # Tell systemd that we initiated reconfiguration - if self._sd_notify: - logger.debug("sd_notify: RELOADING=1") - self._sd_notify.notify("RELOADING=1") + self._notify("RELOADING=1") # Clean up current freqtrade modules self.freqtrade.cleanup() @@ -168,15 +171,11 @@ class Worker: self.freqtrade.notify_status('config reloaded') # Tell systemd that we completed reconfiguration - if self._sd_notify: - logger.debug("sd_notify: READY=1") - self._sd_notify.notify("READY=1") + self._notify("READY=1") def exit(self) -> None: # Tell systemd that we are exiting now - if self._sd_notify: - logger.debug("sd_notify: STOPPING=1") - self._sd_notify.notify("STOPPING=1") + self._notify("STOPPING=1") if self.freqtrade: self.freqtrade.notify_status('process died') diff --git a/mkdocs.yml b/mkdocs.yml index 4e7e6ff75..ebd32b3c1 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,6 +3,7 @@ nav: - Home: index.md - Installation Docker: docker.md - Installation: installation.md + - Freqtrade Basics: bot-basics.md - Configuration: configuration.md - Strategy Customization: strategy-customization.md - Stoploss: stoploss.md @@ -24,6 +25,7 @@ nav: - Plotting: plotting.md - SQL Cheatsheet: sql_cheatsheet.md - Advanced Post-installation Tasks: advanced-setup.md + - Advanced Strategy: strategy-advanced.md - Advanced Hyperopt: advanced-hyperopt.md - Sandbox Testing: sandbox-testing.md - Deprecated Features: deprecated.md diff --git a/requirements-common.txt b/requirements-common.txt index 861c3bdd5..b7e71eada 100644 --- a/requirements-common.txt +++ b/requirements-common.txt @@ -1,17 +1,17 @@ # requirements without requirements installable via conda # mainly used for Raspberry pi installs -ccxt==1.26.53 -SQLAlchemy==1.3.16 -python-telegram-bot==12.6.1 -arrow==0.15.5 -cachetools==4.1.0 -requests==2.23.0 -urllib3==1.25.9 +ccxt==1.32.88 +SQLAlchemy==1.3.18 +python-telegram-bot==12.8 +arrow==0.15.8 +cachetools==4.1.1 +requests==2.24.0 +urllib3==1.25.10 wrapt==1.12.1 jsonschema==3.2.0 -TA-Lib==0.4.17 +TA-Lib==0.4.18 tabulate==0.8.7 -pycoingecko==1.2.0 +pycoingecko==1.3.0 jinja2==2.11.2 # find first, C search in arrays @@ -25,6 +25,8 @@ sdnotify==0.3.2 # Api server flask==1.1.2 +flask-jwt-extended==3.24.1 +flask-cors==3.0.8 # Support for colorized terminal output colorama==0.4.3 diff --git a/requirements-dev.txt b/requirements-dev.txt index d269676af..c02a439d3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,15 +3,15 @@ -r requirements-plot.txt -r requirements-hyperopt.txt -coveralls==2.0.0 -flake8==3.7.9 +coveralls==2.1.1 +flake8==3.8.3 flake8-type-annotations==0.1.0 flake8-tidy-imports==4.1.0 -mypy==0.770 -pytest==5.4.1 -pytest-asyncio==0.10.0 -pytest-cov==2.8.1 -pytest-mock==3.1.0 +mypy==0.782 +pytest==6.0.1 +pytest-asyncio==0.14.0 +pytest-cov==2.10.0 +pytest-mock==3.2.0 pytest-random-order==1.0.4 # Convert jupyter notebooks to markdown documents diff --git a/requirements-hyperopt.txt b/requirements-hyperopt.txt index 6df1eb157..ce08f08e0 100644 --- a/requirements-hyperopt.txt +++ b/requirements-hyperopt.txt @@ -2,9 +2,9 @@ -r requirements.txt # Required for hyperopt -scipy==1.4.1 -scikit-learn==0.22.2.post1 +scipy==1.5.2 +scikit-learn==0.23.1 scikit-optimize==0.7.4 filelock==3.0.12 -joblib==0.14.1 -progressbar2==3.50.1 +joblib==0.16.0 +progressbar2==3.51.4 diff --git a/requirements-plot.txt b/requirements-plot.txt index 3db48a201..51d14d636 100644 --- a/requirements-plot.txt +++ b/requirements-plot.txt @@ -1,5 +1,5 @@ # Include all requirements to run the bot. -r requirements.txt -plotly==4.6.0 +plotly==4.9.0 diff --git a/requirements.txt b/requirements.txt index 967f8df10..d65f90325 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # Load common requirements -r requirements-common.txt -numpy==1.18.3 -pandas==1.0.3 +numpy==1.19.1 +pandas==1.1.0 diff --git a/scripts/rest_client.py b/scripts/rest_client.py index b26c32479..51ea596f6 100755 --- a/scripts/rest_client.py +++ b/scripts/rest_client.py @@ -62,6 +62,9 @@ class FtRestClient(): def _get(self, apipath, params: dict = None): return self._call("GET", apipath, params=params) + def _delete(self, apipath, params: dict = None): + return self._call("DELETE", apipath, params=params) + def _post(self, apipath, params: dict = None, data: dict = None): return self._call("POST", apipath, params=params, data=data) @@ -80,18 +83,18 @@ class FtRestClient(): return self._post("stop") def stopbuy(self): - """Stop buying (but handle sells gracefully). Use `reload_conf` to reset. + """Stop buying (but handle sells gracefully). Use `reload_config` to reset. :return: json object """ return self._post("stopbuy") - def reload_conf(self): + def reload_config(self): """Reload configuration. :return: json object """ - return self._post("reload_conf") + return self._post("reload_config") def balance(self): """Get the account balance. @@ -164,6 +167,15 @@ class FtRestClient(): """ return self._get("trades", params={"limit": limit} if limit else 0) + def delete_trade(self, trade_id): + """Delete trade from the database. + Tries to close open orders. Requires manual handling of this asset on the exchange. + + :param trade_id: Deletes the trade with this ID from the database. + :return: json object + """ + return self._delete("trades/{}".format(trade_id)) + def whitelist(self): """Show the current whitelist. diff --git a/setup.py b/setup.py index 94c48a6a7..6d832e3f5 100644 --- a/setup.py +++ b/setup.py @@ -16,12 +16,12 @@ if readme_file.is_file(): readme_long = (Path(__file__).parent / "README.md").read_text() # Requirements used for submodules -api = ['flask'] +api = ['flask', 'flask-jwt-extended', 'flask-cors'] plot = ['plotly>=4.0'] hyperopt = [ 'scipy', 'scikit-learn', - 'scikit-optimize', + 'scikit-optimize>=0.7.0', 'filelock', 'joblib', 'progressbar2', @@ -63,7 +63,7 @@ setup(name='freqtrade', tests_require=['pytest', 'pytest-asyncio', 'pytest-cov', 'pytest-mock', ], install_requires=[ # from requirements-common.txt - 'ccxt>=1.18.1080', + 'ccxt>=1.24.96', 'SQLAlchemy', 'python-telegram-bot', 'arrow', diff --git a/setup.sh b/setup.sh index e120190ce..918c41e6b 100755 --- a/setup.sh +++ b/setup.sh @@ -252,7 +252,9 @@ function install() { echo "-------------------------" echo "Run the bot !" echo "-------------------------" - echo "You can now use the bot by executing 'source .env/bin/activate; freqtrade trade'." + echo "You can now use the bot by executing 'source .env/bin/activate; freqtrade '." + echo "You can see the list of available bot subcommands by executing 'source .env/bin/activate; freqtrade --help'." + echo "You verify that freqtrade is installed successfully by running 'source .env/bin/activate; freqtrade --version'." } function plot() { diff --git a/tests/commands/test_build_config.py b/tests/commands/test_build_config.py index d4ebe1de2..69b277e3b 100644 --- a/tests/commands/test_build_config.py +++ b/tests/commands/test_build_config.py @@ -44,7 +44,7 @@ def test_start_new_config(mocker, caplog, exchange): 'stake_currency': 'USDT', 'stake_amount': 100, 'fiat_display_currency': 'EUR', - 'ticker_interval': '15m', + 'timeframe': '15m', 'dry_run': True, 'exchange_name': exchange, 'exchange_key': 'sampleKey', @@ -68,7 +68,7 @@ def test_start_new_config(mocker, caplog, exchange): result = rapidjson.loads(wt_mock.call_args_list[0][0][0], parse_mode=rapidjson.PM_COMMENTS | rapidjson.PM_TRAILING_COMMAS) assert result['exchange']['name'] == exchange - assert result['ticker_interval'] == '15m' + assert result['timeframe'] == '15m' def test_start_new_config_exists(mocker, caplog): diff --git a/tests/commands/test_commands.py b/tests/commands/test_commands.py index 4530cd03d..69d80d2cd 100644 --- a/tests/commands/test_commands.py +++ b/tests/commands/test_commands.py @@ -6,15 +6,17 @@ import pytest from freqtrade.commands import (start_convert_data, start_create_userdir, start_download_data, start_hyperopt_list, - start_hyperopt_show, start_list_exchanges, - start_list_hyperopts, start_list_markets, - start_list_strategies, start_list_timeframes, - start_new_hyperopt, start_new_strategy, + start_hyperopt_show, start_list_data, + start_list_exchanges, start_list_hyperopts, + start_list_markets, start_list_strategies, + start_list_timeframes, start_new_hyperopt, + start_new_strategy, start_show_trades, start_test_pairlist, start_trading) from freqtrade.configuration import setup_utils_configuration from freqtrade.exceptions import OperationalException from freqtrade.state import RunMode -from tests.conftest import (get_args, log_has, log_has_re, patch_exchange, +from tests.conftest import (create_mock_trades, get_args, log_has, log_has_re, + patch_exchange, patched_configuration_load_config_file) @@ -30,7 +32,7 @@ def test_setup_utils_configuration(): assert config['exchange']['secret'] == '' -def test_start_trading_fail(mocker): +def test_start_trading_fail(mocker, caplog): mocker.patch("freqtrade.worker.Worker.run", MagicMock(side_effect=OperationalException)) @@ -41,16 +43,15 @@ def test_start_trading_fail(mocker): 'trade', '-c', 'config.json.example' ] - with pytest.raises(OperationalException): - start_trading(get_args(args)) + start_trading(get_args(args)) assert exitmock.call_count == 1 exitmock.reset_mock() - + caplog.clear() mocker.patch("freqtrade.worker.Worker.__init__", MagicMock(side_effect=OperationalException)) - with pytest.raises(OperationalException): - start_trading(get_args(args)) + start_trading(get_args(args)) assert exitmock.call_count == 0 + assert log_has('Fatal exception!', caplog) def test_list_exchanges(capsys): @@ -727,7 +728,7 @@ def test_start_test_pairlist(mocker, caplog, tickers, default_conf, capsys): assert re.match("['ETH/BTC', 'TKN/BTC', 'BLK/BTC', 'LTC/BTC', 'XRP/BTC']", captured.out) -def test_hyperopt_list(mocker, capsys, hyperopt_results): +def test_hyperopt_list(mocker, capsys, caplog, hyperopt_results): mocker.patch( 'freqtrade.optimize.hyperopt.Hyperopt.load_previous_results', MagicMock(return_value=hyperopt_results) @@ -735,7 +736,7 @@ def test_hyperopt_list(mocker, capsys, hyperopt_results): args = [ "hyperopt-list", - "--no-details" + "--no-details", ] pargs = get_args(args) pargs['config'] = None @@ -748,7 +749,7 @@ def test_hyperopt_list(mocker, capsys, hyperopt_results): args = [ "hyperopt-list", "--best", - "--no-details" + "--no-details", ] pargs = get_args(args) pargs['config'] = None @@ -762,7 +763,7 @@ def test_hyperopt_list(mocker, capsys, hyperopt_results): args = [ "hyperopt-list", "--profitable", - "--no-details" + "--no-details", ] pargs = get_args(args) pargs['config'] = None @@ -775,7 +776,7 @@ def test_hyperopt_list(mocker, capsys, hyperopt_results): " 11/12", " 12/12"]) args = [ "hyperopt-list", - "--profitable" + "--profitable", ] pargs = get_args(args) pargs['config'] = None @@ -791,7 +792,7 @@ def test_hyperopt_list(mocker, capsys, hyperopt_results): "hyperopt-list", "--no-details", "--no-color", - "--min-trades", "20" + "--min-trades", "20", ] pargs = get_args(args) pargs['config'] = None @@ -805,7 +806,7 @@ def test_hyperopt_list(mocker, capsys, hyperopt_results): "hyperopt-list", "--profitable", "--no-details", - "--max-trades", "20" + "--max-trades", "20", ] pargs = get_args(args) pargs['config'] = None @@ -820,7 +821,7 @@ def test_hyperopt_list(mocker, capsys, hyperopt_results): "hyperopt-list", "--profitable", "--no-details", - "--min-avg-profit", "0.11" + "--min-avg-profit", "0.11", ] pargs = get_args(args) pargs['config'] = None @@ -834,7 +835,7 @@ def test_hyperopt_list(mocker, capsys, hyperopt_results): args = [ "hyperopt-list", "--no-details", - "--max-avg-profit", "0.10" + "--max-avg-profit", "0.10", ] pargs = get_args(args) pargs['config'] = None @@ -848,7 +849,7 @@ def test_hyperopt_list(mocker, capsys, hyperopt_results): args = [ "hyperopt-list", "--no-details", - "--min-total-profit", "0.4" + "--min-total-profit", "0.4", ] pargs = get_args(args) pargs['config'] = None @@ -862,7 +863,35 @@ def test_hyperopt_list(mocker, capsys, hyperopt_results): args = [ "hyperopt-list", "--no-details", - "--max-total-profit", "0.4" + "--max-total-profit", "0.4", + ] + pargs = get_args(args) + pargs['config'] = None + start_hyperopt_list(pargs) + captured = capsys.readouterr() + assert all(x in captured.out + for x in [" 1/12", " 2/12", " 3/12", " 5/12", " 6/12", " 7/12", " 8/12", + " 9/12", " 11/12"]) + assert all(x not in captured.out + for x in [" 4/12", " 10/12", " 12/12"]) + args = [ + "hyperopt-list", + "--no-details", + "--min-objective", "0.1", + ] + pargs = get_args(args) + pargs['config'] = None + start_hyperopt_list(pargs) + captured = capsys.readouterr() + assert all(x in captured.out + for x in [" 10/12"]) + assert all(x not in captured.out + for x in [" 1/12", " 2/12", " 3/12", " 4/12", " 5/12", " 6/12", " 7/12", " 8/12", + " 9/12", " 11/12", " 12/12"]) + args = [ + "hyperopt-list", + "--no-details", + "--max-objective", "0.1", ] pargs = get_args(args) pargs['config'] = None @@ -877,7 +906,7 @@ def test_hyperopt_list(mocker, capsys, hyperopt_results): "hyperopt-list", "--profitable", "--no-details", - "--min-avg-time", "2000" + "--min-avg-time", "2000", ] pargs = get_args(args) pargs['config'] = None @@ -891,7 +920,7 @@ def test_hyperopt_list(mocker, capsys, hyperopt_results): args = [ "hyperopt-list", "--no-details", - "--max-avg-time", "1500" + "--max-avg-time", "1500", ] pargs = get_args(args) pargs['config'] = None @@ -905,14 +934,13 @@ def test_hyperopt_list(mocker, capsys, hyperopt_results): args = [ "hyperopt-list", "--no-details", - "--export-csv", "test_file.csv" + "--export-csv", "test_file.csv", ] pargs = get_args(args) pargs['config'] = None start_hyperopt_list(pargs) captured = capsys.readouterr() - assert all(x in captured.out - for x in ["CSV-File created!"]) + log_has("CSV file created: test_file.csv", caplog) f = Path("test_file.csv") assert 'Best,1,2,-1.25%,-0.00125625,,-2.51,"3,930.0 m",0.43662' in f.read_text() assert f.is_file() @@ -1041,3 +1069,80 @@ def test_convert_data_trades(mocker, testdatadir): assert trades_mock.call_args[1]['convert_from'] == 'jsongz' assert trades_mock.call_args[1]['convert_to'] == 'json' assert trades_mock.call_args[1]['erase'] is False + + +def test_start_list_data(testdatadir, capsys): + args = [ + "list-data", + "--data-format-ohlcv", + "json", + "--datadir", + str(testdatadir), + ] + pargs = get_args(args) + pargs['config'] = None + start_list_data(pargs) + captured = capsys.readouterr() + assert "Found 16 pair / timeframe combinations." in captured.out + assert "\n| Pair | Timeframe |\n" in captured.out + assert "\n| UNITTEST/BTC | 1m, 5m, 8m, 30m |\n" in captured.out + + args = [ + "list-data", + "--data-format-ohlcv", + "json", + "--pairs", "XRP/ETH", + "--datadir", + str(testdatadir), + ] + pargs = get_args(args) + pargs['config'] = None + start_list_data(pargs) + captured = capsys.readouterr() + assert "Found 2 pair / timeframe combinations." in captured.out + assert "\n| Pair | Timeframe |\n" in captured.out + assert "UNITTEST/BTC" not in captured.out + assert "\n| XRP/ETH | 1m, 5m |\n" in captured.out + + +@pytest.mark.usefixtures("init_persistence") +def test_show_trades(mocker, fee, capsys, caplog): + mocker.patch("freqtrade.persistence.init") + create_mock_trades(fee) + args = [ + "show-trades", + "--db-url", + "sqlite:///" + ] + pargs = get_args(args) + pargs['config'] = None + start_show_trades(pargs) + assert log_has("Printing 4 Trades: ", caplog) + captured = capsys.readouterr() + assert "Trade(id=1" in captured.out + assert "Trade(id=2" in captured.out + assert "Trade(id=3" in captured.out + args = [ + "show-trades", + "--db-url", + "sqlite:///", + "--print-json", + "--trade-ids", "1", "2" + ] + pargs = get_args(args) + pargs['config'] = None + start_show_trades(pargs) + + captured = capsys.readouterr() + assert log_has("Printing 2 Trades: ", caplog) + assert '"trade_id": 1' in captured.out + assert '"trade_id": 2' in captured.out + assert '"trade_id": 3' not in captured.out + args = [ + "show-trades", + ] + pargs = get_args(args) + pargs['config'] = None + + with pytest.raises(OperationalException, match=r"--db-url is required for this command."): + start_show_trades(pargs) diff --git a/tests/config_test_comments.json b/tests/config_test_comments.json index 8f41b08fa..4f201f86c 100644 --- a/tests/config_test_comments.json +++ b/tests/config_test_comments.json @@ -9,7 +9,7 @@ "fiat_display_currency": "USD", // C++-style comment "amount_reserve_percent" : 0.05, // And more, tabs before this comment "dry_run": false, - "ticker_interval": "5m", + "timeframe": "5m", "trailing_stop": false, "trailing_stop_positive": 0.005, "trailing_stop_positive_offset": 0.0051, @@ -92,7 +92,6 @@ "enabled": false, "process_throttle_secs": 3600, "calculate_since_number_of_days": 7, - "capital_available_percentage": 0.5, "allowed_risk": 0.01, "stoploss_range_min": -0.01, "stoploss_range_max": -0.1, diff --git a/tests/conftest.py b/tests/conftest.py index d95475b8c..1ac8256a8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -56,6 +56,7 @@ def patched_configuration_load_config_file(mocker, config) -> None: def patch_exchange(mocker, api_mock=None, id='bittrex', mock_markets=True) -> None: + mocker.patch('freqtrade.exchange.Exchange._load_async_markets', MagicMock(return_value={})) mocker.patch('freqtrade.exchange.Exchange._load_markets', MagicMock(return_value={})) mocker.patch('freqtrade.exchange.Exchange.validate_pairs', MagicMock()) mocker.patch('freqtrade.exchange.Exchange.validate_timeframes', MagicMock()) @@ -92,7 +93,7 @@ def patch_wallet(mocker, free=999.9) -> None: def patch_whitelist(mocker, conf) -> None: - mocker.patch('freqtrade.freqtradebot.FreqtradeBot._refresh_whitelist', + mocker.patch('freqtrade.freqtradebot.FreqtradeBot._refresh_active_whitelist', MagicMock(return_value=conf['exchange']['pair_whitelist'])) @@ -162,7 +163,7 @@ def patch_get_signal(freqtrade: FreqtradeBot, value=(True, False)) -> None: :param value: which value IStrategy.get_signal() must return :return: None """ - freqtrade.strategy.get_signal = lambda e, s, t: value + freqtrade.strategy.get_signal = lambda e, s, x: value freqtrade.exchange.refresh_latest_ohlcv = lambda p: None @@ -175,6 +176,7 @@ def create_mock_trades(fee): pair='ETH/BTC', stake_amount=0.001, amount=123.0, + amount_requested=123.0, fee_open=fee.return_value, fee_close=fee.return_value, open_rate=0.123, @@ -187,6 +189,7 @@ def create_mock_trades(fee): pair='ETC/BTC', stake_amount=0.001, amount=123.0, + amount_requested=123.0, fee_open=fee.return_value, fee_close=fee.return_value, open_rate=0.123, @@ -198,11 +201,26 @@ def create_mock_trades(fee): ) Trade.session.add(trade) + trade = Trade( + pair='XRP/BTC', + stake_amount=0.001, + amount=123.0, + fee_open=fee.return_value, + fee_close=fee.return_value, + open_rate=0.05, + close_rate=0.06, + close_profit=0.01, + exchange='bittrex', + is_open=False, + ) + Trade.session.add(trade) + # Simulate prod entry trade = Trade( pair='ETC/BTC', stake_amount=0.001, amount=123.0, + amount_requested=124.0, fee_open=fee.return_value, fee_close=fee.return_value, open_rate=0.123, @@ -247,8 +265,9 @@ def default_conf(testdatadir): "stake_currency": "BTC", "stake_amount": 0.001, "fiat_display_currency": "USD", - "ticker_interval": '5m', + "timeframe": '5m', "dry_run": True, + "cancel_open_orders_on_exit": False, "minimal_roi": { "40": 0.0, "30": 0.01, @@ -304,7 +323,8 @@ def default_conf(testdatadir): "user_data_dir": Path("user_data"), "verbosity": 3, "strategy_path": str(Path(__file__).parent / "strategy" / "strats"), - "strategy": "DefaultStrategy" + "strategy": "DefaultStrategy", + "internals": {}, } return configuration @@ -658,7 +678,8 @@ def shitcoinmarkets(markets): Fixture with shitcoin markets - used to test filters in pairlists """ shitmarkets = deepcopy(markets) - shitmarkets.update({'HOT/BTC': { + shitmarkets.update({ + 'HOT/BTC': { 'id': 'HOTBTC', 'symbol': 'HOT/BTC', 'base': 'HOT', @@ -763,7 +784,32 @@ def shitcoinmarkets(markets): "spot": True, "future": False, "active": True - }, + }, + 'ADADOUBLE/USDT': { + "percentage": True, + "tierBased": False, + "taker": 0.001, + "maker": 0.001, + "precision": { + "base": 8, + "quote": 8, + "amount": 2, + "price": 4 + }, + "limits": { + }, + "id": "ADADOUBLEUSDT", + "symbol": "ADADOUBLE/USDT", + "base": "ADADOUBLE", + "quote": "USDT", + "baseId": "ADADOUBLE", + "quoteId": "USDT", + "info": {}, + "type": "spot", + "spot": True, + "future": False, + "active": True + }, }) return shitmarkets @@ -779,11 +825,12 @@ def limit_buy_order(): 'id': 'mocked_limit_buy', 'type': 'limit', 'side': 'buy', - 'pair': 'mocked', + 'symbol': 'mocked', 'datetime': arrow.utcnow().isoformat(), 'price': 0.00001099, 'amount': 90.99181073, 'filled': 90.99181073, + 'cost': 0.0009999, 'remaining': 0.0, 'status': 'closed' } @@ -795,7 +842,7 @@ def market_buy_order(): 'id': 'mocked_market_buy', 'type': 'market', 'side': 'buy', - 'pair': 'mocked', + 'symbol': 'mocked', 'datetime': arrow.utcnow().isoformat(), 'price': 0.00004099, 'amount': 91.99181073, @@ -811,7 +858,7 @@ def market_sell_order(): 'id': 'mocked_limit_sell', 'type': 'market', 'side': 'sell', - 'pair': 'mocked', + 'symbol': 'mocked', 'datetime': arrow.utcnow().isoformat(), 'price': 0.00004173, 'amount': 91.99181073, @@ -827,7 +874,7 @@ def limit_buy_order_old(): 'id': 'mocked_limit_buy_old', 'type': 'limit', 'side': 'buy', - 'pair': 'mocked', + 'symbol': 'mocked', 'datetime': str(arrow.utcnow().shift(minutes=-601).datetime), 'price': 0.00001099, 'amount': 90.99181073, @@ -843,7 +890,7 @@ def limit_sell_order_old(): 'id': 'mocked_limit_sell_old', 'type': 'limit', 'side': 'sell', - 'pair': 'ETH/BTC', + 'symbol': 'ETH/BTC', 'datetime': arrow.utcnow().shift(minutes=-601).isoformat(), 'price': 0.00001099, 'amount': 90.99181073, @@ -859,7 +906,7 @@ def limit_buy_order_old_partial(): 'id': 'mocked_limit_buy_old_partial', 'type': 'limit', 'side': 'buy', - 'pair': 'ETH/BTC', + 'symbol': 'ETH/BTC', 'datetime': arrow.utcnow().shift(minutes=-601).isoformat(), 'price': 0.00001099, 'amount': 90.99181073, @@ -873,10 +920,103 @@ def limit_buy_order_old_partial(): def limit_buy_order_old_partial_canceled(limit_buy_order_old_partial): res = deepcopy(limit_buy_order_old_partial) res['status'] = 'canceled' - res['fee'] = {'cost': 0.0001, 'currency': 'ETH'} + res['fee'] = {'cost': 0.023, 'currency': 'ETH'} return res +@pytest.fixture(scope='function') +def limit_buy_order_canceled_empty(request): + # Indirect fixture + # Documentation: + # https://docs.pytest.org/en/latest/example/parametrize.html#apply-indirect-on-particular-arguments + + exchange_name = request.param + if exchange_name == 'ftx': + return { + 'info': {}, + 'id': '1234512345', + 'clientOrderId': None, + 'timestamp': arrow.utcnow().shift(minutes=-601).timestamp, + 'datetime': arrow.utcnow().shift(minutes=-601).isoformat(), + 'lastTradeTimestamp': None, + 'symbol': 'LTC/USDT', + 'type': 'limit', + 'side': 'buy', + 'price': 34.3225, + 'amount': 0.55, + 'cost': 0.0, + 'average': None, + 'filled': 0.0, + 'remaining': 0.0, + 'status': 'closed', + 'fee': None, + 'trades': None + } + elif exchange_name == 'kraken': + return { + 'info': {}, + 'id': 'AZNPFF-4AC4N-7MKTAT', + 'clientOrderId': None, + 'timestamp': arrow.utcnow().shift(minutes=-601).timestamp, + 'datetime': arrow.utcnow().shift(minutes=-601).isoformat(), + 'lastTradeTimestamp': None, + 'status': 'canceled', + 'symbol': 'LTC/USDT', + 'type': 'limit', + 'side': 'buy', + 'price': 34.3225, + 'cost': 0.0, + 'amount': 0.55, + 'filled': 0.0, + 'average': 0.0, + 'remaining': 0.55, + 'fee': {'cost': 0.0, 'rate': None, 'currency': 'USDT'}, + 'trades': [] + } + elif exchange_name == 'binance': + return { + 'info': {}, + 'id': '1234512345', + 'clientOrderId': 'alb1234123', + 'timestamp': arrow.utcnow().shift(minutes=-601).timestamp, + 'datetime': arrow.utcnow().shift(minutes=-601).isoformat(), + 'lastTradeTimestamp': None, + 'symbol': 'LTC/USDT', + 'type': 'limit', + 'side': 'buy', + 'price': 0.016804, + 'amount': 0.55, + 'cost': 0.0, + 'average': None, + 'filled': 0.0, + 'remaining': 0.55, + 'status': 'canceled', + 'fee': None, + 'trades': None + } + else: + return { + 'info': {}, + 'id': '1234512345', + 'clientOrderId': 'alb1234123', + 'timestamp': arrow.utcnow().shift(minutes=-601).timestamp, + 'datetime': arrow.utcnow().shift(minutes=-601).isoformat(), + 'lastTradeTimestamp': None, + 'symbol': 'LTC/USDT', + 'type': 'limit', + 'side': 'buy', + 'price': 0.016804, + 'amount': 0.55, + 'cost': 0.0, + 'average': None, + 'filled': 0.0, + 'remaining': 0.55, + 'status': 'canceled', + 'fee': None, + 'trades': None + } + + @pytest.fixture def limit_sell_order(): return { @@ -1291,6 +1431,28 @@ def tickers(): "quoteVolume": 0.0, "info": {} }, + "ADADOUBLE/USDT": { + "symbol": "ADADOUBLE/USDT", + "timestamp": 1580469388244, + "datetime": "2020-01-31T11:16:28.244Z", + "high": None, + "low": None, + "bid": 0.7305, + "bidVolume": None, + "ask": 0.7342, + "askVolume": None, + "vwap": None, + "open": None, + "close": None, + "last": 0, + "previousClose": None, + "change": None, + "percentage": 2.628, + "average": None, + "baseVolume": 0.0, + "quoteVolume": 0.0, + "info": {} + }, }) @@ -1328,6 +1490,15 @@ def trades_for_order(): @pytest.fixture(scope="function") def trades_history(): + return [[1565798389463, '126181329', None, 'buy', 0.019627, 0.04, 0.00078508], + [1565798399629, '126181330', None, 'buy', 0.019627, 0.244, 0.004788987999999999], + [1565798399752, '126181331', None, 'sell', 0.019626, 0.011, 0.00021588599999999999], + [1565798399862, '126181332', None, 'sell', 0.019626, 0.011, 0.00021588599999999999], + [1565798399872, '126181333', None, 'sell', 0.019626, 0.011, 0.00021588599999999999]] + + +@pytest.fixture(scope="function") +def fetch_trades_result(): return [{'info': {'a': 126181329, 'p': '0.01962700', 'q': '0.04000000', @@ -1482,10 +1653,11 @@ def buy_order_fee(): 'id': 'mocked_limit_buy_old', 'type': 'limit', 'side': 'buy', - 'pair': 'mocked', + 'symbol': 'mocked', 'datetime': str(arrow.utcnow().shift(minutes=-601).datetime), 'price': 0.245441, 'amount': 8.0, + 'cost': 1.963528, 'remaining': 90.99181073, 'status': 'closed', 'fee': None @@ -1601,7 +1773,7 @@ def hyperopt_results(): { 'loss': 0.4366182531161519, 'params_dict': { - 'mfi-value': 15, 'fastd-value': 20, 'adx-value': 25, 'rsi-value': 28, 'mfi-enabled': False, 'fastd-enabled': True, 'adx-enabled': True, 'rsi-enabled': True, 'trigger': 'macd_cross_signal', 'sell-mfi-value': 88, 'sell-fastd-value': 97, 'sell-adx-value': 51, 'sell-rsi-value': 67, 'sell-mfi-enabled': False, 'sell-fastd-enabled': False, 'sell-adx-enabled': True, 'sell-rsi-enabled': True, 'sell-trigger': 'sell-bb_upper', 'roi_t1': 1190, 'roi_t2': 541, 'roi_t3': 408, 'roi_p1': 0.026035863879169705, 'roi_p2': 0.12508730043628782, 'roi_p3': 0.27766427921605896, 'stoploss': -0.2562930402099556}, # noqa: E501 + 'mfi-value': 15, 'fastd-value': 20, 'adx-value': 25, 'rsi-value': 28, 'mfi-enabled': False, 'fastd-enabled': True, 'adx-enabled': True, 'rsi-enabled': True, 'trigger': 'macd_cross_signal', 'sell-mfi-value': 88, 'sell-fastd-value': 97, 'sell-adx-value': 51, 'sell-rsi-value': 67, 'sell-mfi-enabled': False, 'sell-fastd-enabled': False, 'sell-adx-enabled': True, 'sell-rsi-enabled': True, 'sell-trigger': 'sell-bb_upper', 'roi_t1': 1190, 'roi_t2': 541, 'roi_t3': 408, 'roi_p1': 0.026035863879169705, 'roi_p2': 0.12508730043628782, 'roi_p3': 0.27766427921605896, 'stoploss': -0.2562930402099556}, # noqa: E501 'params_details': {'buy': {'mfi-value': 15, 'fastd-value': 20, 'adx-value': 25, 'rsi-value': 28, 'mfi-enabled': False, 'fastd-enabled': True, 'adx-enabled': True, 'rsi-enabled': True, 'trigger': 'macd_cross_signal'}, 'sell': {'sell-mfi-value': 88, 'sell-fastd-value': 97, 'sell-adx-value': 51, 'sell-rsi-value': 67, 'sell-mfi-enabled': False, 'sell-fastd-enabled': False, 'sell-adx-enabled': True, 'sell-rsi-enabled': True, 'sell-trigger': 'sell-bb_upper'}, 'roi': {0: 0.4287874435315165, 408: 0.15112316431545753, 949: 0.026035863879169705, 2139: 0}, 'stoploss': {'stoploss': -0.2562930402099556}}, # noqa: E501 'results_metrics': {'trade_count': 2, 'avg_profit': -1.254995, 'total_profit': -0.00125625, 'profit': -2.50999, 'duration': 3930.0}, # noqa: E501 'results_explanation': ' 2 trades. Avg profit -1.25%. Total profit -0.00125625 BTC ( -2.51Σ%). Avg duration 3930.0 min.', # noqa: E501 @@ -1612,11 +1784,12 @@ def hyperopt_results(): }, { 'loss': 20.0, 'params_dict': { - 'mfi-value': 17, 'fastd-value': 38, 'adx-value': 48, 'rsi-value': 22, 'mfi-enabled': True, 'fastd-enabled': False, 'adx-enabled': True, 'rsi-enabled': True, 'trigger': 'macd_cross_signal', 'sell-mfi-value': 96, 'sell-fastd-value': 68, 'sell-adx-value': 63, 'sell-rsi-value': 81, 'sell-mfi-enabled': False, 'sell-fastd-enabled': True, 'sell-adx-enabled': True, 'sell-rsi-enabled': True, 'sell-trigger': 'sell-sar_reversal', 'roi_t1': 334, 'roi_t2': 683, 'roi_t3': 140, 'roi_p1': 0.06403981740598495, 'roi_p2': 0.055519840060645045, 'roi_p3': 0.3253712811342459, 'stoploss': -0.338070047333259}, # noqa: E501 - 'params_details': {'buy': {'mfi-value': 17, 'fastd-value': 38, 'adx-value': 48, 'rsi-value': 22, 'mfi-enabled': True, 'fastd-enabled': False, 'adx-enabled': True, 'rsi-enabled': True, 'trigger': 'macd_cross_signal'}, # noqa: E501 - 'sell': {'sell-mfi-value': 96, 'sell-fastd-value': 68, 'sell-adx-value': 63, 'sell-rsi-value': 81, 'sell-mfi-enabled': False, 'sell-fastd-enabled': True, 'sell-adx-enabled': True, 'sell-rsi-enabled': True, 'sell-trigger': 'sell-sar_reversal'}, # noqa: E501 - 'roi': {0: 0.4449309386008759, 140: 0.11955965746663, 823: 0.06403981740598495, 1157: 0}, # noqa: E501 - 'stoploss': {'stoploss': -0.338070047333259}}, + 'mfi-value': 17, 'fastd-value': 38, 'adx-value': 48, 'rsi-value': 22, 'mfi-enabled': True, 'fastd-enabled': False, 'adx-enabled': True, 'rsi-enabled': True, 'trigger': 'macd_cross_signal', 'sell-mfi-value': 96, 'sell-fastd-value': 68, 'sell-adx-value': 63, 'sell-rsi-value': 81, 'sell-mfi-enabled': False, 'sell-fastd-enabled': True, 'sell-adx-enabled': True, 'sell-rsi-enabled': True, 'sell-trigger': 'sell-sar_reversal', 'roi_t1': 334, 'roi_t2': 683, 'roi_t3': 140, 'roi_p1': 0.06403981740598495, 'roi_p2': 0.055519840060645045, 'roi_p3': 0.3253712811342459, 'stoploss': -0.338070047333259}, # noqa: E501 + 'params_details': { + 'buy': {'mfi-value': 17, 'fastd-value': 38, 'adx-value': 48, 'rsi-value': 22, 'mfi-enabled': True, 'fastd-enabled': False, 'adx-enabled': True, 'rsi-enabled': True, 'trigger': 'macd_cross_signal'}, # noqa: E501 + 'sell': {'sell-mfi-value': 96, 'sell-fastd-value': 68, 'sell-adx-value': 63, 'sell-rsi-value': 81, 'sell-mfi-enabled': False, 'sell-fastd-enabled': True, 'sell-adx-enabled': True, 'sell-rsi-enabled': True, 'sell-trigger': 'sell-sar_reversal'}, # noqa: E501 + 'roi': {0: 0.4449309386008759, 140: 0.11955965746663, 823: 0.06403981740598495, 1157: 0}, # noqa: E501 + 'stoploss': {'stoploss': -0.338070047333259}}, 'results_metrics': {'trade_count': 1, 'avg_profit': 0.12357, 'total_profit': 6.185e-05, 'profit': 0.12357, 'duration': 1200.0}, # noqa: E501 'results_explanation': ' 1 trades. Avg profit 0.12%. Total profit 0.00006185 BTC ( 0.12Σ%). Avg duration 1200.0 min.', # noqa: E501 'total_profit': 6.185e-05, @@ -1663,8 +1836,9 @@ def hyperopt_results(): }, { 'loss': 4.713497421432944, 'params_dict': {'mfi-value': 13, 'fastd-value': 41, 'adx-value': 21, 'rsi-value': 29, 'mfi-enabled': False, 'fastd-enabled': True, 'adx-enabled': False, 'rsi-enabled': False, 'trigger': 'bb_lower', 'sell-mfi-value': 99, 'sell-fastd-value': 60, 'sell-adx-value': 81, 'sell-rsi-value': 69, 'sell-mfi-enabled': True, 'sell-fastd-enabled': True, 'sell-adx-enabled': True, 'sell-rsi-enabled': False, 'sell-trigger': 'sell-macd_cross_signal', 'roi_t1': 771, 'roi_t2': 620, 'roi_t3': 145, 'roi_p1': 0.0586919200378493, 'roi_p2': 0.04984118697312542, 'roi_p3': 0.37521058680247044, 'stoploss': -0.14613268022709905}, # noqa: E501 - 'params_details': {'buy': {'mfi-value': 13, 'fastd-value': 41, 'adx-value': 21, 'rsi-value': 29, 'mfi-enabled': False, 'fastd-enabled': True, 'adx-enabled': False, 'rsi-enabled': False, 'trigger': 'bb_lower'}, 'sell': {'sell-mfi-value': 99, 'sell-fastd-value': 60, 'sell-adx-value': 81, 'sell-rsi-value': 69, 'sell-mfi-enabled': True, 'sell-fastd-enabled': True, 'sell-adx-enabled': True, 'sell-rsi-enabled': False, 'sell-trigger': 'sell-macd_cross_signal'}, 'roi': {0: 0.4837436938134452, 145: 0.10853310701097472, 765: 0.0586919200378493, 1536: 0}, # noqa: E501 - 'stoploss': {'stoploss': -0.14613268022709905}}, # noqa: E501 + 'params_details': { + 'buy': {'mfi-value': 13, 'fastd-value': 41, 'adx-value': 21, 'rsi-value': 29, 'mfi-enabled': False, 'fastd-enabled': True, 'adx-enabled': False, 'rsi-enabled': False, 'trigger': 'bb_lower'}, 'sell': {'sell-mfi-value': 99, 'sell-fastd-value': 60, 'sell-adx-value': 81, 'sell-rsi-value': 69, 'sell-mfi-enabled': True, 'sell-fastd-enabled': True, 'sell-adx-enabled': True, 'sell-rsi-enabled': False, 'sell-trigger': 'sell-macd_cross_signal'}, 'roi': {0: 0.4837436938134452, 145: 0.10853310701097472, 765: 0.0586919200378493, 1536: 0}, # noqa: E501 + 'stoploss': {'stoploss': -0.14613268022709905}}, # noqa: E501 'results_metrics': {'trade_count': 318, 'avg_profit': -0.39833954716981146, 'total_profit': -0.06339929, 'profit': -126.67197600000004, 'duration': 3140.377358490566}, # noqa: E501 'results_explanation': ' 318 trades. Avg profit -0.40%. Total profit -0.06339929 BTC (-126.67Σ%). Avg duration 3140.4 min.', # noqa: E501 'total_profit': -0.06339929, diff --git a/tests/data/test_btanalysis.py b/tests/data/test_btanalysis.py index 4da2acc5e..718c02f05 100644 --- a/tests/data/test_btanalysis.py +++ b/tests/data/test_btanalysis.py @@ -43,11 +43,11 @@ def test_load_trades_from_db(default_conf, fee, mocker): trades = load_trades_from_db(db_url=default_conf['db_url']) assert init_mock.call_count == 1 - assert len(trades) == 3 + assert len(trades) == 4 assert isinstance(trades, DataFrame) assert "pair" in trades.columns assert "open_time" in trades.columns - assert "profitperc" in trades.columns + assert "profit_percent" in trades.columns for col in BT_DATA_COLUMNS: if col not in ['index', 'open_at_end']: @@ -178,6 +178,10 @@ def test_create_cum_profit1(testdatadir): assert cum_profits.iloc[0]['cum_profits'] == 0 assert cum_profits.iloc[-1]['cum_profits'] == 0.0798005 + with pytest.raises(ValueError, match='Trade dataframe empty.'): + create_cum_profit(df.set_index('date'), bt_data[bt_data["pair"] == 'NOTAPAIR'], + "cum_profits", timeframe="5m") + def test_calculate_max_drawdown(testdatadir): filename = testdatadir / "backtest-result_test.json" diff --git a/tests/data/test_converter.py b/tests/data/test_converter.py index 7dff520e0..4a580366f 100644 --- a/tests/data/test_converter.py +++ b/tests/data/test_converter.py @@ -5,12 +5,10 @@ from freqtrade.configuration.timerange import TimeRange from freqtrade.data.converter import (convert_ohlcv_format, convert_trades_format, ohlcv_fill_up_missing_data, - ohlcv_to_dataframe, - trim_dataframe) -from freqtrade.data.history import (get_timerange, - load_data, - load_pair_history, - validate_backtest_data) + ohlcv_to_dataframe, trades_dict_to_list, + trades_remove_duplicates, trim_dataframe) +from freqtrade.data.history import (get_timerange, load_data, + load_pair_history, validate_backtest_data) from tests.conftest import log_has from tests.data.test_history import _backup_file, _clean_test_file @@ -197,32 +195,60 @@ def test_trim_dataframe(testdatadir) -> None: assert all(data_modify.iloc[0] == data.iloc[25]) -def test_convert_trades_format(mocker, default_conf, testdatadir): - file = testdatadir / "XRP_ETH-trades.json.gz" - file_new = testdatadir / "XRP_ETH-trades.json" - _backup_file(file, copy_file=True) - default_conf['datadir'] = testdatadir +def test_trades_remove_duplicates(trades_history): + trades_history1 = trades_history * 3 + assert len(trades_history1) == len(trades_history) * 3 + res = trades_remove_duplicates(trades_history1) + assert len(res) == len(trades_history) + for i, t in enumerate(res): + assert t == trades_history[i] - assert not file_new.exists() + +def test_trades_dict_to_list(fetch_trades_result): + res = trades_dict_to_list(fetch_trades_result) + assert isinstance(res, list) + assert isinstance(res[0], list) + for i, t in enumerate(res): + assert t[0] == fetch_trades_result[i]['timestamp'] + assert t[1] == fetch_trades_result[i]['id'] + assert t[2] == fetch_trades_result[i]['type'] + assert t[3] == fetch_trades_result[i]['side'] + assert t[4] == fetch_trades_result[i]['price'] + assert t[5] == fetch_trades_result[i]['amount'] + assert t[6] == fetch_trades_result[i]['cost'] + + +def test_convert_trades_format(mocker, default_conf, testdatadir): + files = [{'old': testdatadir / "XRP_ETH-trades.json.gz", + 'new': testdatadir / "XRP_ETH-trades.json"}, + {'old': testdatadir / "XRP_OLD-trades.json.gz", + 'new': testdatadir / "XRP_OLD-trades.json"}, + ] + for file in files: + _backup_file(file['old'], copy_file=True) + assert not file['new'].exists() + + default_conf['datadir'] = testdatadir convert_trades_format(default_conf, convert_from='jsongz', convert_to='json', erase=False) - assert file_new.exists() - assert file.exists() + for file in files: + assert file['new'].exists() + assert file['old'].exists() - # Remove original file - file.unlink() + # Remove original file + file['old'].unlink() # Convert back convert_trades_format(default_conf, convert_from='json', convert_to='jsongz', erase=True) + for file in files: + assert file['old'].exists() + assert not file['new'].exists() - assert file.exists() - assert not file_new.exists() - - _clean_test_file(file) - if file_new.exists(): - file_new.unlink() + _clean_test_file(file['old']) + if file['new'].exists(): + file['new'].unlink() def test_convert_ohlcv_format(mocker, default_conf, testdatadir): diff --git a/tests/data/test_dataprovider.py b/tests/data/test_dataprovider.py index 2b3dda188..c2ecf4b80 100644 --- a/tests/data/test_dataprovider.py +++ b/tests/data/test_dataprovider.py @@ -1,15 +1,19 @@ +from datetime import datetime, timezone from unittest.mock import MagicMock +import pytest from pandas import DataFrame from freqtrade.data.dataprovider import DataProvider +from freqtrade.exceptions import ExchangeError, OperationalException +from freqtrade.pairlist.pairlistmanager import PairListManager from freqtrade.state import RunMode from tests.conftest import get_patched_exchange def test_ohlcv(mocker, default_conf, ohlcv_history): default_conf["runmode"] = RunMode.DRY_RUN - timeframe = default_conf["ticker_interval"] + timeframe = default_conf["timeframe"] exchange = get_patched_exchange(mocker, default_conf) exchange._klines[("XRP/BTC", timeframe)] = ohlcv_history exchange._klines[("UNITTEST/BTC", timeframe)] = ohlcv_history @@ -50,50 +54,47 @@ def test_historic_ohlcv(mocker, default_conf, ohlcv_history): def test_get_pair_dataframe(mocker, default_conf, ohlcv_history): default_conf["runmode"] = RunMode.DRY_RUN - ticker_interval = default_conf["ticker_interval"] + timeframe = default_conf["timeframe"] exchange = get_patched_exchange(mocker, default_conf) - exchange._klines[("XRP/BTC", ticker_interval)] = ohlcv_history - exchange._klines[("UNITTEST/BTC", ticker_interval)] = ohlcv_history + exchange._klines[("XRP/BTC", timeframe)] = ohlcv_history + exchange._klines[("UNITTEST/BTC", timeframe)] = ohlcv_history dp = DataProvider(default_conf, exchange) assert dp.runmode == RunMode.DRY_RUN - assert ohlcv_history.equals(dp.get_pair_dataframe("UNITTEST/BTC", ticker_interval)) - assert isinstance(dp.get_pair_dataframe("UNITTEST/BTC", ticker_interval), DataFrame) - assert dp.get_pair_dataframe("UNITTEST/BTC", ticker_interval) is not ohlcv_history - assert not dp.get_pair_dataframe("UNITTEST/BTC", ticker_interval).empty - assert dp.get_pair_dataframe("NONESENSE/AAA", ticker_interval).empty + assert ohlcv_history.equals(dp.get_pair_dataframe("UNITTEST/BTC", timeframe)) + assert isinstance(dp.get_pair_dataframe("UNITTEST/BTC", timeframe), DataFrame) + assert dp.get_pair_dataframe("UNITTEST/BTC", timeframe) is not ohlcv_history + assert not dp.get_pair_dataframe("UNITTEST/BTC", timeframe).empty + assert dp.get_pair_dataframe("NONESENSE/AAA", timeframe).empty # Test with and without parameter - assert dp.get_pair_dataframe("UNITTEST/BTC", - ticker_interval).equals(dp.get_pair_dataframe("UNITTEST/BTC")) + assert dp.get_pair_dataframe("UNITTEST/BTC", timeframe)\ + .equals(dp.get_pair_dataframe("UNITTEST/BTC")) default_conf["runmode"] = RunMode.LIVE dp = DataProvider(default_conf, exchange) assert dp.runmode == RunMode.LIVE - assert isinstance(dp.get_pair_dataframe("UNITTEST/BTC", ticker_interval), DataFrame) - assert dp.get_pair_dataframe("NONESENSE/AAA", ticker_interval).empty + assert isinstance(dp.get_pair_dataframe("UNITTEST/BTC", timeframe), DataFrame) + assert dp.get_pair_dataframe("NONESENSE/AAA", timeframe).empty historymock = MagicMock(return_value=ohlcv_history) mocker.patch("freqtrade.data.dataprovider.load_pair_history", historymock) default_conf["runmode"] = RunMode.BACKTEST dp = DataProvider(default_conf, exchange) assert dp.runmode == RunMode.BACKTEST - assert isinstance(dp.get_pair_dataframe("UNITTEST/BTC", ticker_interval), DataFrame) - # assert dp.get_pair_dataframe("NONESENSE/AAA", ticker_interval).empty + assert isinstance(dp.get_pair_dataframe("UNITTEST/BTC", timeframe), DataFrame) + # assert dp.get_pair_dataframe("NONESENSE/AAA", timeframe).empty def test_available_pairs(mocker, default_conf, ohlcv_history): exchange = get_patched_exchange(mocker, default_conf) - ticker_interval = default_conf["ticker_interval"] - exchange._klines[("XRP/BTC", ticker_interval)] = ohlcv_history - exchange._klines[("UNITTEST/BTC", ticker_interval)] = ohlcv_history + timeframe = default_conf["timeframe"] + exchange._klines[("XRP/BTC", timeframe)] = ohlcv_history + exchange._klines[("UNITTEST/BTC", timeframe)] = ohlcv_history dp = DataProvider(default_conf, exchange) assert len(dp.available_pairs) == 2 - assert dp.available_pairs == [ - ("XRP/BTC", ticker_interval), - ("UNITTEST/BTC", ticker_interval), - ] + assert dp.available_pairs == [("XRP/BTC", timeframe), ("UNITTEST/BTC", timeframe), ] def test_refresh(mocker, default_conf, ohlcv_history): @@ -101,10 +102,10 @@ def test_refresh(mocker, default_conf, ohlcv_history): mocker.patch("freqtrade.exchange.Exchange.refresh_latest_ohlcv", refresh_mock) exchange = get_patched_exchange(mocker, default_conf, id="binance") - ticker_interval = default_conf["ticker_interval"] - pairs = [("XRP/BTC", ticker_interval), ("UNITTEST/BTC", ticker_interval)] + timeframe = default_conf["timeframe"] + pairs = [("XRP/BTC", timeframe), ("UNITTEST/BTC", timeframe)] - pairs_non_trad = [("ETH/USDT", ticker_interval), ("BTC/TUSD", "1h")] + pairs_non_trad = [("ETH/USDT", timeframe), ("BTC/TUSD", "1h")] dp = DataProvider(default_conf, exchange) dp.refresh(pairs) @@ -152,3 +153,71 @@ def test_market(mocker, default_conf, markets): res = dp.market('UNITTEST/BTC') assert res is None + + +def test_ticker(mocker, default_conf, tickers): + ticker_mock = MagicMock(return_value=tickers()['ETH/BTC']) + mocker.patch("freqtrade.exchange.Exchange.fetch_ticker", ticker_mock) + exchange = get_patched_exchange(mocker, default_conf) + dp = DataProvider(default_conf, exchange) + res = dp.ticker('ETH/BTC') + assert type(res) is dict + assert 'symbol' in res + assert res['symbol'] == 'ETH/BTC' + + ticker_mock = MagicMock(side_effect=ExchangeError('Pair not found')) + mocker.patch("freqtrade.exchange.Exchange.fetch_ticker", ticker_mock) + exchange = get_patched_exchange(mocker, default_conf) + dp = DataProvider(default_conf, exchange) + res = dp.ticker('UNITTEST/BTC') + assert res == {} + + +def test_current_whitelist(mocker, default_conf, tickers): + # patch default conf to volumepairlist + default_conf['pairlists'][0] = {'method': 'VolumePairList', "number_assets": 5} + + mocker.patch.multiple('freqtrade.exchange.Exchange', + exchange_has=MagicMock(return_value=True), + get_tickers=tickers) + exchange = get_patched_exchange(mocker, default_conf) + + pairlist = PairListManager(exchange, default_conf) + dp = DataProvider(default_conf, exchange, pairlist) + + # Simulate volumepairs from exchange. + pairlist.refresh_pairlist() + + assert dp.current_whitelist() == pairlist._whitelist + # The identity of the 2 lists should be identical + assert dp.current_whitelist() is pairlist._whitelist + + with pytest.raises(OperationalException): + dp = DataProvider(default_conf, exchange) + dp.current_whitelist() + + +def test_get_analyzed_dataframe(mocker, default_conf, ohlcv_history): + + default_conf["runmode"] = RunMode.DRY_RUN + + timeframe = default_conf["timeframe"] + exchange = get_patched_exchange(mocker, default_conf) + + dp = DataProvider(default_conf, exchange) + dp._set_cached_df("XRP/BTC", timeframe, ohlcv_history) + dp._set_cached_df("UNITTEST/BTC", timeframe, ohlcv_history) + + assert dp.runmode == RunMode.DRY_RUN + dataframe, time = dp.get_analyzed_dataframe("UNITTEST/BTC", timeframe) + assert ohlcv_history.equals(dataframe) + assert isinstance(time, datetime) + + dataframe, time = dp.get_analyzed_dataframe("XRP/BTC", timeframe) + assert ohlcv_history.equals(dataframe) + assert isinstance(time, datetime) + + dataframe, time = dp.get_analyzed_dataframe("NOTHING/BTC", timeframe) + assert dataframe.empty + assert isinstance(time, datetime) + assert time == datetime(1970, 1, 1, tzinfo=timezone.utc) diff --git a/tests/data/test_history.py b/tests/data/test_history.py index 12390538a..d84c212b1 100644 --- a/tests/data/test_history.py +++ b/tests/data/test_history.py @@ -354,7 +354,7 @@ def test_init(default_conf, mocker) -> None: assert {} == load_data( datadir=Path(''), pairs=[], - timeframe=default_conf['ticker_interval'] + timeframe=default_conf['timeframe'] ) @@ -363,13 +363,13 @@ def test_init_with_refresh(default_conf, mocker) -> None: refresh_data( datadir=Path(''), pairs=[], - timeframe=default_conf['ticker_interval'], + timeframe=default_conf['timeframe'], exchange=exchange ) assert {} == load_data( datadir=Path(''), pairs=[], - timeframe=default_conf['ticker_interval'] + timeframe=default_conf['timeframe'] ) @@ -547,6 +547,18 @@ def test_download_trades_history(trades_history, mocker, default_conf, testdatad assert log_has("New Amount of trades: 5", caplog) assert file1.is_file() + ght_mock.reset_mock() + since_time = int(trades_history[-3][0] // 1000) + since_time2 = int(trades_history[-1][0] // 1000) + timerange = TimeRange('date', None, since_time, 0) + assert _download_trades_history(data_handler=data_handler, exchange=exchange, + pair='ETH/BTC', timerange=timerange) + + assert ght_mock.call_count == 1 + # Check this in seconds - since we had to convert to seconds above too. + assert int(ght_mock.call_args_list[0][1]['since'] // 1000) == since_time2 - 5 + assert ght_mock.call_args_list[0][1]['from_id'] is not None + # clean files freshly downloaded _clean_test_file(file1) @@ -557,6 +569,27 @@ def test_download_trades_history(trades_history, mocker, default_conf, testdatad pair='ETH/BTC') assert log_has_re('Failed to download historic trades for pair: "ETH/BTC".*', caplog) + file2 = testdatadir / 'XRP_ETH-trades.json.gz' + + _backup_file(file2, True) + + ght_mock.reset_mock() + mocker.patch('freqtrade.exchange.Exchange.get_historic_trades', + ght_mock) + # Since before first start date + since_time = int(trades_history[0][0] // 1000) - 500 + timerange = TimeRange('date', None, since_time, 0) + + assert _download_trades_history(data_handler=data_handler, exchange=exchange, + pair='XRP/ETH', timerange=timerange) + + assert ght_mock.call_count == 1 + + assert int(ght_mock.call_args_list[0][1]['since'] // 1000) == since_time + assert ght_mock.call_args_list[0][1]['from_id'] is None + assert log_has_re(r'Start earlier than available data. Redownloading trades for.*', caplog) + _clean_test_file(file2) + def test_convert_trades_to_ohlcv(mocker, default_conf, testdatadir, caplog): @@ -598,10 +631,24 @@ def test_jsondatahandler_ohlcv_get_pairs(testdatadir): assert set(pairs) == {'UNITTEST/BTC'} +def test_jsondatahandler_ohlcv_get_available_data(testdatadir): + paircombs = JsonDataHandler.ohlcv_get_available_data(testdatadir) + # Convert to set to avoid failures due to sorting + assert set(paircombs) == {('UNITTEST/BTC', '5m'), ('ETH/BTC', '5m'), ('XLM/BTC', '5m'), + ('TRX/BTC', '5m'), ('LTC/BTC', '5m'), ('XMR/BTC', '5m'), + ('ZEC/BTC', '5m'), ('UNITTEST/BTC', '1m'), ('ADA/BTC', '5m'), + ('ETC/BTC', '5m'), ('NXT/BTC', '5m'), ('DASH/BTC', '5m'), + ('XRP/ETH', '1m'), ('XRP/ETH', '5m'), ('UNITTEST/BTC', '30m'), + ('UNITTEST/BTC', '8m')} + + paircombs = JsonGzDataHandler.ohlcv_get_available_data(testdatadir) + assert set(paircombs) == {('UNITTEST/BTC', '8m')} + + def test_jsondatahandler_trades_get_pairs(testdatadir): pairs = JsonGzDataHandler.trades_get_pairs(testdatadir) # Convert to set to avoid failures due to sorting - assert set(pairs) == {'XRP/ETH'} + assert set(pairs) == {'XRP/ETH', 'XRP/OLD'} def test_jsondatahandler_ohlcv_purge(mocker, testdatadir): @@ -614,6 +661,17 @@ def test_jsondatahandler_ohlcv_purge(mocker, testdatadir): assert dh.ohlcv_purge('UNITTEST/NONEXIST', '5m') +def test_jsondatahandler_trades_load(mocker, testdatadir, caplog): + dh = JsonGzDataHandler(testdatadir) + logmsg = "Old trades format detected - converting" + dh.trades_load('XRP/ETH') + assert not log_has(logmsg, caplog) + + # Test conversation is happening + dh.trades_load('XRP/OLD') + assert log_has(logmsg, caplog) + + def test_jsondatahandler_trades_purge(mocker, testdatadir): mocker.patch.object(Path, "exists", MagicMock(return_value=False)) mocker.patch.object(Path, "unlink", MagicMock()) diff --git a/tests/edge/test_edge.py b/tests/edge/test_edge.py index 2304c53c2..7373778ad 100644 --- a/tests/edge/test_edge.py +++ b/tests/edge/test_edge.py @@ -27,7 +27,7 @@ from tests.optimize import (BTContainer, BTrade, _build_backtest_dataframe, #################################################################### tests_start_time = arrow.get(2018, 10, 3) -ticker_interval_in_minute = 60 +timeframe_in_minute = 60 _ohlc = {'date': 0, 'buy': 1, 'open': 2, 'high': 3, 'low': 4, 'close': 5, 'sell': 6, 'volume': 7} # Helpers for this test file @@ -49,7 +49,7 @@ def _build_dataframe(buy_ohlc_sell_matrice): 'date': tests_start_time.shift( minutes=( ohlc[0] * - ticker_interval_in_minute)).timestamp * + timeframe_in_minute)).timestamp * 1000, 'buy': ohlc[1], 'open': ohlc[2], @@ -70,7 +70,7 @@ def _build_dataframe(buy_ohlc_sell_matrice): def _time_on_candle(number): return np.datetime64(tests_start_time.shift( - minutes=(number * ticker_interval_in_minute)).timestamp * 1000, 'ms') + minutes=(number * timeframe_in_minute)).timestamp * 1000, 'ms') # End helper functions @@ -262,7 +262,7 @@ def mocked_load_data(datadir, pairs=[], timeframe='0m', NEOBTC = [ [ - tests_start_time.shift(minutes=(x * ticker_interval_in_minute)).timestamp * 1000, + tests_start_time.shift(minutes=(x * timeframe_in_minute)).timestamp * 1000, math.sin(x * hz) / 1000 + base, math.sin(x * hz) / 1000 + base + 0.0001, math.sin(x * hz) / 1000 + base - 0.0001, @@ -274,7 +274,7 @@ def mocked_load_data(datadir, pairs=[], timeframe='0m', base = 0.002 LTCBTC = [ [ - tests_start_time.shift(minutes=(x * ticker_interval_in_minute)).timestamp * 1000, + tests_start_time.shift(minutes=(x * timeframe_in_minute)).timestamp * 1000, math.sin(x * hz) / 1000 + base, math.sin(x * hz) / 1000 + base + 0.0001, math.sin(x * hz) / 1000 + base - 0.0001, @@ -335,12 +335,16 @@ def test_edge_init_error(mocker, edge_conf,): get_patched_freqtradebot(mocker, edge_conf) -def test_process_expectancy(mocker, edge_conf): +@pytest.mark.parametrize("fee,risk_reward_ratio,expectancy", [ + (0.0005, 306.5384615384, 101.5128205128), + (0.001, 152.6923076923, 50.2307692308), +]) +def test_process_expectancy(mocker, edge_conf, fee, risk_reward_ratio, expectancy): edge_conf['edge']['min_trade_number'] = 2 freqtrade = get_patched_freqtradebot(mocker, edge_conf) def get_fee(*args, **kwargs): - return 0.001 + return fee freqtrade.exchange.get_fee = get_fee edge = Edge(edge_conf, freqtrade.exchange, freqtrade.strategy) @@ -394,9 +398,9 @@ def test_process_expectancy(mocker, edge_conf): assert 'TEST/BTC' in final assert final['TEST/BTC'].stoploss == -0.9 assert round(final['TEST/BTC'].winrate, 10) == 0.3333333333 - assert round(final['TEST/BTC'].risk_reward_ratio, 10) == 306.5384615384 + assert round(final['TEST/BTC'].risk_reward_ratio, 10) == risk_reward_ratio assert round(final['TEST/BTC'].required_risk_reward, 10) == 2.0 - assert round(final['TEST/BTC'].expectancy, 10) == 101.5128205128 + assert round(final['TEST/BTC'].expectancy, 10) == expectancy # Pop last item so no trade is profitable trades.pop() @@ -405,3 +409,98 @@ def test_process_expectancy(mocker, edge_conf): final = edge._process_expectancy(trades_df) assert len(final) == 0 assert isinstance(final, dict) + + +def test_process_expectancy_remove_pumps(mocker, edge_conf, fee,): + edge_conf['edge']['min_trade_number'] = 2 + edge_conf['edge']['remove_pumps'] = True + freqtrade = get_patched_freqtradebot(mocker, edge_conf) + + freqtrade.exchange.get_fee = fee + edge = Edge(edge_conf, freqtrade.exchange, freqtrade.strategy) + + trades = [ + {'pair': 'TEST/BTC', + 'stoploss': -0.9, + 'profit_percent': '', + 'profit_abs': '', + 'open_time': np.datetime64('2018-10-03T00:05:00.000000000'), + 'close_time': np.datetime64('2018-10-03T00:10:00.000000000'), + 'open_index': 1, + 'close_index': 1, + 'trade_duration': '', + 'open_rate': 17, + 'close_rate': 15, + 'exit_type': 'sell_signal'}, + + {'pair': 'TEST/BTC', + 'stoploss': -0.9, + 'profit_percent': '', + 'profit_abs': '', + 'open_time': np.datetime64('2018-10-03T00:20:00.000000000'), + 'close_time': np.datetime64('2018-10-03T00:25:00.000000000'), + 'open_index': 4, + 'close_index': 4, + 'trade_duration': '', + 'open_rate': 20, + 'close_rate': 10, + 'exit_type': 'sell_signal'}, + {'pair': 'TEST/BTC', + 'stoploss': -0.9, + 'profit_percent': '', + 'profit_abs': '', + 'open_time': np.datetime64('2018-10-03T00:20:00.000000000'), + 'close_time': np.datetime64('2018-10-03T00:25:00.000000000'), + 'open_index': 4, + 'close_index': 4, + 'trade_duration': '', + 'open_rate': 20, + 'close_rate': 10, + 'exit_type': 'sell_signal'}, + {'pair': 'TEST/BTC', + 'stoploss': -0.9, + 'profit_percent': '', + 'profit_abs': '', + 'open_time': np.datetime64('2018-10-03T00:20:00.000000000'), + 'close_time': np.datetime64('2018-10-03T00:25:00.000000000'), + 'open_index': 4, + 'close_index': 4, + 'trade_duration': '', + 'open_rate': 20, + 'close_rate': 10, + 'exit_type': 'sell_signal'}, + {'pair': 'TEST/BTC', + 'stoploss': -0.9, + 'profit_percent': '', + 'profit_abs': '', + 'open_time': np.datetime64('2018-10-03T00:20:00.000000000'), + 'close_time': np.datetime64('2018-10-03T00:25:00.000000000'), + 'open_index': 4, + 'close_index': 4, + 'trade_duration': '', + 'open_rate': 20, + 'close_rate': 10, + 'exit_type': 'sell_signal'}, + + {'pair': 'TEST/BTC', + 'stoploss': -0.9, + 'profit_percent': '', + 'profit_abs': '', + 'open_time': np.datetime64('2018-10-03T00:30:00.000000000'), + 'close_time': np.datetime64('2018-10-03T00:40:00.000000000'), + 'open_index': 6, + 'close_index': 7, + 'trade_duration': '', + 'open_rate': 26, + 'close_rate': 134, + 'exit_type': 'sell_signal'} + ] + + trades_df = DataFrame(trades) + trades_df = edge._fill_calculable_fields(trades_df) + final = edge._process_expectancy(trades_df) + + assert 'TEST/BTC' in final + assert final['TEST/BTC'].stoploss == -0.9 + assert final['TEST/BTC'].nb_trades == len(trades_df) - 1 + assert round(final['TEST/BTC'].winrate, 10) == 0.0 diff --git a/tests/exchange/test_binance.py b/tests/exchange/test_binance.py index e4599dcd7..72da708b4 100644 --- a/tests/exchange/test_binance.py +++ b/tests/exchange/test_binance.py @@ -5,11 +5,17 @@ import ccxt import pytest from freqtrade.exceptions import (DependencyException, InvalidOrderException, - OperationalException, TemporaryError) + OperationalException) from tests.conftest import get_patched_exchange +from tests.exchange.test_exchange import ccxt_exceptionhandlers -def test_stoploss_order_binance(default_conf, mocker): +@pytest.mark.parametrize('limitratio,expected', [ + (None, 220 * 0.99), + (0.99, 220 * 0.99), + (0.98, 220 * 0.98), +]) +def test_stoploss_order_binance(default_conf, mocker, limitratio, expected): api_mock = MagicMock() order_id = 'test_prod_buy_{}'.format(randint(0, 10 ** 6)) order_type = 'stop_loss_limit' @@ -20,7 +26,6 @@ def test_stoploss_order_binance(default_conf, mocker): 'foo': 'bar' } }) - default_conf['dry_run'] = False mocker.patch('freqtrade.exchange.Exchange.amount_to_precision', lambda s, x, y: y) mocker.patch('freqtrade.exchange.Exchange.price_to_precision', lambda s, x, y: y) @@ -32,8 +37,8 @@ def test_stoploss_order_binance(default_conf, mocker): order_types={'stoploss_on_exchange_limit_ratio': 1.05}) api_mock.create_order.reset_mock() - - order = exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) + order_types = {} if limitratio is None else {'stoploss_on_exchange_limit_ratio': limitratio} + order = exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types=order_types) assert 'id' in order assert 'info' in order @@ -42,7 +47,8 @@ def test_stoploss_order_binance(default_conf, mocker): assert api_mock.create_order.call_args_list[0][1]['type'] == order_type assert api_mock.create_order.call_args_list[0][1]['side'] == 'sell' assert api_mock.create_order.call_args_list[0][1]['amount'] == 1 - assert api_mock.create_order.call_args_list[0][1]['price'] == 220 + # Price should be 1% below stopprice + assert api_mock.create_order.call_args_list[0][1]['price'] == expected assert api_mock.create_order.call_args_list[0][1]['params'] == {'stopPrice': 220} # test exception handling @@ -57,15 +63,9 @@ def test_stoploss_order_binance(default_conf, mocker): exchange = get_patched_exchange(mocker, default_conf, api_mock, 'binance') exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) - with pytest.raises(TemporaryError): - api_mock.create_order = MagicMock(side_effect=ccxt.NetworkError("No connection")) - exchange = get_patched_exchange(mocker, default_conf, api_mock, 'binance') - exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) - - with pytest.raises(OperationalException, match=r".*DeadBeef.*"): - api_mock.create_order = MagicMock(side_effect=ccxt.BaseError("DeadBeef")) - exchange = get_patched_exchange(mocker, default_conf, api_mock, 'binance') - exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) + ccxt_exceptionhandlers(mocker, default_conf, api_mock, "binance", + "stoploss", "create_order", retries=1, + pair='ETH/BTC', amount=1, stop_price=220, order_types={}) def test_stoploss_order_dry_run_binance(default_conf, mocker): diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 3c92612a0..350c2d3cb 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -4,17 +4,17 @@ import copy import logging from datetime import datetime, timezone from random import randint -from unittest.mock import MagicMock, Mock, PropertyMock +from unittest.mock import MagicMock, Mock, PropertyMock, patch import arrow import ccxt import pytest from pandas import DataFrame -from freqtrade.exceptions import (DependencyException, InvalidOrderException, +from freqtrade.exceptions import (DependencyException, InvalidOrderException, DDosProtection, OperationalException, TemporaryError) from freqtrade.exchange import Binance, Exchange, Kraken -from freqtrade.exchange.common import API_RETRY_COUNT +from freqtrade.exchange.common import API_RETRY_COUNT, calculate_backoff from freqtrade.exchange.exchange import (market_is_active, symbol_is_pair, timeframe_to_minutes, timeframe_to_msecs, @@ -25,7 +25,7 @@ from freqtrade.resolvers.exchange_resolver import ExchangeResolver from tests.conftest import get_patched_exchange, log_has, log_has_re # Make sure to always keep one exchange here which is NOT subclassed!! -EXCHANGES = ['bittrex', 'binance', 'kraken', ] +EXCHANGES = ['bittrex', 'binance', 'kraken', 'ftx'] # Source: https://stackoverflow.com/questions/29881236/how-to-mock-asyncio-coroutines @@ -37,12 +37,20 @@ def get_mock_coro(return_value): def ccxt_exceptionhandlers(mocker, default_conf, api_mock, exchange_name, - fun, mock_ccxt_fun, **kwargs): + fun, mock_ccxt_fun, retries=API_RETRY_COUNT + 1, **kwargs): + + with patch('freqtrade.exchange.common.time.sleep'): + with pytest.raises(DDosProtection): + api_mock.__dict__[mock_ccxt_fun] = MagicMock(side_effect=ccxt.DDoSProtection("DDos")) + exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) + getattr(exchange, fun)(**kwargs) + assert api_mock.__dict__[mock_ccxt_fun].call_count == retries + with pytest.raises(TemporaryError): api_mock.__dict__[mock_ccxt_fun] = MagicMock(side_effect=ccxt.NetworkError("DeaDBeef")) exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) getattr(exchange, fun)(**kwargs) - assert api_mock.__dict__[mock_ccxt_fun].call_count == API_RETRY_COUNT + 1 + assert api_mock.__dict__[mock_ccxt_fun].call_count == retries with pytest.raises(OperationalException): api_mock.__dict__[mock_ccxt_fun] = MagicMock(side_effect=ccxt.BaseError("DeadBeef")) @@ -51,12 +59,21 @@ def ccxt_exceptionhandlers(mocker, default_conf, api_mock, exchange_name, assert api_mock.__dict__[mock_ccxt_fun].call_count == 1 -async def async_ccxt_exception(mocker, default_conf, api_mock, fun, mock_ccxt_fun, **kwargs): +async def async_ccxt_exception(mocker, default_conf, api_mock, fun, mock_ccxt_fun, + retries=API_RETRY_COUNT + 1, **kwargs): + + with patch('freqtrade.exchange.common.asyncio.sleep', get_mock_coro(None)): + with pytest.raises(DDosProtection): + api_mock.__dict__[mock_ccxt_fun] = MagicMock(side_effect=ccxt.DDoSProtection("Dooh")) + exchange = get_patched_exchange(mocker, default_conf, api_mock) + await getattr(exchange, fun)(**kwargs) + assert api_mock.__dict__[mock_ccxt_fun].call_count == retries + with pytest.raises(TemporaryError): api_mock.__dict__[mock_ccxt_fun] = MagicMock(side_effect=ccxt.NetworkError("DeadBeef")) exchange = get_patched_exchange(mocker, default_conf, api_mock) await getattr(exchange, fun)(**kwargs) - assert api_mock.__dict__[mock_ccxt_fun].call_count == API_RETRY_COUNT + 1 + assert api_mock.__dict__[mock_ccxt_fun].call_count == retries with pytest.raises(OperationalException): api_mock.__dict__[mock_ccxt_fun] = MagicMock(side_effect=ccxt.BaseError("DeadBeef")) @@ -88,15 +105,19 @@ def test_init_ccxt_kwargs(default_conf, mocker, caplog): caplog.clear() conf = copy.deepcopy(default_conf) conf['exchange']['ccxt_config'] = {'TestKWARG': 11} + conf['exchange']['ccxt_sync_config'] = {'TestKWARG44': 11} conf['exchange']['ccxt_async_config'] = {'asyncio_loop': True} - + asynclogmsg = "Applying additional ccxt config: {'TestKWARG': 11, 'asyncio_loop': True}" ex = Exchange(conf) - assert not log_has("Applying additional ccxt config: {'aiohttp_trust_env': True}", caplog) assert not ex._api_async.aiohttp_trust_env assert hasattr(ex._api, 'TestKWARG') assert ex._api.TestKWARG == 11 - assert not hasattr(ex._api_async, 'TestKWARG') - assert log_has("Applying additional ccxt config: {'TestKWARG': 11}", caplog) + # ccxt_config is assigned to both sync and async + assert not hasattr(ex._api_async, 'TestKWARG44') + + assert hasattr(ex._api_async, 'TestKWARG') + assert log_has("Applying additional ccxt config: {'TestKWARG': 11, 'TestKWARG44': 11}", caplog) + assert log_has(asynclogmsg, caplog) def test_destroy(default_conf, mocker, caplog): @@ -315,7 +336,12 @@ def test_set_sandbox_exception(default_conf, mocker): def test__load_async_markets(default_conf, mocker, caplog): - exchange = get_patched_exchange(mocker, default_conf) + mocker.patch('freqtrade.exchange.Exchange._init_ccxt') + mocker.patch('freqtrade.exchange.Exchange.validate_pairs') + mocker.patch('freqtrade.exchange.Exchange.validate_timeframes') + mocker.patch('freqtrade.exchange.Exchange._load_markets') + mocker.patch('freqtrade.exchange.Exchange.validate_stakecurrency') + exchange = Exchange(default_conf) exchange._api_async.load_markets = get_mock_coro(None) exchange._load_async_markets() assert exchange._api_async.load_markets.call_count == 1 @@ -348,7 +374,7 @@ 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): caplog.set_level(logging.DEBUG) initial_markets = {'ETH/BTC': {}} @@ -361,23 +387,26 @@ def test__reload_markets(default_conf, mocker, caplog): 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().timestamp updated_markets = {'ETH/BTC': {}, "LTC/BTC": {}} assert exchange.markets == initial_markets # less than 10 minutes have passed, no reload - exchange._reload_markets() + exchange.reload_markets() assert exchange.markets == initial_markets + assert exchange._load_async_markets.call_count == 0 # more than 10 minutes have passed, reload is executed exchange._last_markets_refresh = arrow.utcnow().timestamp - 15 * 60 - exchange._reload_markets() + exchange.reload_markets() assert exchange.markets == updated_markets + assert exchange._load_async_markets.call_count == 1 assert log_has('Performing scheduled market reload..', caplog) -def test__reload_markets_exception(default_conf, mocker, caplog): +def test_reload_markets_exception(default_conf, mocker, caplog): caplog.set_level(logging.DEBUG) api_mock = MagicMock() @@ -386,7 +415,7 @@ def test__reload_markets_exception(default_conf, mocker, caplog): exchange = get_patched_exchange(mocker, default_conf, api_mock, id="binance") # less than 10 minutes have passed, no reload - exchange._reload_markets() + exchange.reload_markets() assert exchange._last_markets_refresh == 0 assert log_has_re(r"Could not reload markets.*", caplog) @@ -517,9 +546,9 @@ def test_validate_pairs_restricted(default_conf, mocker, caplog): mocker.patch('freqtrade.exchange.Exchange.validate_stakecurrency') Exchange(default_conf) - assert log_has(f"Pair XRP/BTC is restricted for some users on this exchange." - f"Please check if you are impacted by this restriction " - f"on the exchange and eventually remove XRP/BTC from your whitelist.", caplog) + assert log_has("Pair XRP/BTC is restricted for some users on this exchange." + "Please check if you are impacted by this restriction " + "on the exchange and eventually remove XRP/BTC from your whitelist.", caplog) def test_validate_pairs_stakecompatibility(default_conf, mocker, caplog): @@ -574,7 +603,7 @@ def test_validate_pairs_stakecompatibility_fail(default_conf, mocker, caplog): ('5m'), ("1m"), ("15m"), ("1h") ]) def test_validate_timeframes(default_conf, mocker, timeframe): - default_conf["ticker_interval"] = timeframe + default_conf["timeframe"] = timeframe api_mock = MagicMock() id_mock = PropertyMock(return_value='test_exchange') type(api_mock).id = id_mock @@ -592,7 +621,7 @@ def test_validate_timeframes(default_conf, mocker, timeframe): def test_validate_timeframes_failed(default_conf, mocker): - default_conf["ticker_interval"] = "3m" + default_conf["timeframe"] = "3m" api_mock = MagicMock() id_mock = PropertyMock(return_value='test_exchange') type(api_mock).id = id_mock @@ -609,7 +638,7 @@ def test_validate_timeframes_failed(default_conf, mocker): with pytest.raises(OperationalException, match=r"Invalid timeframe '3m'. This exchange supports.*"): Exchange(default_conf) - default_conf["ticker_interval"] = "15s" + default_conf["timeframe"] = "15s" with pytest.raises(OperationalException, match=r"Timeframes < 1m are currently not supported by Freqtrade."): @@ -617,7 +646,7 @@ def test_validate_timeframes_failed(default_conf, mocker): def test_validate_timeframes_emulated_ohlcv_1(default_conf, mocker): - default_conf["ticker_interval"] = "3m" + default_conf["timeframe"] = "3m" api_mock = MagicMock() id_mock = PropertyMock(return_value='test_exchange') type(api_mock).id = id_mock @@ -637,7 +666,7 @@ def test_validate_timeframes_emulated_ohlcv_1(default_conf, mocker): def test_validate_timeframes_emulated_ohlcvi_2(default_conf, mocker): - default_conf["ticker_interval"] = "3m" + default_conf["timeframe"] = "3m" api_mock = MagicMock() id_mock = PropertyMock(return_value='test_exchange') type(api_mock).id = id_mock @@ -658,7 +687,7 @@ def test_validate_timeframes_emulated_ohlcvi_2(default_conf, mocker): def test_validate_timeframes_not_in_config(default_conf, mocker): - del default_conf["ticker_interval"] + del default_conf["timeframe"] api_mock = MagicMock() id_mock = PropertyMock(return_value='test_exchange') type(api_mock).id = id_mock @@ -685,13 +714,13 @@ def test_validate_order_types(default_conf, mocker): mocker.patch('freqtrade.exchange.Exchange.validate_timeframes') mocker.patch('freqtrade.exchange.Exchange.validate_stakecurrency') mocker.patch('freqtrade.exchange.Exchange.name', 'Bittrex') + default_conf['order_types'] = { 'buy': 'limit', 'sell': 'limit', 'stoploss': 'market', 'stoploss_on_exchange': False } - Exchange(default_conf) type(api_mock).has = PropertyMock(return_value={'createMarketOrder': False}) @@ -701,9 +730,8 @@ def test_validate_order_types(default_conf, mocker): 'buy': 'limit', 'sell': 'limit', 'stoploss': 'market', - 'stoploss_on_exchange': 'false' + 'stoploss_on_exchange': False } - with pytest.raises(OperationalException, match=r'Exchange .* does not support market orders.'): Exchange(default_conf) @@ -714,7 +742,6 @@ def test_validate_order_types(default_conf, mocker): 'stoploss': 'limit', 'stoploss_on_exchange': True } - with pytest.raises(OperationalException, match=r'On exchange stoploss is not supported for .*'): Exchange(default_conf) @@ -1115,9 +1142,10 @@ def test_get_balance_prod(default_conf, mocker, exchange_name): exchange.get_balance(currency='BTC') -def test_get_balances_dry_run(default_conf, mocker): +@pytest.mark.parametrize("exchange_name", EXCHANGES) +def test_get_balances_dry_run(default_conf, mocker, exchange_name): default_conf['dry_run'] = True - exchange = get_patched_exchange(mocker, default_conf) + exchange = get_patched_exchange(mocker, default_conf, id=exchange_name) assert exchange.get_balances() == {} @@ -1254,7 +1282,8 @@ def test_get_historic_ohlcv(default_conf, mocker, caplog, exchange_name): exchange._async_get_candle_history = Mock(wraps=mock_candle_hist) # one_call calculation * 1.8 should do 2 calls - since = 5 * 60 * 500 * 1.8 + + since = 5 * 60 * exchange._ft_has['ohlcv_candle_limit'] * 1.8 ret = exchange.get_historic_ohlcv(pair, "5m", int((arrow.utcnow().timestamp - since) * 1000)) assert exchange._async_get_candle_history.call_count == 2 @@ -1346,7 +1375,7 @@ async def test__async_get_candle_history(default_conf, mocker, caplog, exchange_ # exchange = Exchange(default_conf) await async_ccxt_exception(mocker, default_conf, MagicMock(), "_async_get_candle_history", "fetch_ohlcv", - pair='ABCD/BTC', timeframe=default_conf['ticker_interval']) + pair='ABCD/BTC', timeframe=default_conf['timeframe']) api_mock = MagicMock() with pytest.raises(OperationalException, @@ -1413,13 +1442,13 @@ def test_refresh_latest_ohlcv_inv_result(default_conf, mocker, caplog): @pytest.mark.parametrize("exchange_name", EXCHANGES) -def test_get_order_book(default_conf, mocker, order_book_l2, exchange_name): +def test_fetch_l2_order_book(default_conf, mocker, order_book_l2, exchange_name): default_conf['exchange']['name'] = exchange_name api_mock = MagicMock() api_mock.fetch_l2_order_book = order_book_l2 exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) - order_book = exchange.get_order_book(pair='ETH/BTC', limit=10) + order_book = exchange.fetch_l2_order_book(pair='ETH/BTC', limit=10) assert 'bids' in order_book assert 'asks' in order_book assert len(order_book['bids']) == 10 @@ -1427,20 +1456,20 @@ def test_get_order_book(default_conf, mocker, order_book_l2, exchange_name): @pytest.mark.parametrize("exchange_name", EXCHANGES) -def test_get_order_book_exception(default_conf, mocker, exchange_name): +def test_fetch_l2_order_book_exception(default_conf, mocker, exchange_name): api_mock = MagicMock() with pytest.raises(OperationalException): api_mock.fetch_l2_order_book = MagicMock(side_effect=ccxt.NotSupported("Not supported")) exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) - exchange.get_order_book(pair='ETH/BTC', limit=50) + exchange.fetch_l2_order_book(pair='ETH/BTC', limit=50) with pytest.raises(TemporaryError): api_mock.fetch_l2_order_book = MagicMock(side_effect=ccxt.NetworkError("DeadBeef")) exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) - exchange.get_order_book(pair='ETH/BTC', limit=50) + exchange.fetch_l2_order_book(pair='ETH/BTC', limit=50) with pytest.raises(OperationalException): api_mock.fetch_l2_order_book = MagicMock(side_effect=ccxt.BaseError("DeadBeef")) exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) - exchange.get_order_book(pair='ETH/BTC', limit=50) + exchange.fetch_l2_order_book(pair='ETH/BTC', limit=50) def make_fetch_ohlcv_mock(data): @@ -1476,7 +1505,7 @@ async def test___async_get_candle_history_sort(default_conf, mocker, exchange_na exchange._api_async.fetch_ohlcv = get_mock_coro(ohlcv) sort_mock = mocker.patch('freqtrade.exchange.exchange.sorted', MagicMock(side_effect=sort_data)) # Test the OHLCV data sort - res = await exchange._async_get_candle_history('ETH/BTC', default_conf['ticker_interval']) + res = await exchange._async_get_candle_history('ETH/BTC', default_conf['timeframe']) assert res[0] == 'ETH/BTC' res_ohlcv = res[2] @@ -1513,9 +1542,9 @@ async def test___async_get_candle_history_sort(default_conf, mocker, exchange_na # Reset sort mock sort_mock = mocker.patch('freqtrade.exchange.sorted', MagicMock(side_effect=sort_data)) # Test the OHLCV data sort - res = await exchange._async_get_candle_history('ETH/BTC', default_conf['ticker_interval']) + res = await exchange._async_get_candle_history('ETH/BTC', default_conf['timeframe']) assert res[0] == 'ETH/BTC' - assert res[1] == default_conf['ticker_interval'] + assert res[1] == default_conf['timeframe'] res_ohlcv = res[2] # Sorted not called again - data is already in order assert sort_mock.call_count == 0 @@ -1537,18 +1566,18 @@ async def test___async_get_candle_history_sort(default_conf, mocker, exchange_na @pytest.mark.asyncio @pytest.mark.parametrize("exchange_name", EXCHANGES) async def test__async_fetch_trades(default_conf, mocker, caplog, exchange_name, - trades_history): + fetch_trades_result): caplog.set_level(logging.DEBUG) exchange = get_patched_exchange(mocker, default_conf, id=exchange_name) # Monkey-patch async function - exchange._api_async.fetch_trades = get_mock_coro(trades_history) + exchange._api_async.fetch_trades = get_mock_coro(fetch_trades_result) pair = 'ETH/BTC' res = await exchange._async_fetch_trades(pair, since=None, params=None) assert type(res) is list - assert isinstance(res[0], dict) - assert isinstance(res[1], dict) + assert isinstance(res[0], list) + assert isinstance(res[1], list) assert exchange._api_async.fetch_trades.call_count == 1 assert exchange._api_async.fetch_trades.call_args[0][0] == pair @@ -1594,7 +1623,7 @@ async def test__async_get_trade_history_id(default_conf, mocker, caplog, exchang if 'since' in kwargs: # Return first 3 return trades_history[:-2] - elif kwargs.get('params', {}).get(pagination_arg) == trades_history[-3]['id']: + elif kwargs.get('params', {}).get(pagination_arg) == trades_history[-3][1]: # Return 2 return trades_history[-3:-1] else: @@ -1604,8 +1633,8 @@ async def test__async_get_trade_history_id(default_conf, mocker, caplog, exchang exchange._async_fetch_trades = MagicMock(side_effect=mock_get_trade_hist) pair = 'ETH/BTC' - ret = await exchange._async_get_trade_history_id(pair, since=trades_history[0]["timestamp"], - until=trades_history[-1]["timestamp"]-1) + ret = await exchange._async_get_trade_history_id(pair, since=trades_history[0][0], + until=trades_history[-1][0]-1) assert type(ret) is tuple assert ret[0] == pair assert type(ret[1]) is list @@ -1614,7 +1643,7 @@ async def test__async_get_trade_history_id(default_conf, mocker, caplog, exchang fetch_trades_cal = exchange._async_fetch_trades.call_args_list # first call (using since, not fromId) assert fetch_trades_cal[0][0][0] == pair - assert fetch_trades_cal[0][1]['since'] == trades_history[0]["timestamp"] + assert fetch_trades_cal[0][1]['since'] == trades_history[0][0] # 2nd call assert fetch_trades_cal[1][0][0] == pair @@ -1630,7 +1659,7 @@ async def test__async_get_trade_history_time(default_conf, mocker, caplog, excha caplog.set_level(logging.DEBUG) async def mock_get_trade_hist(pair, *args, **kwargs): - if kwargs['since'] == trades_history[0]["timestamp"]: + if kwargs['since'] == trades_history[0][0]: return trades_history[:-1] else: return trades_history[-1:] @@ -1640,8 +1669,8 @@ async def test__async_get_trade_history_time(default_conf, mocker, caplog, excha # Monkey-patch async function exchange._async_fetch_trades = MagicMock(side_effect=mock_get_trade_hist) pair = 'ETH/BTC' - ret = await exchange._async_get_trade_history_time(pair, since=trades_history[0]["timestamp"], - until=trades_history[-1]["timestamp"]-1) + ret = await exchange._async_get_trade_history_time(pair, since=trades_history[0][0], + until=trades_history[-1][0]-1) assert type(ret) is tuple assert ret[0] == pair assert type(ret[1]) is list @@ -1650,11 +1679,11 @@ async def test__async_get_trade_history_time(default_conf, mocker, caplog, excha fetch_trades_cal = exchange._async_fetch_trades.call_args_list # first call (using since, not fromId) assert fetch_trades_cal[0][0][0] == pair - assert fetch_trades_cal[0][1]['since'] == trades_history[0]["timestamp"] + assert fetch_trades_cal[0][1]['since'] == trades_history[0][0] # 2nd call assert fetch_trades_cal[1][0][0] == pair - assert fetch_trades_cal[0][1]['since'] == trades_history[0]["timestamp"] + assert fetch_trades_cal[0][1]['since'] == trades_history[0][0] assert log_has_re(r"Stopping because until was reached.*", caplog) @@ -1666,7 +1695,7 @@ async def test__async_get_trade_history_time_empty(default_conf, mocker, caplog, caplog.set_level(logging.DEBUG) async def mock_get_trade_hist(pair, *args, **kwargs): - if kwargs['since'] == trades_history[0]["timestamp"]: + if kwargs['since'] == trades_history[0][0]: return trades_history[:-1] else: return [] @@ -1676,8 +1705,8 @@ async def test__async_get_trade_history_time_empty(default_conf, mocker, caplog, # Monkey-patch async function exchange._async_fetch_trades = MagicMock(side_effect=mock_get_trade_hist) pair = 'ETH/BTC' - ret = await exchange._async_get_trade_history_time(pair, since=trades_history[0]["timestamp"], - until=trades_history[-1]["timestamp"]-1) + ret = await exchange._async_get_trade_history_time(pair, since=trades_history[0][0], + until=trades_history[-1][0]-1) assert type(ret) is tuple assert ret[0] == pair assert type(ret[1]) is list @@ -1686,7 +1715,7 @@ async def test__async_get_trade_history_time_empty(default_conf, mocker, caplog, fetch_trades_cal = exchange._async_fetch_trades.call_args_list # first call (using since, not fromId) assert fetch_trades_cal[0][0][0] == pair - assert fetch_trades_cal[0][1]['since'] == trades_history[0]["timestamp"] + assert fetch_trades_cal[0][1]['since'] == trades_history[0][0] @pytest.mark.parametrize("exchange_name", EXCHANGES) @@ -1698,8 +1727,8 @@ def test_get_historic_trades(default_conf, mocker, caplog, exchange_name, trades exchange._async_get_trade_history_id = get_mock_coro((pair, trades_history)) exchange._async_get_trade_history_time = get_mock_coro((pair, trades_history)) - ret = exchange.get_historic_trades(pair, since=trades_history[0]["timestamp"], - until=trades_history[-1]["timestamp"]) + ret = exchange.get_historic_trades(pair, since=trades_history[0][0], + until=trades_history[-1][0]) # Depending on the exchange, one or the other method should be called assert sum([exchange._async_get_trade_history_id.call_count, @@ -1720,8 +1749,8 @@ def test_get_historic_trades_notsupported(default_conf, mocker, caplog, exchange with pytest.raises(OperationalException, match="This exchange does not suport downloading Trades."): - exchange.get_historic_trades(pair, since=trades_history[0]["timestamp"], - until=trades_history[-1]["timestamp"]) + exchange.get_historic_trades(pair, since=trades_history[0][0], + until=trades_history[-1][0]) @pytest.mark.parametrize("exchange_name", EXCHANGES) @@ -1729,6 +1758,7 @@ def test_cancel_order_dry_run(default_conf, mocker, exchange_name): default_conf['dry_run'] = True exchange = get_patched_exchange(mocker, default_conf, id=exchange_name) assert exchange.cancel_order(order_id='123', pair='TKN/BTC') == {} + assert exchange.cancel_stoploss_order(order_id='123', pair='TKN/BTC') == {} @pytest.mark.parametrize("exchange_name", EXCHANGES) @@ -1788,7 +1818,7 @@ def test_cancel_order_with_result_error(default_conf, mocker, exchange_name, cap res = exchange.cancel_order_with_result('1234', 'ETH/BTC', 1541) assert isinstance(res, dict) - assert log_has("Could not cancel order 1234.", caplog) + assert log_has("Could not cancel order 1234 for ETH/BTC.", caplog) assert log_has("Could not fetch cancelled order 1234.", caplog) assert res['amount'] == 1541 @@ -1814,31 +1844,95 @@ def test_cancel_order(default_conf, mocker, exchange_name): @pytest.mark.parametrize("exchange_name", EXCHANGES) -def test_get_order(default_conf, mocker, exchange_name): +def test_cancel_stoploss_order(default_conf, mocker, exchange_name): + default_conf['dry_run'] = False + api_mock = MagicMock() + api_mock.cancel_order = MagicMock(return_value=123) + exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) + assert exchange.cancel_stoploss_order(order_id='_', pair='TKN/BTC') == 123 + + with pytest.raises(InvalidOrderException): + api_mock.cancel_order = MagicMock(side_effect=ccxt.InvalidOrder("Did not find order")) + exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) + exchange.cancel_stoploss_order(order_id='_', pair='TKN/BTC') + assert api_mock.cancel_order.call_count == 1 + + ccxt_exceptionhandlers(mocker, default_conf, api_mock, exchange_name, + "cancel_stoploss_order", "cancel_order", + order_id='_', pair='TKN/BTC') + + +@pytest.mark.parametrize("exchange_name", EXCHANGES) +def test_fetch_order(default_conf, mocker, exchange_name): default_conf['dry_run'] = True order = MagicMock() order.myid = 123 exchange = get_patched_exchange(mocker, default_conf, id=exchange_name) exchange._dry_run_open_orders['X'] = order - assert exchange.get_order('X', 'TKN/BTC').myid == 123 + assert exchange.fetch_order('X', 'TKN/BTC').myid == 123 with pytest.raises(InvalidOrderException, match=r'Tried to get an invalid dry-run-order.*'): - exchange.get_order('Y', 'TKN/BTC') + exchange.fetch_order('Y', 'TKN/BTC') default_conf['dry_run'] = False api_mock = MagicMock() api_mock.fetch_order = MagicMock(return_value=456) exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) - assert exchange.get_order('X', 'TKN/BTC') == 456 + assert exchange.fetch_order('X', 'TKN/BTC') == 456 with pytest.raises(InvalidOrderException): api_mock.fetch_order = MagicMock(side_effect=ccxt.InvalidOrder("Order not found")) exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) - exchange.get_order(order_id='_', pair='TKN/BTC') + exchange.fetch_order(order_id='_', pair='TKN/BTC') + assert api_mock.fetch_order.call_count == 1 + + api_mock.fetch_order = MagicMock(side_effect=ccxt.OrderNotFound("Order not found")) + exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) + with patch('freqtrade.exchange.common.time.sleep') as tm: + with pytest.raises(InvalidOrderException): + exchange.fetch_order(order_id='_', pair='TKN/BTC') + # Ensure backoff is called + assert tm.call_args_list[0][0][0] == 1 + assert tm.call_args_list[1][0][0] == 2 + assert tm.call_args_list[2][0][0] == 5 + assert tm.call_args_list[3][0][0] == 10 + assert api_mock.fetch_order.call_count == 6 + + ccxt_exceptionhandlers(mocker, default_conf, api_mock, exchange_name, + 'fetch_order', 'fetch_order', retries=6, + order_id='_', pair='TKN/BTC') + + +@pytest.mark.parametrize("exchange_name", EXCHANGES) +def test_fetch_stoploss_order(default_conf, mocker, exchange_name): + # Don't test FTX here - that needs a seperate test + if exchange_name == 'ftx': + return + default_conf['dry_run'] = True + order = MagicMock() + order.myid = 123 + exchange = get_patched_exchange(mocker, default_conf, id=exchange_name) + exchange._dry_run_open_orders['X'] = order + assert exchange.fetch_stoploss_order('X', 'TKN/BTC').myid == 123 + + with pytest.raises(InvalidOrderException, match=r'Tried to get an invalid dry-run-order.*'): + exchange.fetch_stoploss_order('Y', 'TKN/BTC') + + default_conf['dry_run'] = False + api_mock = MagicMock() + api_mock.fetch_order = MagicMock(return_value=456) + exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) + assert exchange.fetch_stoploss_order('X', 'TKN/BTC') == 456 + + with pytest.raises(InvalidOrderException): + api_mock.fetch_order = MagicMock(side_effect=ccxt.InvalidOrder("Order not found")) + exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) + exchange.fetch_stoploss_order(order_id='_', pair='TKN/BTC') assert api_mock.fetch_order.call_count == 1 ccxt_exceptionhandlers(mocker, default_conf, api_mock, exchange_name, - 'get_order', 'fetch_order', + 'fetch_stoploss_order', 'fetch_order', + retries=6, order_id='_', pair='TKN/BTC') @@ -2046,6 +2140,13 @@ def test_get_markets(default_conf, mocker, markets, assert sorted(pairs.keys()) == sorted(expected_keys) +def test_get_markets_error(default_conf, mocker): + ex = get_patched_exchange(mocker, default_conf) + mocker.patch('freqtrade.exchange.Exchange.markets', PropertyMock(return_value=None)) + with pytest.raises(OperationalException, match="Markets were not loaded."): + ex.get_markets('LTC', 'USDT', True, False) + + def test_timeframe_to_minutes(): assert timeframe_to_minutes("5m") == 5 assert timeframe_to_minutes("10m") == 10 @@ -2145,3 +2246,88 @@ def test_symbol_is_pair(market_symbol, base_currency, quote_currency, expected_r ]) def test_market_is_active(market, expected_result) -> None: assert market_is_active(market) == expected_result + + +@pytest.mark.parametrize("order,expected", [ + ([{'fee'}], False), + ({'fee': None}, False), + ({'fee': {'currency': 'ETH/BTC'}}, False), + ({'fee': {'currency': 'ETH/BTC', 'cost': None}}, False), + ({'fee': {'currency': 'ETH/BTC', 'cost': 0.01}}, True), +]) +def test_order_has_fee(order, expected) -> None: + assert Exchange.order_has_fee(order) == expected + + +@pytest.mark.parametrize("order,expected", [ + ({'symbol': 'ETH/BTC', 'fee': {'currency': 'ETH', 'cost': 0.43}}, + (0.43, 'ETH', 0.01)), + ({'symbol': 'ETH/USDT', 'fee': {'currency': 'USDT', 'cost': 0.01}}, + (0.01, 'USDT', 0.01)), + ({'symbol': 'BTC/USDT', 'fee': {'currency': 'USDT', 'cost': 0.34, 'rate': 0.01}}, + (0.34, 'USDT', 0.01)), +]) +def test_extract_cost_curr_rate(mocker, default_conf, order, expected) -> None: + mocker.patch('freqtrade.exchange.Exchange.calculate_fee_rate', MagicMock(return_value=0.01)) + ex = get_patched_exchange(mocker, default_conf) + assert ex.extract_cost_curr_rate(order) == expected + + +@pytest.mark.parametrize("order,expected", [ + # Using base-currency + ({'symbol': 'ETH/BTC', 'amount': 0.04, 'cost': 0.05, + 'fee': {'currency': 'ETH', 'cost': 0.004, 'rate': None}}, 0.1), + ({'symbol': 'ETH/BTC', 'amount': 0.05, 'cost': 0.05, + 'fee': {'currency': 'ETH', 'cost': 0.004, 'rate': None}}, 0.08), + # Using quote currency + ({'symbol': 'ETH/BTC', 'amount': 0.04, 'cost': 0.05, + 'fee': {'currency': 'BTC', 'cost': 0.005}}, 0.1), + ({'symbol': 'ETH/BTC', 'amount': 0.04, 'cost': 0.05, + 'fee': {'currency': 'BTC', 'cost': 0.002, 'rate': None}}, 0.04), + # Using foreign currency + ({'symbol': 'ETH/BTC', 'amount': 0.04, 'cost': 0.05, + 'fee': {'currency': 'NEO', 'cost': 0.0012}}, 0.001944), + ({'symbol': 'ETH/BTC', 'amount': 2.21, 'cost': 0.02992561, + 'fee': {'currency': 'NEO', 'cost': 0.00027452}}, 0.00074305), + # Rate included in return - return as is + ({'symbol': 'ETH/BTC', 'amount': 0.04, 'cost': 0.05, + 'fee': {'currency': 'USDT', 'cost': 0.34, 'rate': 0.01}}, 0.01), + ({'symbol': 'ETH/BTC', 'amount': 0.04, 'cost': 0.05, + 'fee': {'currency': 'USDT', 'cost': 0.34, 'rate': 0.005}}, 0.005), + # 0.1% filled - no costs (kraken - #3431) + ({'symbol': 'ETH/BTC', 'amount': 0.04, 'cost': 0.0, + 'fee': {'currency': 'BTC', 'cost': 0.0, 'rate': None}}, None), + ({'symbol': 'ETH/BTC', 'amount': 0.04, 'cost': 0.0, + 'fee': {'currency': 'ETH', 'cost': 0.0, 'rate': None}}, 0.0), + ({'symbol': 'ETH/BTC', 'amount': 0.04, 'cost': 0.0, + 'fee': {'currency': 'NEO', 'cost': 0.0, 'rate': None}}, None), +]) +def test_calculate_fee_rate(mocker, default_conf, order, expected) -> None: + mocker.patch('freqtrade.exchange.Exchange.fetch_ticker', return_value={'last': 0.081}) + + ex = get_patched_exchange(mocker, default_conf) + assert ex.calculate_fee_rate(order) == expected + + +@pytest.mark.parametrize('retrycount,max_retries,expected', [ + (0, 3, 10), + (1, 3, 5), + (2, 3, 2), + (3, 3, 1), + (0, 1, 2), + (1, 1, 1), + (0, 4, 17), + (1, 4, 10), + (2, 4, 5), + (3, 4, 2), + (4, 4, 1), + (0, 5, 26), + (1, 5, 17), + (2, 5, 10), + (3, 5, 5), + (4, 5, 2), + (5, 5, 1), + +]) +def test_calculate_backoff(retrycount, max_retries, expected): + assert calculate_backoff(retrycount, max_retries) == expected diff --git a/tests/exchange/test_ftx.py b/tests/exchange/test_ftx.py new file mode 100644 index 000000000..bed92d276 --- /dev/null +++ b/tests/exchange/test_ftx.py @@ -0,0 +1,158 @@ +# pragma pylint: disable=missing-docstring, C0103, bad-continuation, global-statement +# pragma pylint: disable=protected-access +from random import randint +from unittest.mock import MagicMock + +import ccxt +import pytest + +from freqtrade.exceptions import DependencyException, InvalidOrderException +from tests.conftest import get_patched_exchange + +from .test_exchange import ccxt_exceptionhandlers + +STOPLOSS_ORDERTYPE = 'stop' + + +def test_stoploss_order_ftx(default_conf, mocker): + api_mock = MagicMock() + order_id = 'test_prod_buy_{}'.format(randint(0, 10 ** 6)) + + api_mock.create_order = MagicMock(return_value={ + 'id': order_id, + 'info': { + 'foo': 'bar' + } + }) + + default_conf['dry_run'] = False + mocker.patch('freqtrade.exchange.Exchange.amount_to_precision', lambda s, x, y: y) + mocker.patch('freqtrade.exchange.Exchange.price_to_precision', lambda s, x, y: y) + + exchange = get_patched_exchange(mocker, default_conf, api_mock, 'ftx') + + # stoploss_on_exchange_limit_ratio is irrelevant for ftx market orders + order = exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=190, + order_types={'stoploss_on_exchange_limit_ratio': 1.05}) + + assert api_mock.create_order.call_args_list[0][1]['symbol'] == 'ETH/BTC' + assert api_mock.create_order.call_args_list[0][1]['type'] == STOPLOSS_ORDERTYPE + assert api_mock.create_order.call_args_list[0][1]['side'] == 'sell' + assert api_mock.create_order.call_args_list[0][1]['amount'] == 1 + assert api_mock.create_order.call_args_list[0][1]['price'] == 190 + assert 'orderPrice' not in api_mock.create_order.call_args_list[0][1]['params'] + + assert api_mock.create_order.call_count == 1 + + api_mock.create_order.reset_mock() + + order = exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) + + assert 'id' in order + assert 'info' in order + assert order['id'] == order_id + assert api_mock.create_order.call_args_list[0][1]['symbol'] == 'ETH/BTC' + assert api_mock.create_order.call_args_list[0][1]['type'] == STOPLOSS_ORDERTYPE + assert api_mock.create_order.call_args_list[0][1]['side'] == 'sell' + assert api_mock.create_order.call_args_list[0][1]['amount'] == 1 + assert api_mock.create_order.call_args_list[0][1]['price'] == 220 + assert 'orderPrice' not in api_mock.create_order.call_args_list[0][1]['params'] + + api_mock.create_order.reset_mock() + order = exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, + order_types={'stoploss': 'limit'}) + + assert 'id' in order + assert 'info' in order + assert order['id'] == order_id + assert api_mock.create_order.call_args_list[0][1]['symbol'] == 'ETH/BTC' + assert api_mock.create_order.call_args_list[0][1]['type'] == STOPLOSS_ORDERTYPE + assert api_mock.create_order.call_args_list[0][1]['side'] == 'sell' + assert api_mock.create_order.call_args_list[0][1]['amount'] == 1 + assert api_mock.create_order.call_args_list[0][1]['price'] == 220 + assert 'orderPrice' in api_mock.create_order.call_args_list[0][1]['params'] + assert api_mock.create_order.call_args_list[0][1]['params']['orderPrice'] == 217.8 + + # test exception handling + with pytest.raises(DependencyException): + api_mock.create_order = MagicMock(side_effect=ccxt.InsufficientFunds("0 balance")) + exchange = get_patched_exchange(mocker, default_conf, api_mock, 'ftx') + exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) + + with pytest.raises(InvalidOrderException): + api_mock.create_order = MagicMock( + side_effect=ccxt.InvalidOrder("ftx Order would trigger immediately.")) + exchange = get_patched_exchange(mocker, default_conf, api_mock, 'ftx') + exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) + + ccxt_exceptionhandlers(mocker, default_conf, api_mock, "ftx", + "stoploss", "create_order", retries=1, + pair='ETH/BTC', amount=1, stop_price=220, order_types={}) + + +def test_stoploss_order_dry_run_ftx(default_conf, mocker): + api_mock = MagicMock() + default_conf['dry_run'] = True + mocker.patch('freqtrade.exchange.Exchange.amount_to_precision', lambda s, x, y: y) + mocker.patch('freqtrade.exchange.Exchange.price_to_precision', lambda s, x, y: y) + + exchange = get_patched_exchange(mocker, default_conf, api_mock, 'ftx') + + api_mock.create_order.reset_mock() + + order = exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) + + assert 'id' in order + assert 'info' in order + assert 'type' in order + + assert order['type'] == STOPLOSS_ORDERTYPE + assert order['price'] == 220 + assert order['amount'] == 1 + + +def test_stoploss_adjust_ftx(mocker, default_conf): + exchange = get_patched_exchange(mocker, default_conf, id='ftx') + order = { + 'type': STOPLOSS_ORDERTYPE, + 'price': 1500, + } + assert exchange.stoploss_adjust(1501, order) + assert not exchange.stoploss_adjust(1499, order) + # Test with invalid order case ... + order['type'] = 'stop_loss_limit' + assert not exchange.stoploss_adjust(1501, order) + + +def test_fetch_stoploss_order(default_conf, mocker): + default_conf['dry_run'] = True + order = MagicMock() + order.myid = 123 + exchange = get_patched_exchange(mocker, default_conf, id='ftx') + exchange._dry_run_open_orders['X'] = order + assert exchange.fetch_stoploss_order('X', 'TKN/BTC').myid == 123 + + with pytest.raises(InvalidOrderException, match=r'Tried to get an invalid dry-run-order.*'): + exchange.fetch_stoploss_order('Y', 'TKN/BTC') + + default_conf['dry_run'] = False + api_mock = MagicMock() + api_mock.fetch_orders = MagicMock(return_value=[{'id': 'X', 'status': '456'}]) + exchange = get_patched_exchange(mocker, default_conf, api_mock, id='ftx') + assert exchange.fetch_stoploss_order('X', 'TKN/BTC')['status'] == '456' + + api_mock.fetch_orders = MagicMock(return_value=[{'id': 'Y', 'status': '456'}]) + exchange = get_patched_exchange(mocker, default_conf, api_mock, id='ftx') + with pytest.raises(InvalidOrderException, match=r"Could not get stoploss order for id X"): + exchange.fetch_stoploss_order('X', 'TKN/BTC')['status'] + + with pytest.raises(InvalidOrderException): + api_mock.fetch_orders = MagicMock(side_effect=ccxt.InvalidOrder("Order not found")) + exchange = get_patched_exchange(mocker, default_conf, api_mock, id='ftx') + exchange.fetch_stoploss_order(order_id='_', pair='TKN/BTC') + assert api_mock.fetch_orders.call_count == 1 + + ccxt_exceptionhandlers(mocker, default_conf, api_mock, 'ftx', + 'fetch_stoploss_order', 'fetch_orders', + retries=6, + order_id='_', pair='TKN/BTC') diff --git a/tests/exchange/test_kraken.py b/tests/exchange/test_kraken.py index d63dd66cc..9451c0b9e 100644 --- a/tests/exchange/test_kraken.py +++ b/tests/exchange/test_kraken.py @@ -6,11 +6,12 @@ from unittest.mock import MagicMock import ccxt import pytest -from freqtrade.exceptions import (DependencyException, InvalidOrderException, - OperationalException, TemporaryError) +from freqtrade.exceptions import DependencyException, InvalidOrderException from tests.conftest import get_patched_exchange from tests.exchange.test_exchange import ccxt_exceptionhandlers +STOPLOSS_ORDERTYPE = 'stop-loss' + def test_buy_kraken_trading_agreement(default_conf, mocker): api_mock = MagicMock() @@ -159,7 +160,6 @@ def test_get_balances_prod(default_conf, mocker): def test_stoploss_order_kraken(default_conf, mocker): api_mock = MagicMock() order_id = 'test_prod_buy_{}'.format(randint(0, 10 ** 6)) - order_type = 'stop-loss' api_mock.create_order = MagicMock(return_value={ 'id': order_id, @@ -187,7 +187,7 @@ def test_stoploss_order_kraken(default_conf, mocker): assert 'info' in order assert order['id'] == order_id assert api_mock.create_order.call_args_list[0][1]['symbol'] == 'ETH/BTC' - assert api_mock.create_order.call_args_list[0][1]['type'] == order_type + assert api_mock.create_order.call_args_list[0][1]['type'] == STOPLOSS_ORDERTYPE assert api_mock.create_order.call_args_list[0][1]['side'] == 'sell' assert api_mock.create_order.call_args_list[0][1]['amount'] == 1 assert api_mock.create_order.call_args_list[0][1]['price'] == 220 @@ -205,20 +205,13 @@ def test_stoploss_order_kraken(default_conf, mocker): exchange = get_patched_exchange(mocker, default_conf, api_mock, 'kraken') exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) - with pytest.raises(TemporaryError): - api_mock.create_order = MagicMock(side_effect=ccxt.NetworkError("No connection")) - exchange = get_patched_exchange(mocker, default_conf, api_mock, 'kraken') - exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) - - with pytest.raises(OperationalException, match=r".*DeadBeef.*"): - api_mock.create_order = MagicMock(side_effect=ccxt.BaseError("DeadBeef")) - exchange = get_patched_exchange(mocker, default_conf, api_mock, 'kraken') - exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) + ccxt_exceptionhandlers(mocker, default_conf, api_mock, "kraken", + "stoploss", "create_order", retries=1, + pair='ETH/BTC', amount=1, stop_price=220, order_types={}) def test_stoploss_order_dry_run_kraken(default_conf, mocker): api_mock = MagicMock() - order_type = 'stop-loss' default_conf['dry_run'] = True mocker.patch('freqtrade.exchange.Exchange.amount_to_precision', lambda s, x, y: y) mocker.patch('freqtrade.exchange.Exchange.price_to_precision', lambda s, x, y: y) @@ -233,7 +226,7 @@ def test_stoploss_order_dry_run_kraken(default_conf, mocker): assert 'info' in order assert 'type' in order - assert order['type'] == order_type + assert order['type'] == STOPLOSS_ORDERTYPE assert order['price'] == 220 assert order['amount'] == 1 @@ -241,7 +234,7 @@ def test_stoploss_order_dry_run_kraken(default_conf, mocker): def test_stoploss_adjust_kraken(mocker, default_conf): exchange = get_patched_exchange(mocker, default_conf, id='kraken') order = { - 'type': 'stop-loss', + 'type': STOPLOSS_ORDERTYPE, 'price': 1500, } assert exchange.stoploss_adjust(1501, order) diff --git a/tests/optimize/test_backtest_detail.py b/tests/optimize/test_backtest_detail.py index e7bc76c1d..9b3043086 100644 --- a/tests/optimize/test_backtest_detail.py +++ b/tests/optimize/test_backtest_detail.py @@ -360,7 +360,7 @@ def test_backtest_results(default_conf, fee, mocker, caplog, data) -> None: """ default_conf["stoploss"] = data.stop_loss default_conf["minimal_roi"] = data.roi - default_conf["ticker_interval"] = tests_timeframe + default_conf["timeframe"] = tests_timeframe default_conf["trailing_stop"] = data.trailing_stop default_conf["trailing_only_offset_is_reached"] = data.trailing_only_offset_is_reached # Only add this to configuration If it's necessary diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index 1c4d3b16a..caa40fe84 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -2,7 +2,7 @@ import random from pathlib import Path -from unittest.mock import MagicMock +from unittest.mock import MagicMock, PropertyMock import numpy as np import pandas as pd @@ -10,8 +10,9 @@ import pytest from arrow import Arrow from freqtrade import constants +from freqtrade.commands.optimize_commands import (setup_optimize_configuration, + start_backtesting) from freqtrade.configuration import TimeRange -from freqtrade.commands.optimize_commands import setup_optimize_configuration, start_backtesting from freqtrade.data import history from freqtrade.data.btanalysis import evaluate_result_multi from freqtrade.data.converter import clean_ohlcv_dataframe @@ -80,7 +81,7 @@ def load_data_test(what, testdatadir): def simple_backtest(config, contour, num_results, mocker, testdatadir) -> None: patch_exchange(mocker) - config['ticker_interval'] = '1m' + config['timeframe'] = '1m' backtesting = Backtesting(config) data = load_data_test(contour, testdatadir) @@ -164,7 +165,7 @@ def test_setup_optimize_configuration_without_arguments(mocker, default_conf, ca assert 'pair_whitelist' in config['exchange'] assert 'datadir' in config assert log_has('Using data directory: {} ...'.format(config['datadir']), caplog) - assert 'ticker_interval' in config + assert 'timeframe' in config assert not log_has_re('Parameter -i/--ticker-interval detected .*', caplog) assert 'position_stacking' not in config @@ -188,7 +189,7 @@ def test_setup_bt_configuration_with_arguments(mocker, default_conf, caplog) -> '--config', 'config.json', '--strategy', 'DefaultStrategy', '--datadir', '/foo/bar', - '--ticker-interval', '1m', + '--timeframe', '1m', '--enable-position-stacking', '--disable-max-market-positions', '--timerange', ':100', @@ -207,8 +208,8 @@ def test_setup_bt_configuration_with_arguments(mocker, default_conf, caplog) -> assert config['runmode'] == RunMode.BACKTEST assert log_has('Using data directory: {} ...'.format(config['datadir']), caplog) - assert 'ticker_interval' in config - assert log_has('Parameter -i/--ticker-interval detected ... Using ticker_interval: 1m ...', + assert 'timeframe' in config + assert log_has('Parameter -i/--timeframe detected ... Using timeframe: 1m ...', caplog) assert 'position_stacking' in config @@ -285,9 +286,9 @@ def test_backtesting_init(mocker, default_conf, order_types) -> None: assert not backtesting.strategy.order_types["stoploss_on_exchange"] -def test_backtesting_init_no_ticker_interval(mocker, default_conf, caplog) -> None: +def test_backtesting_init_no_timeframe(mocker, default_conf, caplog) -> None: patch_exchange(mocker) - del default_conf['ticker_interval'] + del default_conf['timeframe'] default_conf['strategy_list'] = ['DefaultStrategy', 'SampleStrategy'] @@ -307,6 +308,11 @@ def test_data_with_fee(default_conf, mocker, testdatadir) -> None: assert backtesting.fee == 0.1234 assert fee_mock.call_count == 0 + default_conf['fee'] = 0.0 + backtesting = Backtesting(default_conf) + assert backtesting.fee == 0.0 + assert fee_mock.call_count == 0 + def test_data_to_dataframe_bt(default_conf, mocker, testdatadir) -> None: patch_exchange(mocker) @@ -332,10 +338,12 @@ def test_backtesting_start(default_conf, mocker, testdatadir, caplog) -> None: mocker.patch('freqtrade.data.history.get_timerange', get_timerange) patch_exchange(mocker) mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest') + mocker.patch('freqtrade.optimize.backtesting.generate_backtest_stats') mocker.patch('freqtrade.optimize.backtesting.show_backtest_results') + mocker.patch('freqtrade.pairlist.pairlistmanager.PairListManager.whitelist', + PropertyMock(return_value=['UNITTEST/BTC'])) - default_conf['exchange']['pair_whitelist'] = ['UNITTEST/BTC'] - default_conf['ticker_interval'] = '1m' + default_conf['timeframe'] = '1m' default_conf['datadir'] = testdatadir default_conf['export'] = None default_conf['timerange'] = '-1510694220' @@ -362,10 +370,10 @@ def test_backtesting_start_no_data(default_conf, mocker, caplog, testdatadir) -> mocker.patch('freqtrade.data.history.get_timerange', get_timerange) patch_exchange(mocker) mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest') - mocker.patch('freqtrade.optimize.backtesting.show_backtest_results') + mocker.patch('freqtrade.pairlist.pairlistmanager.PairListManager.whitelist', + PropertyMock(return_value=['UNITTEST/BTC'])) - default_conf['exchange']['pair_whitelist'] = ['UNITTEST/BTC'] - default_conf['ticker_interval'] = "1m" + default_conf['timeframe'] = "1m" default_conf['datadir'] = testdatadir default_conf['export'] = None default_conf['timerange'] = '20180101-20180102' @@ -375,6 +383,61 @@ def test_backtesting_start_no_data(default_conf, mocker, caplog, testdatadir) -> backtesting.start() +def test_backtesting_no_pair_left(default_conf, mocker, caplog, testdatadir) -> None: + mocker.patch('freqtrade.exchange.Exchange.exchange_has', MagicMock(return_value=True)) + mocker.patch('freqtrade.data.history.history_utils.load_pair_history', + MagicMock(return_value=pd.DataFrame())) + mocker.patch('freqtrade.data.history.get_timerange', get_timerange) + patch_exchange(mocker) + mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest') + mocker.patch('freqtrade.pairlist.pairlistmanager.PairListManager.whitelist', + PropertyMock(return_value=[])) + + default_conf['timeframe'] = "1m" + default_conf['datadir'] = testdatadir + default_conf['export'] = None + default_conf['timerange'] = '20180101-20180102' + + with pytest.raises(OperationalException, match='No pair in whitelist.'): + Backtesting(default_conf) + + default_conf['pairlists'] = [{"method": "VolumePairList", "number_assets": 5}] + with pytest.raises(OperationalException, match='VolumePairList not allowed for backtesting.'): + Backtesting(default_conf) + + +def test_backtesting_pairlist_list(default_conf, mocker, caplog, testdatadir, tickers) -> None: + mocker.patch('freqtrade.exchange.Exchange.exchange_has', MagicMock(return_value=True)) + mocker.patch('freqtrade.exchange.Exchange.get_tickers', tickers) + mocker.patch('freqtrade.exchange.Exchange.price_to_precision', lambda s, x, y: y) + mocker.patch('freqtrade.data.history.get_timerange', get_timerange) + patch_exchange(mocker) + mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest') + mocker.patch('freqtrade.pairlist.pairlistmanager.PairListManager.whitelist', + PropertyMock(return_value=['XRP/BTC'])) + mocker.patch('freqtrade.pairlist.pairlistmanager.PairListManager.refresh_pairlist') + + default_conf['ticker_interval'] = "1m" + default_conf['datadir'] = testdatadir + default_conf['export'] = None + # Use stoploss from strategy + del default_conf['stoploss'] + default_conf['timerange'] = '20180101-20180102' + + default_conf['pairlists'] = [{"method": "VolumePairList", "number_assets": 5}] + with pytest.raises(OperationalException, match='VolumePairList not allowed for backtesting.'): + Backtesting(default_conf) + + default_conf['pairlists'] = [{"method": "StaticPairList"}, {"method": "PrecisionFilter"}, ] + Backtesting(default_conf) + + # Multiple strategies + default_conf['strategy_list'] = ['DefaultStrategy', 'TestStrategyLegacy'] + with pytest.raises(OperationalException, + match='PrecisionFilter not allowed for backtesting multiple strategies.'): + Backtesting(default_conf) + + def test_backtest(default_conf, fee, mocker, testdatadir) -> None: default_conf['ask_strategy']['use_sell_signal'] = False mocker.patch('freqtrade.exchange.Exchange.get_fee', fee) @@ -428,7 +491,7 @@ def test_backtest(default_conf, fee, mocker, testdatadir) -> None: t["close_rate"], 6) < round(ln.iloc[0]["high"], 6)) -def test_backtest_1min_ticker_interval(default_conf, fee, mocker, testdatadir) -> None: +def test_backtest_1min_timeframe(default_conf, fee, mocker, testdatadir) -> None: default_conf['ask_strategy']['use_sell_signal'] = False mocker.patch('freqtrade.exchange.Exchange.get_fee', fee) patch_exchange(mocker) @@ -509,7 +572,7 @@ def test_backtest_alternate_buy_sell(default_conf, fee, mocker, testdatadir): mocker.patch('freqtrade.exchange.Exchange.get_fee', fee) backtest_conf = _make_backtest_conf(mocker, conf=default_conf, pair='UNITTEST/BTC', datadir=testdatadir) - default_conf['ticker_interval'] = '1m' + default_conf['timeframe'] = '1m' backtesting = Backtesting(default_conf) backtesting.strategy.advise_buy = _trend_alternate # Override backtesting.strategy.advise_sell = _trend_alternate # Override @@ -530,7 +593,7 @@ def test_backtest_multi_pair(default_conf, fee, mocker, tres, pair, testdatadir) """ Buy every xth candle - sell every other xth -2 (hold on to pairs a bit) """ - if metadata['pair'] in('ETH/BTC', 'LTC/BTC'): + if metadata['pair'] in ('ETH/BTC', 'LTC/BTC'): multi = 20 else: multi = 18 @@ -548,7 +611,7 @@ def test_backtest_multi_pair(default_conf, fee, mocker, tres, pair, testdatadir) # Remove data for one pair from the beginning of the data data[pair] = data[pair][tres:].reset_index() - default_conf['ticker_interval'] = '5m' + default_conf['timeframe'] = '5m' backtesting = Backtesting(default_conf) backtesting.strategy.advise_buy = _trend_alternate_hold # Override @@ -585,12 +648,13 @@ def test_backtest_multi_pair(default_conf, fee, mocker, tres, pair, testdatadir) def test_backtest_start_timerange(default_conf, mocker, caplog, testdatadir): - default_conf['exchange']['pair_whitelist'] = ['UNITTEST/BTC'] patch_exchange(mocker) - mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest', MagicMock()) - mocker.patch('freqtrade.optimize.backtesting.show_backtest_results', MagicMock()) - + mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest') + mocker.patch('freqtrade.optimize.backtesting.generate_backtest_stats') + mocker.patch('freqtrade.optimize.backtesting.show_backtest_results') + mocker.patch('freqtrade.pairlist.pairlistmanager.PairListManager.whitelist', + PropertyMock(return_value=['UNITTEST/BTC'])) patched_configuration_load_config_file(mocker, default_conf) args = [ @@ -598,7 +662,7 @@ def test_backtest_start_timerange(default_conf, mocker, caplog, testdatadir): '--config', 'config.json', '--strategy', 'DefaultStrategy', '--datadir', str(testdatadir), - '--ticker-interval', '1m', + '--timeframe', '1m', '--timerange', '1510694220-1510700340', '--enable-position-stacking', '--disable-max-market-positions' @@ -607,7 +671,7 @@ def test_backtest_start_timerange(default_conf, mocker, caplog, testdatadir): start_backtesting(args) # check the logs, that will contain the backtest result exists = [ - 'Parameter -i/--ticker-interval detected ... Using ticker_interval: 1m ...', + 'Parameter -i/--timeframe detected ... Using timeframe: 1m ...', 'Ignoring max_open_trades (--disable-max-market-positions was used) ...', 'Parameter --timerange detected: 1510694220-1510700340 ...', f'Using data directory: {testdatadir} ...', @@ -624,17 +688,26 @@ def test_backtest_start_timerange(default_conf, mocker, caplog, testdatadir): assert log_has(line, caplog) +@pytest.mark.filterwarnings("ignore:deprecated") def test_backtest_start_multi_strat(default_conf, mocker, caplog, testdatadir): - default_conf['exchange']['pair_whitelist'] = ['UNITTEST/BTC'] patch_exchange(mocker) backtestmock = MagicMock() + mocker.patch('freqtrade.pairlist.pairlistmanager.PairListManager.whitelist', + PropertyMock(return_value=['UNITTEST/BTC'])) mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest', backtestmock) - gen_table_mock = MagicMock() - mocker.patch('freqtrade.optimize.optimize_reports.generate_text_table', gen_table_mock) - gen_strattable_mock = MagicMock() - mocker.patch('freqtrade.optimize.optimize_reports.generate_text_table_strategy', - gen_strattable_mock) + text_table_mock = MagicMock() + sell_reason_mock = MagicMock() + strattable_mock = MagicMock() + strat_summary = MagicMock() + + mocker.patch.multiple('freqtrade.optimize.optimize_reports', + text_table_bt_results=text_table_mock, + text_table_strategy=strattable_mock, + generate_pair_metrics=MagicMock(), + generate_sell_reason_stats=sell_reason_mock, + generate_strategy_metrics=strat_summary, + ) patched_configuration_load_config_file(mocker, default_conf) args = [ @@ -642,7 +715,7 @@ def test_backtest_start_multi_strat(default_conf, mocker, caplog, testdatadir): '--config', 'config.json', '--datadir', str(testdatadir), '--strategy-path', str(Path(__file__).parents[1] / 'strategy/strats'), - '--ticker-interval', '1m', + '--timeframe', '1m', '--timerange', '1510694220-1510700340', '--enable-position-stacking', '--disable-max-market-positions', @@ -654,12 +727,14 @@ def test_backtest_start_multi_strat(default_conf, mocker, caplog, testdatadir): start_backtesting(args) # 2 backtests, 4 tables assert backtestmock.call_count == 2 - assert gen_table_mock.call_count == 4 - assert gen_strattable_mock.call_count == 1 + assert text_table_mock.call_count == 4 + assert strattable_mock.call_count == 1 + assert sell_reason_mock.call_count == 2 + assert strat_summary.call_count == 1 # check the logs, that will contain the backtest result exists = [ - 'Parameter -i/--ticker-interval detected ... Using ticker_interval: 1m ...', + 'Parameter -i/--timeframe detected ... Using timeframe: 1m ...', 'Ignoring max_open_trades (--disable-max-market-positions was used) ...', 'Parameter --timerange detected: 1510694220-1510700340 ...', f'Using data directory: {testdatadir} ...', @@ -676,3 +751,92 @@ def test_backtest_start_multi_strat(default_conf, mocker, caplog, testdatadir): for line in exists: assert log_has(line, caplog) + + +@pytest.mark.filterwarnings("ignore:deprecated") +def test_backtest_start_multi_strat_nomock(default_conf, mocker, caplog, testdatadir, capsys): + + patch_exchange(mocker) + backtestmock = MagicMock(side_effect=[ + pd.DataFrame({'pair': ['XRP/BTC', 'LTC/BTC'], + 'profit_percent': [0.0, 0.0], + 'profit_abs': [0.0, 0.0], + 'open_time': pd.to_datetime(['2018-01-29 18:40:00', + '2018-01-30 03:30:00', ], utc=True + ), + 'close_time': pd.to_datetime(['2018-01-29 20:45:00', + '2018-01-30 05:35:00', ], utc=True), + 'open_index': [78, 184], + 'close_index': [125, 192], + 'trade_duration': [235, 40], + 'open_at_end': [False, False], + 'open_rate': [0.104445, 0.10302485], + 'close_rate': [0.104969, 0.103541], + 'sell_reason': [SellType.ROI, SellType.ROI] + }), + pd.DataFrame({'pair': ['XRP/BTC', 'LTC/BTC', 'ETH/BTC'], + 'profit_percent': [0.03, 0.01, 0.1], + 'profit_abs': [0.01, 0.02, 0.2], + 'open_time': pd.to_datetime(['2018-01-29 18:40:00', + '2018-01-30 03:30:00', + '2018-01-30 05:30:00'], utc=True + ), + 'close_time': pd.to_datetime(['2018-01-29 20:45:00', + '2018-01-30 05:35:00', + '2018-01-30 08:30:00'], utc=True), + 'open_index': [78, 184, 185], + 'close_index': [125, 224, 205], + 'trade_duration': [47, 40, 20], + 'open_at_end': [False, False, False], + 'open_rate': [0.104445, 0.10302485, 0.122541], + 'close_rate': [0.104969, 0.103541, 0.123541], + 'sell_reason': [SellType.ROI, SellType.ROI, SellType.STOP_LOSS] + }), + ]) + mocker.patch('freqtrade.pairlist.pairlistmanager.PairListManager.whitelist', + PropertyMock(return_value=['UNITTEST/BTC'])) + mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest', backtestmock) + + patched_configuration_load_config_file(mocker, default_conf) + + args = [ + 'backtesting', + '--config', 'config.json', + '--datadir', str(testdatadir), + '--strategy-path', str(Path(__file__).parents[1] / 'strategy/strats'), + '--timeframe', '1m', + '--timerange', '1510694220-1510700340', + '--enable-position-stacking', + '--disable-max-market-positions', + '--strategy-list', + 'DefaultStrategy', + 'TestStrategyLegacy', + ] + args = get_args(args) + start_backtesting(args) + + # check the logs, that will contain the backtest result + exists = [ + 'Parameter -i/--timeframe detected ... Using timeframe: 1m ...', + 'Ignoring max_open_trades (--disable-max-market-positions was used) ...', + 'Parameter --timerange detected: 1510694220-1510700340 ...', + f'Using data directory: {testdatadir} ...', + 'Using stake_currency: BTC ...', + 'Using stake_amount: 0.001 ...', + 'Loading data from 2017-11-14T20:57:00+00:00 ' + 'up to 2017-11-14T22:58:00+00:00 (0 days)..', + 'Backtesting with data from 2017-11-14T21:17:00+00:00 ' + 'up to 2017-11-14T22:58:00+00:00 (0 days)..', + 'Parameter --enable-position-stacking detected ...', + 'Running backtesting for Strategy DefaultStrategy', + 'Running backtesting for Strategy TestStrategyLegacy', + ] + + for line in exists: + assert log_has(line, caplog) + + captured = capsys.readouterr() + assert 'BACKTESTING REPORT' in captured.out + assert 'SELL REASON STATS' in captured.out + assert 'LEFT OPEN TRADES REPORT' in captured.out + assert 'STRATEGY SUMMARY' in captured.out diff --git a/tests/optimize/test_edge_cli.py b/tests/optimize/test_edge_cli.py index a5e468542..acec51f66 100644 --- a/tests/optimize/test_edge_cli.py +++ b/tests/optimize/test_edge_cli.py @@ -29,7 +29,7 @@ def test_setup_optimize_configuration_without_arguments(mocker, default_conf, ca assert 'pair_whitelist' in config['exchange'] assert 'datadir' in config assert log_has('Using data directory: {} ...'.format(config['datadir']), caplog) - assert 'ticker_interval' in config + assert 'timeframe' in config assert not log_has_re('Parameter -i/--ticker-interval detected .*', caplog) assert 'timerange' not in config @@ -48,7 +48,7 @@ def test_setup_edge_configuration_with_arguments(mocker, edge_conf, caplog) -> N '--config', 'config.json', '--strategy', 'DefaultStrategy', '--datadir', '/foo/bar', - '--ticker-interval', '1m', + '--timeframe', '1m', '--timerange', ':100', '--stoplosses=-0.01,-0.10,-0.001' ] @@ -62,8 +62,8 @@ def test_setup_edge_configuration_with_arguments(mocker, edge_conf, caplog) -> N assert 'datadir' in config assert config['runmode'] == RunMode.EDGE assert log_has('Using data directory: {} ...'.format(config['datadir']), caplog) - assert 'ticker_interval' in config - assert log_has('Parameter -i/--ticker-interval detected ... Using ticker_interval: 1m ...', + assert 'timeframe' in config + assert log_has('Parameter -i/--timeframe detected ... Using timeframe: 1m ...', caplog) assert 'timerange' in config diff --git a/tests/optimize/test_hyperopt.py b/tests/optimize/test_hyperopt.py index cdbe7f161..4b178ca11 100644 --- a/tests/optimize/test_hyperopt.py +++ b/tests/optimize/test_hyperopt.py @@ -1,5 +1,6 @@ # pragma pylint: disable=missing-docstring,W0212,C0103 import locale +import logging from datetime import datetime from pathlib import Path from typing import Dict, List @@ -56,14 +57,14 @@ def hyperopt_results(): # Functions for recurrent object patching -def create_trials(mocker, hyperopt, testdatadir) -> List[Dict]: +def create_results(mocker, hyperopt, testdatadir) -> List[Dict]: """ - When creating trials, mock the hyperopt Trials so that *by default* + When creating results, mock the hyperopt so that *by default* - we don't create any pickle'd files in the filesystem - we might have a pickle'd file so make sure that we return false when looking for it """ - hyperopt.trials_file = testdatadir / 'optimize/ut_trials.pickle' + hyperopt.results_file = testdatadir / 'optimize/ut_results.pickle' mocker.patch.object(Path, "is_file", MagicMock(return_value=False)) stat_mock = MagicMock() @@ -93,7 +94,7 @@ def test_setup_hyperopt_configuration_without_arguments(mocker, default_conf, ca assert 'pair_whitelist' in config['exchange'] assert 'datadir' in config assert log_has('Using data directory: {} ...'.format(config['datadir']), caplog) - assert 'ticker_interval' in config + assert 'timeframe' in config assert not log_has_re('Parameter -i/--ticker-interval detected .*', caplog) assert 'position_stacking' not in config @@ -116,7 +117,7 @@ def test_setup_hyperopt_configuration_with_arguments(mocker, default_conf, caplo '--config', 'config.json', '--hyperopt', 'DefaultHyperOpt', '--datadir', '/foo/bar', - '--ticker-interval', '1m', + '--timeframe', '1m', '--timerange', ':100', '--enable-position-stacking', '--disable-max-market-positions', @@ -135,8 +136,8 @@ def test_setup_hyperopt_configuration_with_arguments(mocker, default_conf, caplo assert config['runmode'] == RunMode.HYPEROPT assert log_has('Using data directory: {} ...'.format(config['datadir']), caplog) - assert 'ticker_interval' in config - assert log_has('Parameter -i/--ticker-interval detected ... Using ticker_interval: 1m ...', + assert 'timeframe' in config + assert log_has('Parameter -i/--timeframe detected ... Using timeframe: 1m ...', caplog) assert 'position_stacking' in config @@ -196,7 +197,8 @@ def test_hyperoptresolver(mocker, default_conf, caplog) -> None: "Using populate_sell_trend from the strategy.", caplog) assert log_has("Hyperopt class does not provide populate_buy_trend() method. " "Using populate_buy_trend from the strategy.", caplog) - assert hasattr(x, "ticker_interval") + assert hasattr(x, "ticker_interval") # DEPRECATED + assert hasattr(x, "timeframe") def test_hyperoptresolver_wrongname(mocker, default_conf, caplog) -> None: @@ -477,28 +479,30 @@ def test_no_log_if_loss_does_not_improve(hyperopt, caplog) -> None: assert caplog.record_tuples == [] -def test_save_trials_saves_trials(mocker, hyperopt, testdatadir, caplog) -> None: - trials = create_trials(mocker, hyperopt, testdatadir) +def test_save_results_saves_epochs(mocker, hyperopt, testdatadir, caplog) -> None: + epochs = create_results(mocker, hyperopt, testdatadir) mock_dump = mocker.patch('freqtrade.optimize.hyperopt.dump', return_value=None) - trials_file = testdatadir / 'optimize' / 'ut_trials.pickle' + results_file = testdatadir / 'optimize' / 'ut_results.pickle' - hyperopt.trials = trials - hyperopt.save_trials(final=True) - assert log_has(f"1 epoch saved to '{trials_file}'.", caplog) + caplog.set_level(logging.DEBUG) + + hyperopt.epochs = epochs + hyperopt._save_results() + assert log_has(f"1 epoch saved to '{results_file}'.", caplog) mock_dump.assert_called_once() - hyperopt.trials = trials + trials - hyperopt.save_trials(final=True) - assert log_has(f"2 epochs saved to '{trials_file}'.", caplog) + hyperopt.epochs = epochs + epochs + hyperopt._save_results() + assert log_has(f"2 epochs saved to '{results_file}'.", caplog) -def test_read_trials_returns_trials_file(mocker, hyperopt, testdatadir, caplog) -> None: - trials = create_trials(mocker, hyperopt, testdatadir) - mock_load = mocker.patch('freqtrade.optimize.hyperopt.load', return_value=trials) - trials_file = testdatadir / 'optimize' / 'ut_trials.pickle' - hyperopt_trial = hyperopt._read_trials(trials_file) - assert log_has(f"Reading Trials from '{trials_file}'", caplog) - assert hyperopt_trial == trials +def test_read_results_returns_epochs(mocker, hyperopt, testdatadir, caplog) -> None: + epochs = create_results(mocker, hyperopt, testdatadir) + mock_load = mocker.patch('freqtrade.optimize.hyperopt.load', return_value=epochs) + results_file = testdatadir / 'optimize' / 'ut_results.pickle' + hyperopt_epochs = hyperopt._read_results(results_file) + assert log_has(f"Reading epochs from '{results_file}'", caplog) + assert hyperopt_epochs == epochs mock_load.assert_called_once() @@ -541,7 +545,7 @@ def test_start_calls_optimizer(mocker, default_conf, caplog, capsys) -> None: ) patch_exchange(mocker) # Co-test loading timeframe from strategy - del default_conf['ticker_interval'] + del default_conf['timeframe'] default_conf.update({'config': 'config.json.example', 'hyperopt': 'DefaultHyperOpt', 'epochs': 1, @@ -823,7 +827,7 @@ def test_continue_hyperopt(mocker, default_conf, caplog): Hyperopt(default_conf) assert unlinkmock.call_count == 0 - assert log_has(f"Continuing on previous hyperopt results.", caplog) + assert log_has("Continuing on previous hyperopt results.", caplog) def test_print_json_spaces_all(mocker, default_conf, caplog, capsys) -> None: diff --git a/tests/optimize/test_optimize_reports.py b/tests/optimize/test_optimize_reports.py index e0782146a..175405e4c 100644 --- a/tests/optimize/test_optimize_reports.py +++ b/tests/optimize/test_optimize_reports.py @@ -1,17 +1,19 @@ from pathlib import Path import pandas as pd +import pytest from arrow import Arrow from freqtrade.edge import PairInfo from freqtrade.optimize.optimize_reports import ( - generate_edge_table, generate_text_table, generate_text_table_sell_reason, - generate_text_table_strategy, store_backtest_result) + generate_pair_metrics, generate_edge_table, generate_sell_reason_stats, + text_table_bt_results, text_table_sell_reason, generate_strategy_metrics, + text_table_strategy, store_backtest_result) from freqtrade.strategy.interface import SellType from tests.conftest import patch_exchange -def test_generate_text_table(default_conf, mocker): +def test_text_table_bt_results(default_conf, mocker): results = pd.DataFrame( { @@ -35,12 +37,38 @@ def test_generate_text_table(default_conf, mocker): '| TOTAL | 2 | 15.00 | 30.00 | 0.60000000 |' ' 15.00 | 0:20:00 | 2 | 0 | 0 |' ) - assert generate_text_table(data={'ETH/BTC': {}}, - stake_currency='BTC', max_open_trades=2, - results=results) == result_str + + pair_results = generate_pair_metrics(data={'ETH/BTC': {}}, stake_currency='BTC', + max_open_trades=2, results=results) + assert text_table_bt_results(pair_results, stake_currency='BTC') == result_str -def test_generate_text_table_sell_reason(default_conf, mocker): +def test_generate_pair_metrics(default_conf, mocker): + + results = pd.DataFrame( + { + 'pair': ['ETH/BTC', 'ETH/BTC'], + 'profit_percent': [0.1, 0.2], + 'profit_abs': [0.2, 0.4], + 'trade_duration': [10, 30], + 'wins': [2, 0], + 'draws': [0, 0], + 'losses': [0, 0] + } + ) + + pair_results = generate_pair_metrics(data={'ETH/BTC': {}}, stake_currency='BTC', + max_open_trades=2, results=results) + assert isinstance(pair_results, list) + assert len(pair_results) == 2 + assert pair_results[-1]['key'] == 'TOTAL' + assert ( + pytest.approx(pair_results[-1]['profit_mean_pct']) == pair_results[-1]['profit_mean'] * 100) + assert ( + pytest.approx(pair_results[-1]['profit_sum_pct']) == pair_results[-1]['profit_sum'] * 100) + + +def test_text_table_sell_reason(default_conf): results = pd.DataFrame( { @@ -65,11 +93,49 @@ def test_generate_text_table_sell_reason(default_conf, mocker): '| stop_loss | 1 | 0 | 0 | 1 |' ' -10 | -10 | -0.2 | -5 |' ) - assert generate_text_table_sell_reason(stake_currency='BTC', max_open_trades=2, - results=results) == result_str + + sell_reason_stats = generate_sell_reason_stats(max_open_trades=2, + results=results) + assert text_table_sell_reason(sell_reason_stats=sell_reason_stats, + stake_currency='BTC') == result_str -def test_generate_text_table_strategy(default_conf, mocker): +def test_generate_sell_reason_stats(default_conf): + + results = pd.DataFrame( + { + 'pair': ['ETH/BTC', 'ETH/BTC', 'ETH/BTC'], + 'profit_percent': [0.1, 0.2, -0.1], + 'profit_abs': [0.2, 0.4, -0.2], + 'trade_duration': [10, 30, 10], + 'wins': [2, 0, 0], + 'draws': [0, 0, 0], + 'losses': [0, 0, 1], + 'sell_reason': [SellType.ROI, SellType.ROI, SellType.STOP_LOSS] + } + ) + + sell_reason_stats = generate_sell_reason_stats(max_open_trades=2, + results=results) + roi_result = sell_reason_stats[0] + assert roi_result['sell_reason'] == 'roi' + assert roi_result['trades'] == 2 + assert pytest.approx(roi_result['profit_mean']) == 0.15 + assert roi_result['profit_mean_pct'] == round(roi_result['profit_mean'] * 100, 2) + assert pytest.approx(roi_result['profit_mean']) == 0.15 + assert roi_result['profit_mean_pct'] == round(roi_result['profit_mean'] * 100, 2) + + stop_result = sell_reason_stats[1] + + assert stop_result['sell_reason'] == 'stop_loss' + assert stop_result['trades'] == 1 + assert pytest.approx(stop_result['profit_mean']) == -0.1 + assert stop_result['profit_mean_pct'] == round(stop_result['profit_mean'] * 100, 2) + assert pytest.approx(stop_result['profit_mean']) == -0.1 + assert stop_result['profit_mean_pct'] == round(stop_result['profit_mean'] * 100, 2) + + +def test_text_table_strategy(default_conf, mocker): results = {} results['TestStrategy1'] = pd.DataFrame( { @@ -106,7 +172,12 @@ def test_generate_text_table_strategy(default_conf, mocker): '| TestStrategy2 | 3 | 30.00 | 90.00 | 1.30000000 |' ' 45.00 | 0:20:00 | 3 | 0 | 0 |' ) - assert generate_text_table_strategy('BTC', 2, all_results=results) == result_str + + strategy_results = generate_strategy_metrics(stake_currency='BTC', + max_open_trades=2, + all_results=results) + + assert text_table_strategy(strategy_results, 'BTC') == result_str def test_generate_edge_table(edge_conf, mocker): diff --git a/tests/pairlist/test_pairlist.py b/tests/pairlist/test_pairlist.py index 7dfe8bcca..efe4a784b 100644 --- a/tests/pairlist/test_pairlist.py +++ b/tests/pairlist/test_pairlist.py @@ -4,13 +4,11 @@ from unittest.mock import MagicMock, PropertyMock import pytest -from freqtrade.exceptions import OperationalException from freqtrade.constants import AVAILABLE_PAIRLISTS -from freqtrade.resolvers import PairListResolver +from freqtrade.exceptions import OperationalException from freqtrade.pairlist.pairlistmanager import PairListManager -from tests.conftest import get_patched_freqtradebot, log_has_re - -# whitelist, blacklist +from freqtrade.resolvers import PairListResolver +from tests.conftest import get_patched_freqtradebot, log_has, log_has_re @pytest.fixture(scope="function") @@ -21,7 +19,8 @@ def whitelist_conf(default_conf): 'TKN/BTC', 'TRST/BTC', 'SWT/BTC', - 'BCC/BTC' + 'BCC/BTC', + 'HOT/BTC', ] default_conf['exchange']['pair_blacklist'] = [ 'BLK/BTC' @@ -36,6 +35,53 @@ def whitelist_conf(default_conf): return default_conf +@pytest.fixture(scope="function") +def whitelist_conf_2(default_conf): + default_conf['stake_currency'] = 'BTC' + default_conf['exchange']['pair_whitelist'] = [ + 'ETH/BTC', 'TKN/BTC', 'BLK/BTC', 'LTC/BTC', + 'BTT/BTC', 'HOT/BTC', 'FUEL/BTC', 'XRP/BTC' + ] + default_conf['exchange']['pair_blacklist'] = [ + 'BLK/BTC' + ] + default_conf['pairlists'] = [ + # { "method": "StaticPairList"}, + { + "method": "VolumePairList", + "number_assets": 5, + "sort_key": "quoteVolume", + "refresh_period": 0, + }, + ] + return default_conf + + +@pytest.fixture(scope="function") +def whitelist_conf_3(default_conf): + default_conf['stake_currency'] = 'BTC' + default_conf['exchange']['pair_whitelist'] = [ + 'ETH/BTC', 'TKN/BTC', 'BLK/BTC', 'LTC/BTC', + 'BTT/BTC', 'HOT/BTC', 'FUEL/BTC', 'XRP/BTC' + ] + default_conf['exchange']['pair_blacklist'] = [ + 'BLK/BTC' + ] + default_conf['pairlists'] = [ + { + "method": "VolumePairList", + "number_assets": 5, + "sort_key": "quoteVolume", + "refresh_period": 0, + }, + { + "method": "AgeFilter", + "min_days_listed": 2 + } + ] + return default_conf + + @pytest.fixture(scope="function") def static_pl_conf(whitelist_conf): whitelist_conf['pairlists'] = [ @@ -55,7 +101,7 @@ def test_log_on_refresh(mocker, static_pl_conf, markets, tickers): freqtrade = get_patched_freqtradebot(mocker, static_pl_conf) logmock = MagicMock() # Assign starting whitelist - pl = freqtrade.pairlists._pairlists[0] + pl = freqtrade.pairlists._pairlist_handlers[0] pl.log_on_refresh(logmock, 'Hello world') assert logmock.call_count == 1 pl.log_on_refresh(logmock, 'Hello world') @@ -69,44 +115,44 @@ def test_log_on_refresh(mocker, static_pl_conf, markets, tickers): def test_load_pairlist_noexist(mocker, markets, default_conf): - bot = get_patched_freqtradebot(mocker, default_conf) + freqtrade = get_patched_freqtradebot(mocker, default_conf) mocker.patch('freqtrade.exchange.Exchange.markets', PropertyMock(return_value=markets)) - plm = PairListManager(bot.exchange, default_conf) + plm = PairListManager(freqtrade.exchange, default_conf) with pytest.raises(OperationalException, match=r"Impossible to load Pairlist 'NonexistingPairList'. " r"This class does not exist or contains Python code errors."): - PairListResolver.load_pairlist('NonexistingPairList', bot.exchange, plm, + PairListResolver.load_pairlist('NonexistingPairList', freqtrade.exchange, plm, default_conf, {}, 1) def test_refresh_market_pair_not_in_whitelist(mocker, markets, static_pl_conf): - freqtradebot = get_patched_freqtradebot(mocker, static_pl_conf) + freqtrade = get_patched_freqtradebot(mocker, static_pl_conf) mocker.patch('freqtrade.exchange.Exchange.markets', PropertyMock(return_value=markets)) - freqtradebot.pairlists.refresh_pairlist() + freqtrade.pairlists.refresh_pairlist() # List ordered by BaseVolume whitelist = ['ETH/BTC', 'TKN/BTC'] # Ensure all except those in whitelist are removed - assert set(whitelist) == set(freqtradebot.pairlists.whitelist) + assert set(whitelist) == set(freqtrade.pairlists.whitelist) # Ensure config dict hasn't been changed assert (static_pl_conf['exchange']['pair_whitelist'] == - freqtradebot.config['exchange']['pair_whitelist']) + freqtrade.config['exchange']['pair_whitelist']) def test_refresh_static_pairlist(mocker, markets, static_pl_conf): - freqtradebot = get_patched_freqtradebot(mocker, static_pl_conf) + freqtrade = get_patched_freqtradebot(mocker, static_pl_conf) mocker.patch.multiple( 'freqtrade.exchange.Exchange', exchange_has=MagicMock(return_value=True), markets=PropertyMock(return_value=markets), ) - freqtradebot.pairlists.refresh_pairlist() + freqtrade.pairlists.refresh_pairlist() # List ordered by BaseVolume whitelist = ['ETH/BTC', 'TKN/BTC'] # Ensure all except those in whitelist are removed - assert set(whitelist) == set(freqtradebot.pairlists.whitelist) - assert static_pl_conf['exchange']['pair_blacklist'] == freqtradebot.pairlists.blacklist + assert set(whitelist) == set(freqtrade.pairlists.whitelist) + assert static_pl_conf['exchange']['pair_blacklist'] == freqtrade.pairlists.blacklist def test_refresh_pairlist_dynamic(mocker, shitcoinmarkets, tickers, whitelist_conf): @@ -116,27 +162,52 @@ def test_refresh_pairlist_dynamic(mocker, shitcoinmarkets, tickers, whitelist_co get_tickers=tickers, exchange_has=MagicMock(return_value=True), ) - bot = get_patched_freqtradebot(mocker, whitelist_conf) + freqtrade = get_patched_freqtradebot(mocker, whitelist_conf) # Remock markets with shitcoinmarkets since get_patched_freqtradebot uses the markets fixture mocker.patch.multiple( 'freqtrade.exchange.Exchange', markets=PropertyMock(return_value=shitcoinmarkets), - ) + ) # argument: use the whitelist dynamically by exchange-volume whitelist = ['ETH/BTC', 'TKN/BTC', 'LTC/BTC', 'XRP/BTC', 'HOT/BTC'] - bot.pairlists.refresh_pairlist() - - assert whitelist == bot.pairlists.whitelist - - whitelist_conf['pairlists'] = [{'method': 'VolumePairList', - 'config': {} - } - ] + freqtrade.pairlists.refresh_pairlist() + assert whitelist == freqtrade.pairlists.whitelist + whitelist_conf['pairlists'] = [{'method': 'VolumePairList'}] with pytest.raises(OperationalException, match=r'`number_assets` not specified. Please check your configuration ' r'for "pairlist.config.number_assets"'): - PairListManager(bot.exchange, whitelist_conf) + PairListManager(freqtrade.exchange, whitelist_conf) + + +def test_refresh_pairlist_dynamic_2(mocker, shitcoinmarkets, tickers, whitelist_conf_2): + + tickers_dict = tickers() + + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + exchange_has=MagicMock(return_value=True), + ) + # Remove caching of ticker data to emulate changing volume by the time of second call + mocker.patch.multiple( + 'freqtrade.pairlist.pairlistmanager.PairListManager', + _get_cached_tickers=MagicMock(return_value=tickers_dict), + ) + freqtrade = get_patched_freqtradebot(mocker, whitelist_conf_2) + # Remock markets with shitcoinmarkets since get_patched_freqtradebot uses the markets fixture + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + markets=PropertyMock(return_value=shitcoinmarkets), + ) + + whitelist = ['ETH/BTC', 'TKN/BTC', 'LTC/BTC', 'XRP/BTC', 'HOT/BTC'] + freqtrade.pairlists.refresh_pairlist() + assert whitelist == freqtrade.pairlists.whitelist + + whitelist = ['FUEL/BTC', 'ETH/BTC', 'TKN/BTC', 'LTC/BTC', 'XRP/BTC'] + tickers_dict['FUEL/BTC']['quoteVolume'] = 10000.0 + freqtrade.pairlists.refresh_pairlist() + assert whitelist == freqtrade.pairlists.whitelist def test_VolumePairList_refresh_empty(mocker, markets_empty, whitelist_conf): @@ -144,103 +215,231 @@ def test_VolumePairList_refresh_empty(mocker, markets_empty, whitelist_conf): 'freqtrade.exchange.Exchange', exchange_has=MagicMock(return_value=True), ) - freqtradebot = get_patched_freqtradebot(mocker, whitelist_conf) + freqtrade = get_patched_freqtradebot(mocker, whitelist_conf) mocker.patch('freqtrade.exchange.Exchange.markets', PropertyMock(return_value=markets_empty)) # argument: use the whitelist dynamically by exchange-volume whitelist = [] whitelist_conf['exchange']['pair_whitelist'] = [] - freqtradebot.pairlists.refresh_pairlist() + freqtrade.pairlists.refresh_pairlist() pairslist = whitelist_conf['exchange']['pair_whitelist'] assert set(whitelist) == set(pairslist) @pytest.mark.parametrize("pairlists,base_currency,whitelist_result", [ + # VolumePairList only ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}], - "BTC", ['ETH/BTC', 'TKN/BTC', 'LTC/BTC', 'XRP/BTC', 'HOT/BTC']), + "BTC", ['ETH/BTC', 'TKN/BTC', 'LTC/BTC', 'XRP/BTC', 'HOT/BTC']), # Different sorting depending on quote or bid volume ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "bidVolume"}], - "BTC", ['HOT/BTC', 'FUEL/BTC', 'XRP/BTC', 'LTC/BTC', 'TKN/BTC']), + "BTC", ['HOT/BTC', 'FUEL/BTC', 'XRP/BTC', 'LTC/BTC', 'TKN/BTC']), ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}], - "USDT", ['ETH/USDT', 'NANO/USDT', 'ADAHALF/USDT']), - # No pair for ETH ... + "USDT", ['ETH/USDT', 'NANO/USDT', 'ADAHALF/USDT', 'ADADOUBLE/USDT']), + # No pair for ETH, VolumePairList ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}], "ETH", []), + # No pair for ETH, StaticPairList + ([{"method": "StaticPairList"}], + "ETH", []), + # No pair for ETH, all handlers + ([{"method": "StaticPairList"}, + {"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, + {"method": "AgeFilter", "min_days_listed": 2}, + {"method": "PrecisionFilter"}, + {"method": "PriceFilter", "low_price_ratio": 0.03}, + {"method": "SpreadFilter", "max_spread_ratio": 0.005}, + {"method": "ShuffleFilter"}], + "ETH", []), + # AgeFilter and VolumePairList (require 2 days only, all should pass age test) + ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, + {"method": "AgeFilter", "min_days_listed": 2}], + "BTC", ['ETH/BTC', 'TKN/BTC', 'LTC/BTC', 'XRP/BTC', 'HOT/BTC']), + # AgeFilter and VolumePairList (require 10 days, all should fail age test) + ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, + {"method": "AgeFilter", "min_days_listed": 10}], + "BTC", []), # Precisionfilter and quote volume ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, - {"method": "PrecisionFilter"}], "BTC", ['ETH/BTC', 'TKN/BTC', 'LTC/BTC', 'XRP/BTC']), + {"method": "PrecisionFilter"}], + "BTC", ['ETH/BTC', 'TKN/BTC', 'LTC/BTC', 'XRP/BTC']), # Precisionfilter bid ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "bidVolume"}, - {"method": "PrecisionFilter"}], "BTC", ['FUEL/BTC', 'XRP/BTC', 'LTC/BTC', 'TKN/BTC']), + {"method": "PrecisionFilter"}], + "BTC", ['FUEL/BTC', 'XRP/BTC', 'LTC/BTC', 'TKN/BTC']), # PriceFilter and VolumePairList ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, {"method": "PriceFilter", "low_price_ratio": 0.03}], - "BTC", ['ETH/BTC', 'TKN/BTC', 'LTC/BTC', 'XRP/BTC']), + "BTC", ['ETH/BTC', 'TKN/BTC', 'LTC/BTC', 'XRP/BTC']), # PriceFilter and VolumePairList ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, {"method": "PriceFilter", "low_price_ratio": 0.03}], - "USDT", ['ETH/USDT', 'NANO/USDT']), - # Hot is removed by precision_filter, Fuel by low_price_filter. + "USDT", ['ETH/USDT', 'NANO/USDT']), + # Hot is removed by precision_filter, Fuel by low_price_ratio, Ripple by min_price. ([{"method": "VolumePairList", "number_assets": 6, "sort_key": "quoteVolume"}, {"method": "PrecisionFilter"}, - {"method": "PriceFilter", "low_price_ratio": 0.02} - ], "BTC", ['ETH/BTC', 'TKN/BTC', 'LTC/BTC', 'XRP/BTC']), + {"method": "PriceFilter", "low_price_ratio": 0.02, "min_price": 0.01}], + "BTC", ['ETH/BTC', 'TKN/BTC', 'LTC/BTC']), + # Hot is removed by precision_filter, Fuel by low_price_ratio, Ethereum by max_price. + ([{"method": "VolumePairList", "number_assets": 6, "sort_key": "quoteVolume"}, + {"method": "PrecisionFilter"}, + {"method": "PriceFilter", "low_price_ratio": 0.02, "max_price": 0.05}], + "BTC", ['TKN/BTC', 'LTC/BTC', 'XRP/BTC']), # HOT and XRP are removed because below 1250 quoteVolume ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume", "min_value": 1250}], - "BTC", ['ETH/BTC', 'TKN/BTC', 'LTC/BTC']), - # StaticPairlist Only - ([{"method": "StaticPairList"}, - ], "BTC", ['ETH/BTC', 'TKN/BTC']), + "BTC", ['ETH/BTC', 'TKN/BTC', 'LTC/BTC']), + # StaticPairlist only + ([{"method": "StaticPairList"}], + "BTC", ['ETH/BTC', 'TKN/BTC', 'HOT/BTC']), # Static Pairlist before VolumePairList - sorting changes ([{"method": "StaticPairList"}, - {"method": "VolumePairList", "number_assets": 5, "sort_key": "bidVolume"}, - ], "BTC", ['TKN/BTC', 'ETH/BTC']), + {"method": "VolumePairList", "number_assets": 5, "sort_key": "bidVolume"}], + "BTC", ['HOT/BTC', 'TKN/BTC', 'ETH/BTC']), # SpreadFilter ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, - {"method": "SpreadFilter", "max_spread": 0.005} - ], "USDT", ['ETH/USDT']), + {"method": "SpreadFilter", "max_spread_ratio": 0.005}], + "USDT", ['ETH/USDT']), + # ShuffleFilter + ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, + {"method": "ShuffleFilter", "seed": 77}], + "USDT", ['ADADOUBLE/USDT', 'ETH/USDT', 'NANO/USDT', 'ADAHALF/USDT']), + # ShuffleFilter, other seed + ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, + {"method": "ShuffleFilter", "seed": 42}], + "USDT", ['ADAHALF/USDT', 'NANO/USDT', 'ADADOUBLE/USDT', 'ETH/USDT']), + # ShuffleFilter, no seed + ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, + {"method": "ShuffleFilter"}], + "USDT", 3), # whitelist_result is integer -- check only length of randomized pairlist + # AgeFilter only + ([{"method": "AgeFilter", "min_days_listed": 2}], + "BTC", 'filter_at_the_beginning'), # OperationalException expected + # PrecisionFilter after StaticPairList + ([{"method": "StaticPairList"}, + {"method": "PrecisionFilter"}], + "BTC", ['ETH/BTC', 'TKN/BTC']), + # PrecisionFilter only + ([{"method": "PrecisionFilter"}], + "BTC", 'filter_at_the_beginning'), # OperationalException expected + # PriceFilter after StaticPairList + ([{"method": "StaticPairList"}, + {"method": "PriceFilter", "low_price_ratio": 0.02, "min_price": 0.000001, "max_price": 0.1}], + "BTC", ['ETH/BTC', 'TKN/BTC']), + # PriceFilter only + ([{"method": "PriceFilter", "low_price_ratio": 0.02}], + "BTC", 'filter_at_the_beginning'), # OperationalException expected + # ShuffleFilter after StaticPairList + ([{"method": "StaticPairList"}, + {"method": "ShuffleFilter", "seed": 42}], + "BTC", ['TKN/BTC', 'ETH/BTC', 'HOT/BTC']), + # ShuffleFilter only + ([{"method": "ShuffleFilter", "seed": 42}], + "BTC", 'filter_at_the_beginning'), # OperationalException expected + # SpreadFilter after StaticPairList + ([{"method": "StaticPairList"}, + {"method": "SpreadFilter", "max_spread_ratio": 0.005}], + "BTC", ['ETH/BTC', 'TKN/BTC']), + # SpreadFilter only + ([{"method": "SpreadFilter", "max_spread_ratio": 0.005}], + "BTC", 'filter_at_the_beginning'), # OperationalException expected + # Static Pairlist after VolumePairList, on a non-first position + ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "bidVolume"}, + {"method": "StaticPairList"}], + "BTC", 'static_in_the_middle'), + ([{"method": "VolumePairList", "number_assets": 20, "sort_key": "quoteVolume"}, + {"method": "PriceFilter", "low_price_ratio": 0.02}], + "USDT", ['ETH/USDT', 'NANO/USDT']), ]) def test_VolumePairList_whitelist_gen(mocker, whitelist_conf, shitcoinmarkets, tickers, - pairlists, base_currency, whitelist_result, - caplog) -> None: + ohlcv_history_list, pairlists, base_currency, + whitelist_result, caplog) -> None: whitelist_conf['pairlists'] = pairlists + whitelist_conf['stake_currency'] = base_currency mocker.patch('freqtrade.exchange.Exchange.exchange_has', MagicMock(return_value=True)) - freqtrade = get_patched_freqtradebot(mocker, whitelist_conf) + if whitelist_result == 'static_in_the_middle': + with pytest.raises(OperationalException, + match=r"StaticPairList can only be used in the first position " + r"in the list of Pairlist Handlers."): + freqtrade = get_patched_freqtradebot(mocker, whitelist_conf) + return + + freqtrade = get_patched_freqtradebot(mocker, whitelist_conf) mocker.patch.multiple('freqtrade.exchange.Exchange', get_tickers=tickers, - markets=PropertyMock(return_value=shitcoinmarkets), + markets=PropertyMock(return_value=shitcoinmarkets) ) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_historic_ohlcv=MagicMock(return_value=ohlcv_history_list), + ) - freqtrade.config['stake_currency'] = base_currency - freqtrade.pairlists.refresh_pairlist() - whitelist = freqtrade.pairlists.whitelist + # Set whitelist_result to None if pairlist is invalid and should produce exception + if whitelist_result == 'filter_at_the_beginning': + with pytest.raises(OperationalException, + match=r"This Pairlist Handler should not be used at the first position " + r"in the list of Pairlist Handlers."): + freqtrade.pairlists.refresh_pairlist() + else: + freqtrade.pairlists.refresh_pairlist() + whitelist = freqtrade.pairlists.whitelist - assert whitelist == whitelist_result - for pairlist in pairlists: - if pairlist['method'] == 'PrecisionFilter': - assert log_has_re(r'^Removed .* from whitelist, because stop price .* ' - r'would be <= stop limit.*', caplog) - if pairlist['method'] == 'PriceFilter': - assert (log_has_re(r'^Removed .* from whitelist, because 1 unit is .*%$', caplog) or - log_has_re(r"^Removed .* from whitelist, because ticker\['last'\] is empty.*", - caplog)) + assert isinstance(whitelist, list) + + # Verify length of pairlist matches (used for ShuffleFilter without seed) + if type(whitelist_result) is list: + assert whitelist == whitelist_result + else: + len(whitelist) == whitelist_result + + for pairlist in pairlists: + if pairlist['method'] == 'AgeFilter' and pairlist['min_days_listed'] and \ + len(ohlcv_history_list) <= pairlist['min_days_listed']: + assert log_has_re(r'^Removed .* from whitelist, because age .* is less than ' + r'.* day.*', caplog) + if pairlist['method'] == 'PrecisionFilter' and whitelist_result: + assert log_has_re(r'^Removed .* from whitelist, because stop price .* ' + r'would be <= stop limit.*', caplog) + if pairlist['method'] == 'PriceFilter' and whitelist_result: + assert (log_has_re(r'^Removed .* from whitelist, because 1 unit is .*%$', caplog) or + log_has_re(r'^Removed .* from whitelist, ' + r'because last price < .*%$', caplog) or + log_has_re(r'^Removed .* from whitelist, ' + r'because last price > .*%$', caplog) or + log_has_re(r"^Removed .* from whitelist, because ticker\['last'\] " + r"is empty.*", caplog)) + if pairlist['method'] == 'VolumePairList': + logmsg = ("DEPRECATED: using any key other than quoteVolume for " + "VolumePairList is deprecated.") + if pairlist['sort_key'] != 'quoteVolume': + assert log_has(logmsg, caplog) + else: + assert not log_has(logmsg, caplog) + + +def test_PrecisionFilter_error(mocker, whitelist_conf, tickers) -> None: + whitelist_conf['pairlists'] = [{"method": "StaticPairList"}, {"method": "PrecisionFilter"}] + del whitelist_conf['stoploss'] + + mocker.patch('freqtrade.exchange.Exchange.exchange_has', MagicMock(return_value=True)) + + with pytest.raises(OperationalException, + match=r"PrecisionFilter can only work with stoploss defined\..*"): + PairListManager(MagicMock, whitelist_conf) def test_gen_pair_whitelist_not_supported(mocker, default_conf, tickers) -> None: - default_conf['pairlists'] = [{'method': 'VolumePairList', - 'config': {'number_assets': 10} - }] + default_conf['pairlists'] = [{'method': 'VolumePairList', 'number_assets': 10}] mocker.patch.multiple('freqtrade.exchange.Exchange', get_tickers=tickers, exchange_has=MagicMock(return_value=False), ) - with pytest.raises(OperationalException): + with pytest.raises(OperationalException, + match=r'Exchange does not support dynamic whitelist.*'): get_patched_freqtradebot(mocker, default_conf) @@ -283,12 +482,30 @@ def test__whitelist_for_active_markets(mocker, whitelist_conf, markets, pairlist caplog.clear() # Assign starting whitelist - new_whitelist = freqtrade.pairlists._pairlists[0]._whitelist_for_active_markets(whitelist) + pairlist_handler = freqtrade.pairlists._pairlist_handlers[0] + new_whitelist = pairlist_handler._whitelist_for_active_markets(whitelist) assert set(new_whitelist) == set(['ETH/BTC', 'TKN/BTC']) assert log_message in caplog.text +@pytest.mark.parametrize("pairlist", AVAILABLE_PAIRLISTS) +def test__whitelist_for_active_markets_empty(mocker, whitelist_conf, markets, pairlist, tickers): + whitelist_conf['pairlists'][0]['method'] = pairlist + + mocker.patch('freqtrade.exchange.Exchange.exchange_has', return_value=True) + + freqtrade = get_patched_freqtradebot(mocker, whitelist_conf) + mocker.patch.multiple('freqtrade.exchange.Exchange', + markets=PropertyMock(return_value=None), + get_tickers=tickers + ) + # Assign starting whitelist + pairlist_handler = freqtrade.pairlists._pairlist_handlers[0] + with pytest.raises(OperationalException, match=r'Markets not loaded.*'): + pairlist_handler._whitelist_for_active_markets(['ETH/BTC']) + + def test_volumepairlist_invalid_sortvalue(mocker, markets, whitelist_conf): whitelist_conf['pairlists'][0].update({"sort_key": "asdf"}) @@ -305,18 +522,102 @@ def test_volumepairlist_caching(mocker, markets, whitelist_conf, tickers): exchange_has=MagicMock(return_value=True), get_tickers=tickers ) - bot = get_patched_freqtradebot(mocker, whitelist_conf) - assert bot.pairlists._pairlists[0]._last_refresh == 0 + freqtrade = get_patched_freqtradebot(mocker, whitelist_conf) + assert freqtrade.pairlists._pairlist_handlers[0]._last_refresh == 0 assert tickers.call_count == 0 - bot.pairlists.refresh_pairlist() + freqtrade.pairlists.refresh_pairlist() assert tickers.call_count == 1 - assert bot.pairlists._pairlists[0]._last_refresh != 0 - lrf = bot.pairlists._pairlists[0]._last_refresh - bot.pairlists.refresh_pairlist() + assert freqtrade.pairlists._pairlist_handlers[0]._last_refresh != 0 + lrf = freqtrade.pairlists._pairlist_handlers[0]._last_refresh + freqtrade.pairlists.refresh_pairlist() assert tickers.call_count == 1 # Time should not be updated. - assert bot.pairlists._pairlists[0]._last_refresh == lrf + assert freqtrade.pairlists._pairlist_handlers[0]._last_refresh == lrf + + +def test_agefilter_min_days_listed_too_small(mocker, default_conf, markets, tickers, caplog): + default_conf['pairlists'] = [{'method': 'VolumePairList', 'number_assets': 10}, + {'method': 'AgeFilter', 'min_days_listed': -1}] + + mocker.patch.multiple('freqtrade.exchange.Exchange', + markets=PropertyMock(return_value=markets), + exchange_has=MagicMock(return_value=True), + get_tickers=tickers + ) + + with pytest.raises(OperationalException, + match=r'AgeFilter requires min_days_listed must be >= 1'): + get_patched_freqtradebot(mocker, default_conf) + + +def test_agefilter_min_days_listed_too_large(mocker, default_conf, markets, tickers, caplog): + default_conf['pairlists'] = [{'method': 'VolumePairList', 'number_assets': 10}, + {'method': 'AgeFilter', 'min_days_listed': 99999}] + + mocker.patch.multiple('freqtrade.exchange.Exchange', + markets=PropertyMock(return_value=markets), + exchange_has=MagicMock(return_value=True), + get_tickers=tickers + ) + + with pytest.raises(OperationalException, + match=r'AgeFilter requires min_days_listed must not exceed ' + r'exchange max request size \([0-9]+\)'): + get_patched_freqtradebot(mocker, default_conf) + + +def test_agefilter_caching(mocker, markets, whitelist_conf_3, tickers, ohlcv_history_list): + + mocker.patch.multiple('freqtrade.exchange.Exchange', + markets=PropertyMock(return_value=markets), + exchange_has=MagicMock(return_value=True), + get_tickers=tickers + ) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_historic_ohlcv=MagicMock(return_value=ohlcv_history_list), + ) + + freqtrade = get_patched_freqtradebot(mocker, whitelist_conf_3) + assert freqtrade.exchange.get_historic_ohlcv.call_count == 0 + freqtrade.pairlists.refresh_pairlist() + assert freqtrade.exchange.get_historic_ohlcv.call_count > 0 + + previous_call_count = freqtrade.exchange.get_historic_ohlcv.call_count + freqtrade.pairlists.refresh_pairlist() + # Should not have increased since first call. + assert freqtrade.exchange.get_historic_ohlcv.call_count == previous_call_count + + +@pytest.mark.parametrize("pairlistconfig,expected", [ + ({"method": "PriceFilter", "low_price_ratio": 0.001, "min_price": 0.00000010, + "max_price": 1.0}, "[{'PriceFilter': 'PriceFilter - Filtering pairs priced below " + "0.1% or below 0.00000010 or above 1.00000000.'}]" + ), + ({"method": "PriceFilter", "low_price_ratio": 0.001, "min_price": 0.00000010}, + "[{'PriceFilter': 'PriceFilter - Filtering pairs priced below 0.1% or below 0.00000010.'}]" + ), + ({"method": "PriceFilter", "low_price_ratio": 0.001, "max_price": 1.00010000}, + "[{'PriceFilter': 'PriceFilter - Filtering pairs priced below 0.1% or above 1.00010000.'}]" + ), + ({"method": "PriceFilter", "min_price": 0.00002000}, + "[{'PriceFilter': 'PriceFilter - Filtering pairs priced below 0.00002000.'}]" + ), + ({"method": "PriceFilter"}, + "[{'PriceFilter': 'PriceFilter - No price filters configured.'}]" + ), +]) +def test_pricefilter_desc(mocker, whitelist_conf, markets, pairlistconfig, expected): + mocker.patch.multiple('freqtrade.exchange.Exchange', + markets=PropertyMock(return_value=markets), + exchange_has=MagicMock(return_value=True) + ) + whitelist_conf['pairlists'] = [pairlistconfig] + + freqtrade = get_patched_freqtradebot(mocker, whitelist_conf) + short_desc = str(freqtrade.pairlists.short_desc()) + assert short_desc == expected def test_pairlistmanager_no_pairlist(mocker, markets, whitelist_conf, caplog): @@ -325,5 +626,5 @@ def test_pairlistmanager_no_pairlist(mocker, markets, whitelist_conf, caplog): whitelist_conf['pairlists'] = [] with pytest.raises(OperationalException, - match=r"No Pairlist defined!"): + match=r"No Pairlist Handlers defined"): get_patched_freqtradebot(mocker, whitelist_conf) diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index d2af4bd87..9bbd34672 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -8,12 +8,13 @@ import pytest from numpy import isnan from freqtrade.edge import PairInfo -from freqtrade.exceptions import DependencyException, TemporaryError +from freqtrade.exceptions import ExchangeError, InvalidOrderException, TemporaryError from freqtrade.persistence import Trade from freqtrade.rpc import RPC, RPCException from freqtrade.rpc.fiat_convert import CryptoToFiatConverter from freqtrade.state import State -from tests.conftest import get_patched_freqtradebot, patch_get_signal, create_mock_trades +from tests.conftest import (create_mock_trades, get_patched_freqtradebot, + patch_get_signal) # Functions for recurrent object patching @@ -42,79 +43,133 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: rpc._rpc_trade_status() freqtradebot.enter_positions() + trades = Trade.get_open_trades() + trades[0].open_order_id = None + freqtradebot.exit_positions(trades) + results = rpc._rpc_trade_status() - assert { + assert results[0] == { 'trade_id': 1, 'pair': 'ETH/BTC', 'base_currency': 'BTC', 'open_date': ANY, 'open_date_hum': ANY, + 'open_timestamp': ANY, 'is_open': ANY, 'fee_open': ANY, - 'fee_close': ANY, + 'fee_open_cost': ANY, + 'fee_open_currency': ANY, + 'fee_close': fee.return_value, + 'fee_close_cost': ANY, + 'fee_close_currency': ANY, 'open_rate_requested': ANY, - 'open_trade_price': ANY, + 'open_trade_price': 0.0010025, 'close_rate_requested': ANY, 'sell_reason': ANY, + 'sell_order_status': ANY, 'min_rate': ANY, 'max_rate': ANY, 'strategy': ANY, 'ticker_interval': ANY, + 'timeframe': ANY, 'open_order_id': ANY, 'close_date': None, 'close_date_hum': None, + 'close_timestamp': None, 'open_rate': 1.098e-05, 'close_rate': None, 'current_rate': 1.099e-05, - 'amount': 91.07468124, + 'amount': 91.07468123, + 'amount_requested': 91.07468123, 'stake_amount': 0.001, 'close_profit': None, - 'current_profit': -0.41, - 'stop_loss': 0.0, - 'initial_stop_loss': 0.0, - 'initial_stop_loss_pct': None, - 'stop_loss_pct': None, - 'open_order': '(limit buy rem=0.00000000)' - } == results[0] + 'close_profit_pct': None, + 'close_profit_abs': None, + 'current_profit': -0.00408133, + 'current_profit_pct': -0.41, + 'current_profit_abs': -4.09e-06, + 'stop_loss': 9.882e-06, + 'stop_loss_abs': 9.882e-06, + 'stop_loss_pct': -10.0, + 'stop_loss_ratio': -0.1, + 'stoploss_order_id': None, + 'stoploss_last_update': ANY, + 'stoploss_last_update_timestamp': ANY, + 'initial_stop_loss': 9.882e-06, + 'initial_stop_loss_abs': 9.882e-06, + 'initial_stop_loss_pct': -10.0, + 'initial_stop_loss_ratio': -0.1, + 'stoploss_current_dist': -1.1080000000000002e-06, + 'stoploss_current_dist_ratio': -0.10081893, + 'stoploss_entry_dist': -0.00010475, + 'stoploss_entry_dist_ratio': -0.10448878, + 'open_order': None, + 'exchange': 'bittrex', + } mocker.patch('freqtrade.freqtradebot.FreqtradeBot.get_sell_rate', - MagicMock(side_effect=DependencyException(f"Pair 'ETH/BTC' not available"))) + MagicMock(side_effect=ExchangeError("Pair 'ETH/BTC' not available"))) results = rpc._rpc_trade_status() assert isnan(results[0]['current_profit']) assert isnan(results[0]['current_rate']) - assert { + assert results[0] == { 'trade_id': 1, 'pair': 'ETH/BTC', 'base_currency': 'BTC', 'open_date': ANY, 'open_date_hum': ANY, + 'open_timestamp': ANY, 'is_open': ANY, 'fee_open': ANY, - 'fee_close': ANY, + 'fee_open_cost': ANY, + 'fee_open_currency': ANY, + 'fee_close': fee.return_value, + 'fee_close_cost': ANY, + 'fee_close_currency': ANY, 'open_rate_requested': ANY, 'open_trade_price': ANY, 'close_rate_requested': ANY, 'sell_reason': ANY, + 'sell_order_status': ANY, 'min_rate': ANY, 'max_rate': ANY, 'strategy': ANY, 'ticker_interval': ANY, + 'timeframe': ANY, 'open_order_id': ANY, 'close_date': None, 'close_date_hum': None, + 'close_timestamp': None, 'open_rate': 1.098e-05, 'close_rate': None, 'current_rate': ANY, - 'amount': 91.07468124, + 'amount': 91.07468123, + 'amount_requested': 91.07468123, 'stake_amount': 0.001, 'close_profit': None, + 'close_profit_pct': None, + 'close_profit_abs': None, 'current_profit': ANY, - 'stop_loss': 0.0, - 'initial_stop_loss': 0.0, - 'initial_stop_loss_pct': None, - 'stop_loss_pct': None, - 'open_order': '(limit buy rem=0.00000000)' - } == results[0] + 'current_profit_pct': ANY, + 'current_profit_abs': ANY, + 'stop_loss': 9.882e-06, + 'stop_loss_abs': 9.882e-06, + 'stop_loss_pct': -10.0, + 'stop_loss_ratio': -0.1, + 'stoploss_order_id': None, + 'stoploss_last_update': ANY, + 'stoploss_last_update_timestamp': ANY, + 'initial_stop_loss': 9.882e-06, + 'initial_stop_loss_abs': 9.882e-06, + 'initial_stop_loss_pct': -10.0, + 'initial_stop_loss_ratio': -0.1, + 'stoploss_current_dist': ANY, + 'stoploss_current_dist_ratio': ANY, + 'stoploss_entry_dist': -0.00010475, + 'stoploss_entry_dist_ratio': -0.10448878, + 'open_order': None, + 'exchange': 'bittrex', + } def test_rpc_status_table(default_conf, ticker, fee, mocker) -> None: @@ -157,7 +212,7 @@ def test_rpc_status_table(default_conf, ticker, fee, mocker) -> None: assert '-0.41% (-0.06)' == result[0][3] mocker.patch('freqtrade.freqtradebot.FreqtradeBot.get_sell_rate', - MagicMock(side_effect=DependencyException(f"Pair 'ETH/BTC' not available"))) + MagicMock(side_effect=ExchangeError("Pair 'ETH/BTC' not available"))) result, headers = rpc._rpc_status_table(default_conf['stake_currency'], 'USD') assert 'instantly' == result[0][2] assert 'ETH/BTC' in result[0][1] @@ -195,16 +250,18 @@ def test_rpc_daily_profit(default_conf, update, ticker, fee, # Try valid data update.message.text = '/daily 2' days = rpc._rpc_daily_profit(7, stake_currency, fiat_display_currency) - assert len(days) == 7 - for day in days: + assert len(days['data']) == 7 + assert days['stake_currency'] == default_conf['stake_currency'] + assert days['fiat_display_currency'] == default_conf['fiat_display_currency'] + for day in days['data']: # [datetime.date(2018, 1, 11), '0.00000000 BTC', '0.000 USD'] - assert (day[1] == '0.00000000 BTC' or - day[1] == '0.00006217 BTC') + assert (day['abs_profit'] == '0.00000000' or + day['abs_profit'] == '0.00006217') - assert (day[2] == '0.000 USD' or - day[2] == '0.767 USD') + assert (day['fiat_value'] == '0.000' or + day['fiat_value'] == '0.767') # ensure first day is current date - assert str(days[0][0]) == str(datetime.utcnow().date()) + assert str(days['data'][0]['date']) == str(datetime.utcnow().date()) # Try invalid data with pytest.raises(RPCException, match=r'.*must be an integer greater than 0*'): @@ -229,12 +286,66 @@ def test_rpc_trade_history(mocker, default_conf, markets, fee): assert isinstance(trades['trades'][1], dict) trades = rpc._rpc_trade_history(0) - assert len(trades['trades']) == 3 - assert trades['trades_count'] == 3 - # The first trade is for ETH ... sorting is descending - assert trades['trades'][-1]['pair'] == 'ETH/BTC' - assert trades['trades'][0]['pair'] == 'ETC/BTC' - assert trades['trades'][1]['pair'] == 'ETC/BTC' + assert len(trades['trades']) == 2 + assert trades['trades_count'] == 2 + # The first closed trade is for ETC ... sorting is descending + assert trades['trades'][-1]['pair'] == 'ETC/BTC' + assert trades['trades'][0]['pair'] == 'XRP/BTC' + + +def test_rpc_delete_trade(mocker, default_conf, fee, markets, caplog): + mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) + stoploss_mock = MagicMock() + cancel_mock = MagicMock() + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + markets=PropertyMock(return_value=markets), + cancel_order=cancel_mock, + cancel_stoploss_order=stoploss_mock, + ) + + freqtradebot = get_patched_freqtradebot(mocker, default_conf) + freqtradebot.strategy.order_types['stoploss_on_exchange'] = True + create_mock_trades(fee) + rpc = RPC(freqtradebot) + with pytest.raises(RPCException, match='invalid argument'): + rpc._rpc_delete('200') + + create_mock_trades(fee) + trades = Trade.query.all() + trades[1].stoploss_order_id = '1234' + trades[2].stoploss_order_id = '1234' + assert len(trades) > 2 + + res = rpc._rpc_delete('1') + assert isinstance(res, dict) + assert res['result'] == 'success' + assert res['trade_id'] == '1' + assert res['cancel_order_count'] == 1 + assert cancel_mock.call_count == 1 + assert stoploss_mock.call_count == 0 + cancel_mock.reset_mock() + stoploss_mock.reset_mock() + + res = rpc._rpc_delete('2') + assert isinstance(res, dict) + assert cancel_mock.call_count == 1 + assert stoploss_mock.call_count == 1 + assert res['cancel_order_count'] == 2 + + stoploss_mock = mocker.patch('freqtrade.exchange.Exchange.cancel_stoploss_order', + side_effect=InvalidOrderException) + + res = rpc._rpc_delete('3') + assert stoploss_mock.call_count == 1 + stoploss_mock.reset_mock() + + cancel_mock = mocker.patch('freqtrade.exchange.Exchange.cancel_order', + side_effect=InvalidOrderException) + + res = rpc._rpc_delete('4') + assert cancel_mock.call_count == 1 + assert stoploss_mock.call_count == 0 def test_rpc_trade_statistics(default_conf, ticker, ticker_sell_up, fee, @@ -259,8 +370,12 @@ def test_rpc_trade_statistics(default_conf, ticker, ticker_sell_up, fee, rpc = RPC(freqtradebot) rpc._fiat_converter = CryptoToFiatConverter() - with pytest.raises(RPCException, match=r'.*no closed trade*'): - rpc._rpc_trade_statistics(stake_currency, fiat_display_currency) + res = rpc._rpc_trade_statistics(stake_currency, fiat_display_currency) + assert res['trade_count'] == 0 + assert res['first_trade_date'] == '' + assert res['first_trade_timestamp'] == 0 + assert res['latest_trade_date'] == '' + assert res['latest_trade_timestamp'] == 0 # Create some test data freqtradebot.enter_positions() @@ -307,7 +422,7 @@ def test_rpc_trade_statistics(default_conf, ticker, ticker_sell_up, fee, # Test non-available pair mocker.patch('freqtrade.freqtradebot.FreqtradeBot.get_sell_rate', - MagicMock(side_effect=DependencyException(f"Pair 'ETH/BTC' not available"))) + MagicMock(side_effect=ExchangeError("Pair 'ETH/BTC' not available"))) stats = rpc._rpc_trade_statistics(stake_currency, fiat_display_currency) assert stats['trade_count'] == 2 assert stats['first_trade_date'] == 'just now' @@ -536,7 +651,7 @@ def test_rpc_stopbuy(mocker, default_conf) -> None: assert freqtradebot.config['max_open_trades'] != 0 result = rpc._rpc_stopbuy() - assert {'status': 'No more buy will occur from now. Run /reload_conf to reset.'} == result + assert {'status': 'No more buy will occur from now. Run /reload_config to reset.'} == result assert freqtradebot.config['max_open_trades'] == 0 @@ -548,7 +663,7 @@ def test_rpc_forcesell(default_conf, ticker, fee, mocker) -> None: 'freqtrade.exchange.Exchange', fetch_ticker=ticker, cancel_order=cancel_order_mock, - get_order=MagicMock( + fetch_order=MagicMock( return_value={ 'status': 'closed', 'type': 'limit', @@ -594,7 +709,7 @@ def test_rpc_forcesell(default_conf, ticker, fee, mocker) -> None: trade = Trade.query.filter(Trade.id == '1').first() filled_amount = trade.amount / 2 mocker.patch( - 'freqtrade.exchange.Exchange.get_order', + 'freqtrade.exchange.Exchange.fetch_order', return_value={ 'status': 'open', 'type': 'limit', @@ -613,7 +728,7 @@ def test_rpc_forcesell(default_conf, ticker, fee, mocker) -> None: amount = trade.amount # make an limit-buy open trade, if there is no 'filled', don't sell it mocker.patch( - 'freqtrade.exchange.Exchange.get_order', + 'freqtrade.exchange.Exchange.fetch_order', return_value={ 'status': 'open', 'type': 'limit', @@ -630,7 +745,7 @@ def test_rpc_forcesell(default_conf, ticker, fee, mocker) -> None: freqtradebot.enter_positions() # make an limit-sell open trade mocker.patch( - 'freqtrade.exchange.Exchange.get_order', + 'freqtrade.exchange.Exchange.fetch_order', return_value={ 'status': 'open', 'type': 'limit', @@ -813,6 +928,20 @@ def test_rpc_blacklist(mocker, default_conf) -> None: assert ret['blacklist'] == default_conf['exchange']['pair_blacklist'] assert ret['blacklist'] == ['DOGE/BTC', 'HOT/BTC', 'ETH/BTC'] + ret = rpc._rpc_blacklist(["ETH/BTC"]) + assert 'errors' in ret + assert isinstance(ret['errors'], dict) + assert ret['errors']['ETH/BTC']['error_msg'] == 'Pair ETH/BTC already in pairlist.' + + ret = rpc._rpc_blacklist(["ETH/ETH"]) + assert 'StaticPairList' in ret['method'] + assert len(ret['blacklist']) == 3 + assert ret['blacklist'] == default_conf['exchange']['pair_blacklist'] + assert ret['blacklist'] == ['DOGE/BTC', 'HOT/BTC', 'ETH/BTC'] + assert 'errors' in ret + assert isinstance(ret['errors'], dict) + assert ret['errors']['ETH/ETH']['error_msg'] == 'Pair ETH/ETH does not match stake currency.' + def test_rpc_edge_disabled(mocker, default_conf) -> None: mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 6548790cb..408f7e537 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -24,6 +24,7 @@ def botclient(default_conf, mocker): default_conf.update({"api_server": {"enabled": True, "listen_ip_address": "127.0.0.1", "listen_port": 8080, + "CORS_origins": ['http://example.com'], "username": _TEST_USER, "password": _TEST_PASS, }}) @@ -39,16 +40,28 @@ def client_post(client, url, data={}): return client.post(url, content_type="application/json", data=data, - headers={'Authorization': _basic_auth_str(_TEST_USER, _TEST_PASS)}) + headers={'Authorization': _basic_auth_str(_TEST_USER, _TEST_PASS), + 'Origin': 'http://example.com'}) def client_get(client, url): - return client.get(url, headers={'Authorization': _basic_auth_str(_TEST_USER, _TEST_PASS)}) + # Add fake Origin to ensure CORS kicks in + return client.get(url, headers={'Authorization': _basic_auth_str(_TEST_USER, _TEST_PASS), + 'Origin': 'http://example.com'}) -def assert_response(response, expected_code=200): +def client_delete(client, url): + # Add fake Origin to ensure CORS kicks in + return client.delete(url, headers={'Authorization': _basic_auth_str(_TEST_USER, _TEST_PASS), + 'Origin': 'http://example.com'}) + + +def assert_response(response, expected_code=200, needs_cors=True): assert response.status_code == expected_code assert response.content_type == "application/json" + if needs_cors: + assert ('Access-Control-Allow-Credentials', 'true') in response.headers._list + assert ('Access-Control-Allow-Origin', 'http://example.com') in response.headers._list def test_api_not_found(botclient): @@ -65,12 +78,12 @@ def test_api_not_found(botclient): def test_api_unauthorized(botclient): ftbot, client = botclient rc = client.get(f"{BASE_URI}/ping") - assert_response(rc) + assert_response(rc, needs_cors=False) assert rc.json == {'status': 'pong'} # Don't send user/pass information rc = client.get(f"{BASE_URI}/version") - assert_response(rc, 401) + assert_response(rc, 401, needs_cors=False) assert rc.json == {'error': 'Unauthorized'} # Change only username @@ -94,6 +107,35 @@ def test_api_unauthorized(botclient): assert rc.json == {'error': 'Unauthorized'} +def test_api_token_login(botclient): + ftbot, client = botclient + rc = client_post(client, f"{BASE_URI}/token/login") + assert_response(rc) + assert 'access_token' in rc.json + assert 'refresh_token' in rc.json + + # test Authentication is working with JWT tokens too + rc = client.get(f"{BASE_URI}/count", + content_type="application/json", + headers={'Authorization': f'Bearer {rc.json["access_token"]}', + 'Origin': 'http://example.com'}) + assert_response(rc) + + +def test_api_token_refresh(botclient): + ftbot, client = botclient + rc = client_post(client, f"{BASE_URI}/token/login") + assert_response(rc) + rc = client.post(f"{BASE_URI}/token/refresh", + content_type="application/json", + data=None, + headers={'Authorization': f'Bearer {rc.json["refresh_token"]}', + 'Origin': 'http://example.com'}) + assert_response(rc) + assert 'access_token' in rc.json + assert 'refresh_token' not in rc.json + + def test_api_stop_workflow(botclient): ftbot, client = botclient assert ftbot.state == State.RUNNING @@ -123,6 +165,12 @@ def test_api__init__(default_conf, mocker): """ Test __init__() method """ + default_conf.update({"api_server": {"enabled": True, + "listen_ip_address": "127.0.0.1", + "listen_port": 8080, + "username": "TestUser", + "password": "testPass", + }}) mocker.patch('freqtrade.rpc.telegram.Updater', MagicMock()) mocker.patch('freqtrade.rpc.api_server.ApiServer.run', MagicMock()) @@ -211,10 +259,10 @@ def test_api_cleanup(default_conf, mocker, caplog): def test_api_reloadconf(botclient): ftbot, client = botclient - rc = client_post(client, f"{BASE_URI}/reload_conf") + rc = client_post(client, f"{BASE_URI}/reload_config") assert_response(rc) assert rc.json == {'status': 'reloading config ...'} - assert ftbot.state == State.RELOAD_CONF + assert ftbot.state == State.RELOAD_CONFIG def test_api_stopbuy(botclient): @@ -223,7 +271,7 @@ def test_api_stopbuy(botclient): rc = client_post(client, f"{BASE_URI}/stopbuy") assert_response(rc) - assert rc.json == {'status': 'No more buy will occur from now. Run /reload_conf to reset.'} + assert rc.json == {'status': 'No more buy will occur from now. Run /reload_config to reset.'} assert ftbot.config['max_open_trades'] == 0 @@ -283,7 +331,13 @@ def test_api_show_config(botclient, mocker): assert 'dry_run' in rc.json assert rc.json['exchange'] == 'bittrex' assert rc.json['ticker_interval'] == '5m' + assert rc.json['timeframe'] == '5m' + assert rc.json['timeframe_ms'] == 300000 + assert rc.json['timeframe_min'] == 5 + assert rc.json['state'] == 'running' assert not rc.json['trailing_stop'] + assert 'bid_strategy' in rc.json + assert 'ask_strategy' in rc.json def test_api_daily(botclient, mocker, ticker, fee, markets): @@ -298,11 +352,13 @@ def test_api_daily(botclient, mocker, ticker, fee, markets): ) rc = client_get(client, f"{BASE_URI}/daily") assert_response(rc) - assert len(rc.json) == 7 - assert rc.json[0][0] == str(datetime.utcnow().date()) + 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()) -def test_api_trades(botclient, mocker, ticker, fee, markets): +def test_api_trades(botclient, mocker, fee, markets): ftbot, client = botclient patch_get_signal(ftbot, (True, False)) mocker.patch.multiple( @@ -318,12 +374,53 @@ def test_api_trades(botclient, mocker, ticker, fee, markets): rc = client_get(client, f"{BASE_URI}/trades") assert_response(rc) - assert len(rc.json['trades']) == 3 - assert rc.json['trades_count'] == 3 - rc = client_get(client, f"{BASE_URI}/trades?limit=2") - assert_response(rc) assert len(rc.json['trades']) == 2 assert rc.json['trades_count'] == 2 + rc = client_get(client, f"{BASE_URI}/trades?limit=1") + assert_response(rc) + assert len(rc.json['trades']) == 1 + assert rc.json['trades_count'] == 1 + + +def test_api_delete_trade(botclient, mocker, fee, markets): + ftbot, client = botclient + patch_get_signal(ftbot, (True, False)) + stoploss_mock = MagicMock() + cancel_mock = MagicMock() + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + markets=PropertyMock(return_value=markets), + cancel_order=cancel_mock, + cancel_stoploss_order=stoploss_mock, + ) + rc = client_delete(client, f"{BASE_URI}/trades/1") + # Error - trade won't exist yet. + assert_response(rc, 502) + + create_mock_trades(fee) + ftbot.strategy.order_types['stoploss_on_exchange'] = True + trades = Trade.query.all() + trades[1].stoploss_order_id = '1234' + assert len(trades) > 2 + + rc = client_delete(client, f"{BASE_URI}/trades/1") + assert_response(rc) + assert rc.json['result_msg'] == 'Deleted trade 1. Closed 1 open orders.' + assert len(trades) - 1 == len(Trade.query.all()) + assert cancel_mock.call_count == 1 + + cancel_mock.reset_mock() + rc = client_delete(client, f"{BASE_URI}/trades/1") + # Trade is gone now. + assert_response(rc, 502) + assert cancel_mock.call_count == 0 + + assert len(trades) - 1 == len(Trade.query.all()) + rc = client_delete(client, f"{BASE_URI}/trades/2") + assert_response(rc) + assert rc.json['result_msg'] == 'Deleted trade 2. Closed 2 open orders.' + assert len(trades) - 2 == len(Trade.query.all()) + assert stoploss_mock.call_count == 1 def test_api_edge_disabled(botclient, mocker, ticker, fee, markets): @@ -353,9 +450,8 @@ def test_api_profit(botclient, mocker, ticker, fee, markets, limit_buy_order, li ) rc = client_get(client, f"{BASE_URI}/profit") - assert_response(rc, 502) - assert len(rc.json) == 1 - assert rc.json == {"error": "Error querying _profit: no closed trade"} + assert_response(rc, 200) + assert rc.json['trade_count'] == 0 ftbot.enter_positions() trade = Trade.query.first() @@ -363,8 +459,11 @@ def test_api_profit(botclient, mocker, ticker, fee, markets, limit_buy_order, li # Simulate fulfilled LIMIT_BUY order for trade trade.update(limit_buy_order) rc = client_get(client, f"{BASE_URI}/profit") - assert_response(rc, 502) - assert rc.json == {"error": "Error querying _profit: no closed trade"} + assert_response(rc, 200) + # One open trade + assert rc.json['trade_count'] == 1 + assert rc.json['best_pair'] == '' + assert rc.json['best_rate'] == 0 trade.update(limit_sell_order) @@ -377,14 +476,27 @@ def test_api_profit(botclient, mocker, ticker, fee, markets, limit_buy_order, li 'best_pair': 'ETH/BTC', 'best_rate': 6.2, 'first_trade_date': 'just now', + 'first_trade_timestamp': ANY, 'latest_trade_date': 'just now', + 'latest_trade_timestamp': ANY, 'profit_all_coin': 6.217e-05, - 'profit_all_fiat': 0, + 'profit_all_fiat': 0.76748865, 'profit_all_percent': 6.2, + 'profit_all_percent_mean': 6.2, + 'profit_all_ratio_mean': 0.06201058, + 'profit_all_percent_sum': 6.2, + 'profit_all_ratio_sum': 0.06201058, 'profit_closed_coin': 6.217e-05, - 'profit_closed_fiat': 0, + 'profit_closed_fiat': 0.76748865, 'profit_closed_percent': 6.2, - 'trade_count': 1 + 'profit_closed_ratio_mean': 0.06201058, + 'profit_closed_percent_mean': 6.2, + 'profit_closed_ratio_sum': 0.06201058, + 'profit_closed_percent_sum': 6.2, + 'trade_count': 1, + 'closed_trade_count': 1, + 'winning_trades': 1, + 'losing_trades': 0, } @@ -447,42 +559,72 @@ def test_api_status(botclient, mocker, ticker, fee, markets): assert rc.json == [] ftbot.enter_positions() + trades = Trade.get_open_trades() + trades[0].open_order_id = None + ftbot.exit_positions(trades) + rc = client_get(client, f"{BASE_URI}/status") assert_response(rc) assert len(rc.json) == 1 - assert rc.json == [{'amount': 91.07468124, + assert rc.json == [{'amount': 91.07468123, + 'amount_requested': 91.07468123, 'base_currency': 'BTC', 'close_date': None, 'close_date_hum': None, + 'close_timestamp': None, 'close_profit': None, + 'close_profit_pct': None, + 'close_profit_abs': None, 'close_rate': None, - 'current_profit': -0.41, + 'current_profit': -0.00408133, + 'current_profit_pct': -0.41, + 'current_profit_abs': -4.09e-06, 'current_rate': 1.099e-05, - 'initial_stop_loss': 0.0, - 'initial_stop_loss_pct': None, 'open_date': ANY, 'open_date_hum': 'just now', - 'open_order': '(limit buy rem=0.00000000)', + 'open_timestamp': ANY, + 'open_order': None, 'open_rate': 1.098e-05, 'pair': 'ETH/BTC', 'stake_amount': 0.001, - 'stop_loss': 0.0, - 'stop_loss_pct': None, + 'stop_loss': 9.882e-06, + 'stop_loss_abs': 9.882e-06, + 'stop_loss_pct': -10.0, + 'stop_loss_ratio': -0.1, + 'stoploss_order_id': None, + 'stoploss_last_update': ANY, + 'stoploss_last_update_timestamp': ANY, + 'initial_stop_loss': 9.882e-06, + 'initial_stop_loss_abs': 9.882e-06, + 'initial_stop_loss_pct': -10.0, + 'initial_stop_loss_ratio': -0.1, + 'stoploss_current_dist': -1.1080000000000002e-06, + 'stoploss_current_dist_ratio': -0.10081893, + 'stoploss_entry_dist': -0.00010475, + 'stoploss_entry_dist_ratio': -0.10448878, 'trade_id': 1, 'close_rate_requested': None, 'current_rate': 1.099e-05, 'fee_close': 0.0025, + 'fee_close_cost': None, + 'fee_close_currency': None, 'fee_open': 0.0025, + 'fee_open_cost': None, + 'fee_open_currency': None, 'open_date': ANY, 'is_open': True, - 'max_rate': 0.0, - 'min_rate': None, - 'open_order_id': ANY, + 'max_rate': 1.099e-05, + 'min_rate': 1.098e-05, + 'open_order_id': None, 'open_rate_requested': 1.098e-05, 'open_trade_price': 0.0010025, 'sell_reason': None, + 'sell_order_status': None, 'strategy': 'DefaultStrategy', - 'ticker_interval': 5}] + 'ticker_interval': 5, + 'timeframe': 5, + 'exchange': 'bittrex', + }] def test_api_version(botclient): @@ -500,7 +642,9 @@ def test_api_blacklist(botclient, mocker): assert_response(rc) assert rc.json == {"blacklist": ["DOGE/BTC", "HOT/BTC"], "length": 2, - "method": ["StaticPairList"]} + "method": ["StaticPairList"], + "errors": {}, + } # Add ETH/BTC to blacklist rc = client_post(client, f"{BASE_URI}/blacklist", @@ -508,7 +652,9 @@ def test_api_blacklist(botclient, mocker): assert_response(rc) assert rc.json == {"blacklist": ["DOGE/BTC", "HOT/BTC", "ETH/BTC"], "length": 3, - "method": ["StaticPairList"]} + "method": ["StaticPairList"], + "errors": {}, + } def test_api_whitelist(botclient): @@ -543,6 +689,7 @@ def test_api_forcebuy(botclient, mocker, fee): fbuy_mock = MagicMock(return_value=Trade( pair='ETH/ETH', amount=1, + amount_requested=1, exchange='bittrex', stake_amount=1, open_rate=0.245441, @@ -559,32 +706,50 @@ def test_api_forcebuy(botclient, mocker, fee): data='{"pair": "ETH/BTC"}') assert_response(rc) assert rc.json == {'amount': 1, + 'amount_requested': 1, + 'trade_id': None, 'close_date': None, 'close_date_hum': None, + 'close_timestamp': None, 'close_rate': 0.265441, - 'initial_stop_loss': None, - 'initial_stop_loss_pct': None, 'open_date': ANY, 'open_date_hum': 'just now', + 'open_timestamp': ANY, 'open_rate': 0.245441, 'pair': 'ETH/ETH', 'stake_amount': 1, 'stop_loss': None, + 'stop_loss_abs': None, 'stop_loss_pct': None, - 'trade_id': None, + 'stop_loss_ratio': None, + 'stoploss_order_id': None, + 'stoploss_last_update': None, + 'stoploss_last_update_timestamp': None, + 'initial_stop_loss': None, + 'initial_stop_loss_abs': None, + 'initial_stop_loss_pct': None, + 'initial_stop_loss_ratio': None, 'close_profit': None, + 'close_profit_abs': None, 'close_rate_requested': None, 'fee_close': 0.0025, + 'fee_close_cost': None, + 'fee_close_currency': None, 'fee_open': 0.0025, + 'fee_open_cost': None, + 'fee_open_currency': None, 'is_open': False, 'max_rate': None, 'min_rate': None, 'open_order_id': '123456', 'open_rate_requested': None, - 'open_trade_price': 0.2460546025, + 'open_trade_price': 0.24605460, 'sell_reason': None, + 'sell_order_status': None, 'strategy': None, - 'ticker_interval': None + 'ticker_interval': None, + 'timeframe': None, + 'exchange': 'bittrex', } diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index bbc961763..bfa774856 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -21,8 +21,9 @@ from freqtrade.rpc import RPCMessageType from freqtrade.rpc.telegram import Telegram, authorized_only from freqtrade.state import State from freqtrade.strategy.interface import SellType -from tests.conftest import (get_patched_freqtradebot, log_has, patch_exchange, - patch_get_signal, patch_whitelist) +from tests.conftest import (create_mock_trades, get_patched_freqtradebot, + log_has, patch_exchange, patch_get_signal, + patch_whitelist) class DummyCls(Telegram): @@ -60,7 +61,7 @@ def test__init__(default_conf, mocker) -> None: assert telegram._config == default_conf -def test_init(default_conf, mocker, caplog) -> None: +def test_telegram_init(default_conf, mocker, caplog) -> None: start_polling = MagicMock() mocker.patch('freqtrade.rpc.telegram.Updater', MagicMock(return_value=start_polling)) @@ -71,10 +72,11 @@ def test_init(default_conf, mocker, caplog) -> None: assert start_polling.dispatcher.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'], ['forcebuy'], " \ - "['performance'], ['daily'], ['count'], ['reload_conf'], ['show_config'], " \ - "['stopbuy'], ['whitelist'], ['blacklist'], ['edge'], ['help'], ['version']]" + message_str = ("rpc.telegram is listening for following commands: [['status'], ['profit'], " + "['balance'], ['start'], ['stop'], ['forcesell'], ['forcebuy'], ['trades'], " + "['delete'], ['performance'], ['daily'], ['count'], ['reload_config', " + "'reload_conf'], ['show_config', 'show_conf'], ['stopbuy'], " + "['whitelist'], ['blacklist'], ['edge'], ['help'], ['version']]") assert log_has(message_str, caplog) @@ -166,10 +168,12 @@ def test_status(default_conf, update, mocker, fee, ticker,) -> None: 'current_rate': 1.098e-05, 'amount': 90.99181074, 'stake_amount': 90.99181074, - 'close_profit': None, - 'current_profit': -0.59, + 'close_profit_pct': None, + 'current_profit': -0.0059, + 'current_profit_pct': -0.59, 'initial_stop_loss': 1.098e-05, 'stop_loss': 1.099e-05, + 'sell_order_status': None, 'initial_stop_loss_pct': -0.05, 'stop_loss_pct': -0.01, 'open_order': '(limit buy rem=0.00000000)' @@ -418,7 +422,7 @@ def test_profit_handle(default_conf, update, ticker, ticker_sell_up, fee, telegram._profit(update=update, context=MagicMock()) assert msg_mock.call_count == 1 - assert 'no closed trade' in msg_mock.call_args_list[0][0][0] + assert 'No trades yet.' in msg_mock.call_args_list[0][0][0] msg_mock.reset_mock() # Create some test data @@ -430,7 +434,10 @@ def test_profit_handle(default_conf, update, ticker, ticker_sell_up, fee, telegram._profit(update=update, context=MagicMock()) assert msg_mock.call_count == 1 - assert 'no closed trade' in msg_mock.call_args_list[-1][0][0] + 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] + assert ('∙ `-0.00000500 BTC (-0.50%) (-0.5 \N{GREEK CAPITAL LETTER SIGMA}%)`' + in msg_mock.call_args_list[-1][0][0]) msg_mock.reset_mock() # Update the ticker with a market going up @@ -442,11 +449,13 @@ def test_profit_handle(default_conf, update, ticker, ticker_sell_up, fee, telegram._profit(update=update, context=MagicMock()) assert msg_mock.call_count == 1 - assert '*ROI:* Close trades' in msg_mock.call_args_list[-1][0][0] - assert '∙ `0.00006217 BTC (6.20%)`' in msg_mock.call_args_list[-1][0][0] + assert '*ROI:* Closed trades' in msg_mock.call_args_list[-1][0][0] + assert ('∙ `0.00006217 BTC (6.20%) (6.2 \N{GREEK CAPITAL LETTER SIGMA}%)`' + in msg_mock.call_args_list[-1][0][0]) assert '∙ `0.933 USD`' in msg_mock.call_args_list[-1][0][0] assert '*ROI:* All trades' in msg_mock.call_args_list[-1][0][0] - assert '∙ `0.00006217 BTC (6.20%)`' in msg_mock.call_args_list[-1][0][0] + assert ('∙ `0.00006217 BTC (6.20%) (6.2 \N{GREEK CAPITAL LETTER SIGMA}%)`' + in msg_mock.call_args_list[-1][0][0]) assert '∙ `0.933 USD`' in msg_mock.call_args_list[-1][0][0] assert '*Best Performing:* `ETH/BTC: 6.20%`' in msg_mock.call_args_list[-1][0][0] @@ -659,11 +668,11 @@ def test_stopbuy_handle(default_conf, update, mocker) -> None: telegram._stopbuy(update=update, context=MagicMock()) assert freqtradebot.config['max_open_trades'] == 0 assert msg_mock.call_count == 1 - assert 'No more buy will occur from now. Run /reload_conf to reset.' \ + assert 'No more buy will occur from now. Run /reload_config to reset.' \ in msg_mock.call_args_list[0][0][0] -def test_reload_conf_handle(default_conf, update, mocker) -> None: +def test_reload_config_handle(default_conf, update, mocker) -> None: msg_mock = MagicMock() mocker.patch.multiple( 'freqtrade.rpc.telegram.Telegram', @@ -676,14 +685,14 @@ def test_reload_conf_handle(default_conf, update, mocker) -> None: freqtradebot.state = State.RUNNING assert freqtradebot.state == State.RUNNING - telegram._reload_conf(update=update, context=MagicMock()) - assert freqtradebot.state == State.RELOAD_CONF + 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_forcesell_handle(default_conf, update, ticker, fee, - ticker_sell_up, mocker) -> None: +def test_telegram_forcesell_handle(default_conf, update, ticker, fee, + ticker_sell_up, mocker) -> None: mocker.patch('freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', return_value=15000.0) rpc_mock = mocker.patch('freqtrade.rpc.telegram.Telegram.send_msg', MagicMock()) mocker.patch('freqtrade.rpc.telegram.Telegram._init', MagicMock()) @@ -717,11 +726,12 @@ def test_forcesell_handle(default_conf, update, ticker, fee, last_msg = rpc_mock.call_args_list[-1][0][0] assert { 'type': RPCMessageType.SELL_NOTIFICATION, + 'trade_id': 1, 'exchange': 'Bittrex', 'pair': 'ETH/BTC', 'gain': 'profit', 'limit': 1.173e-05, - 'amount': 91.07468123861567, + 'amount': 91.07468123, 'order_type': 'limit', 'open_rate': 1.098e-05, 'current_rate': 1.173e-05, @@ -735,8 +745,8 @@ def test_forcesell_handle(default_conf, update, ticker, fee, } == last_msg -def test_forcesell_down_handle(default_conf, update, ticker, fee, - ticker_sell_down, mocker) -> None: +def test_telegram_forcesell_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) rpc_mock = mocker.patch('freqtrade.rpc.telegram.Telegram.send_msg', MagicMock()) @@ -776,11 +786,12 @@ def test_forcesell_down_handle(default_conf, update, ticker, fee, last_msg = rpc_mock.call_args_list[-1][0][0] assert { 'type': RPCMessageType.SELL_NOTIFICATION, + 'trade_id': 1, 'exchange': 'Bittrex', 'pair': 'ETH/BTC', 'gain': 'loss', 'limit': 1.043e-05, - 'amount': 91.07468123861567, + 'amount': 91.07468123, 'order_type': 'limit', 'open_rate': 1.098e-05, 'current_rate': 1.043e-05, @@ -824,11 +835,12 @@ def test_forcesell_all_handle(default_conf, update, ticker, fee, mocker) -> None msg = rpc_mock.call_args_list[0][0][0] assert { 'type': RPCMessageType.SELL_NOTIFICATION, + 'trade_id': 1, 'exchange': 'Bittrex', 'pair': 'ETH/BTC', 'gain': 'loss', 'limit': 1.099e-05, - 'amount': 91.07468123861567, + 'amount': 91.07468123, 'order_type': 'limit', 'open_rate': 1.098e-05, 'current_rate': 1.099e-05, @@ -1009,9 +1021,8 @@ def test_count_handle(default_conf, update, ticker, fee, mocker) -> None: msg_mock.reset_mock() telegram._count(update=update, context=MagicMock()) - msg = '
  current    max    total stake\n---------  -----  -------------\n' \
-          '        1      {}          {}
'\ - .format( + msg = ('
  current    max    total stake\n---------  -----  -------------\n'
+           '        1      {}          {}
').format( default_conf['max_open_trades'], default_conf['stake_amount'] ) @@ -1083,6 +1094,18 @@ def test_blacklist_static(default_conf, update, mocker) -> None: in msg_mock.call_args_list[0][0][0]) assert freqtradebot.pairlists.blacklist == ["DOGE/BTC", "HOT/BTC", "ETH/BTC"] + msg_mock.reset_mock() + context = MagicMock() + context.args = ["ETH/ETH"] + telegram._blacklist(update=update, context=context) + assert msg_mock.call_count == 2 + assert ("Error adding `ETH/ETH` to blacklist: `Pair ETH/ETH does not match stake currency.`" + in msg_mock.call_args_list[0][0][0]) + + assert ("Blacklist contains 3 pairs\n`DOGE/BTC, HOT/BTC, ETH/BTC`" + in msg_mock.call_args_list[1][0][0]) + assert freqtradebot.pairlists.blacklist == ["DOGE/BTC", "HOT/BTC", "ETH/BTC"] + def test_edge_disabled(default_conf, update, mocker) -> None: msg_mock = MagicMock() @@ -1124,6 +1147,63 @@ def test_edge_enabled(edge_conf, update, mocker) -> None: assert 'Pair Winrate Expectancy Stoploss' in msg_mock.call_args_list[0][0][0] +def test_telegram_trades(mocker, update, default_conf, fee): + msg_mock = MagicMock() + mocker.patch.multiple( + 'freqtrade.rpc.telegram.Telegram', + _init=MagicMock(), + _send_msg=msg_mock + ) + + freqtradebot = get_patched_freqtradebot(mocker, default_conf) + telegram = Telegram(freqtradebot) + context = MagicMock() + context.args = [] + + 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()
+    create_mock_trades(fee)
+
+    context = MagicMock()
+    context.args = [5]
+    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]
+    assert "Open Date" in msg_mock.call_args_list[0][0][0]
+    assert "
" in msg_mock.call_args_list[0][0][0]
+
+
+def test_telegram_delete_trade(mocker, update, default_conf, fee):
+    msg_mock = MagicMock()
+    mocker.patch.multiple(
+        'freqtrade.rpc.telegram.Telegram',
+        _init=MagicMock(),
+        _send_msg=msg_mock
+    )
+
+    freqtradebot = get_patched_freqtradebot(mocker, default_conf)
+    telegram = Telegram(freqtradebot)
+    context = MagicMock()
+    context.args = []
+
+    telegram._delete_trade(update=update, context=context)
+    assert "invalid argument" in msg_mock.call_args_list[0][0][0]
+
+    msg_mock.reset_mock()
+    create_mock_trades(fee)
+
+    context = MagicMock()
+    context.args = [1]
+    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]
+
+
 def test_help_handle(default_conf, update, mocker) -> None:
     msg_mock = MagicMock()
     mocker.patch.multiple(
@@ -1206,7 +1286,7 @@ def test_send_msg_buy_notification(default_conf, mocker) -> None:
         'open_date': arrow.utcnow().shift(hours=-1)
     })
     assert msg_mock.call_args[0][0] \
-        == '*Bittrex:* Buying ETH/BTC\n' \
+        == '\N{LARGE BLUE CIRCLE} *Bittrex:* Buying ETH/BTC\n' \
            '*Amount:* `1333.33333333`\n' \
            '*Open Rate:* `0.00001099`\n' \
            '*Current Rate:* `0.00001099`\n' \
@@ -1228,7 +1308,7 @@ def test_send_msg_buy_cancel_notification(default_conf, mocker) -> None:
         'pair': 'ETH/BTC',
     })
     assert msg_mock.call_args[0][0] \
-        == ('*Bittrex:* Cancelling Open Buy Order for ETH/BTC')
+        == ('\N{WARNING SIGN} *Bittrex:* Cancelling Open Buy Order for ETH/BTC')
 
 
 def test_send_msg_sell_notification(default_conf, mocker) -> None:
@@ -1261,7 +1341,7 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None:
         'close_date': arrow.utcnow(),
     })
     assert msg_mock.call_args[0][0] \
-        == ('*Binance:* Selling KEY/ETH\n'
+        == ('\N{WARNING SIGN} *Binance:* Selling KEY/ETH\n'
             '*Amount:* `1333.33333333`\n'
             '*Open Rate:* `0.00007500`\n'
             '*Current Rate:* `0.00003201`\n'
@@ -1289,7 +1369,7 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None:
         'close_date': arrow.utcnow(),
     })
     assert msg_mock.call_args[0][0] \
-        == ('*Binance:* Selling KEY/ETH\n'
+        == ('\N{WARNING SIGN} *Binance:* Selling KEY/ETH\n'
             '*Amount:* `1333.33333333`\n'
             '*Open Rate:* `0.00007500`\n'
             '*Current Rate:* `0.00003201`\n'
@@ -1319,7 +1399,8 @@ def test_send_msg_sell_cancel_notification(default_conf, mocker) -> None:
         'reason': 'Cancelled on exchange'
     })
     assert msg_mock.call_args[0][0] \
-        == ('*Binance:* Cancelling Open Sell Order for KEY/ETH. Reason: Cancelled on exchange')
+        == ('\N{WARNING SIGN} *Binance:* Cancelling Open Sell Order for KEY/ETH. '
+            'Reason: Cancelled on exchange')
 
     msg_mock.reset_mock()
     telegram.send_msg({
@@ -1329,7 +1410,7 @@ def test_send_msg_sell_cancel_notification(default_conf, mocker) -> None:
         'reason': 'timeout'
     })
     assert msg_mock.call_args[0][0] \
-        == ('*Binance:* Cancelling Open Sell Order for KEY/ETH. Reason: timeout')
+        == ('\N{WARNING SIGN} *Binance:* Cancelling Open Sell Order for KEY/ETH. Reason: timeout')
     # Reset singleton function to avoid random breaks
     telegram._fiat_converter.convert_amount = old_convamount
 
@@ -1363,7 +1444,7 @@ def test_warning_notification(default_conf, mocker) -> None:
         'type': RPCMessageType.WARNING_NOTIFICATION,
         'status': 'message'
     })
-    assert msg_mock.call_args[0][0] == '*Warning:* `message`'
+    assert msg_mock.call_args[0][0] == '\N{WARNING SIGN} *Warning:* `message`'
 
 
 def test_custom_notification(default_conf, mocker) -> None:
@@ -1421,12 +1502,11 @@ def test_send_msg_buy_notification_no_fiat(default_conf, mocker) -> None:
         'amount': 1333.3333333333335,
         'open_date': arrow.utcnow().shift(hours=-1)
     })
-    assert msg_mock.call_args[0][0] \
-        == '*Bittrex:* Buying ETH/BTC\n' \
-           '*Amount:* `1333.33333333`\n' \
-           '*Open Rate:* `0.00001099`\n' \
-           '*Current Rate:* `0.00001099`\n' \
-           '*Total:* `(0.001000 BTC)`'
+    assert msg_mock.call_args[0][0] == ('\N{LARGE BLUE CIRCLE} *Bittrex:* Buying ETH/BTC\n'
+                                        '*Amount:* `1333.33333333`\n'
+                                        '*Open Rate:* `0.00001099`\n'
+                                        '*Current Rate:* `0.00001099`\n'
+                                        '*Total:* `(0.001000 BTC)`')
 
 
 def test_send_msg_sell_notification_no_fiat(default_conf, mocker) -> None:
@@ -1457,15 +1537,37 @@ def test_send_msg_sell_notification_no_fiat(default_conf, mocker) -> None:
         'open_date': arrow.utcnow().shift(hours=-2, minutes=-35, seconds=-3),
         'close_date': arrow.utcnow(),
     })
-    assert msg_mock.call_args[0][0] \
-        == '*Binance:* Selling KEY/ETH\n' \
-           '*Amount:* `1333.33333333`\n' \
-           '*Open Rate:* `0.00007500`\n' \
-           '*Current Rate:* `0.00003201`\n' \
-           '*Close Rate:* `0.00003201`\n' \
-           '*Sell Reason:* `stop_loss`\n' \
-           '*Duration:* `2:35:03 (155.1 min)`\n' \
-           '*Profit:* `-57.41%`'
+    assert msg_mock.call_args[0][0] == ('\N{WARNING SIGN} *Binance:* Selling KEY/ETH\n'
+                                        '*Amount:* `1333.33333333`\n'
+                                        '*Open Rate:* `0.00007500`\n'
+                                        '*Current Rate:* `0.00003201`\n'
+                                        '*Close Rate:* `0.00003201`\n'
+                                        '*Sell Reason:* `stop_loss`\n'
+                                        '*Duration:* `2:35:03 (155.1 min)`\n'
+                                        '*Profit:* `-57.41%`')
+
+
+@pytest.mark.parametrize('msg,expected', [
+    ({'profit_percent': 20.1, 'sell_reason': 'roi'}, "\N{ROCKET}"),
+    ({'profit_percent': 5.1, 'sell_reason': 'roi'}, "\N{ROCKET}"),
+    ({'profit_percent': 2.56, 'sell_reason': 'roi'}, "\N{EIGHT SPOKED ASTERISK}"),
+    ({'profit_percent': 1.0, 'sell_reason': 'roi'}, "\N{EIGHT SPOKED ASTERISK}"),
+    ({'profit_percent': 0.0, 'sell_reason': 'roi'}, "\N{EIGHT SPOKED ASTERISK}"),
+    ({'profit_percent': -5.0, 'sell_reason': 'stop_loss'}, "\N{WARNING SIGN}"),
+    ({'profit_percent': -2.0, 'sell_reason': 'sell_signal'}, "\N{CROSS MARK}"),
+])
+def test__sell_emoji(default_conf, mocker, msg, expected):
+    del default_conf['fiat_display_currency']
+    msg_mock = MagicMock()
+    mocker.patch.multiple(
+        'freqtrade.rpc.telegram.Telegram',
+        _init=MagicMock(),
+        _send_msg=msg_mock
+    )
+    freqtradebot = get_patched_freqtradebot(mocker, default_conf)
+    telegram = Telegram(freqtradebot)
+
+    assert telegram._get_sell_emoji(msg) == expected
 
 
 def test__send_msg(default_conf, mocker) -> None:
diff --git a/tests/strategy/strats/default_strategy.py b/tests/strategy/strats/default_strategy.py
index 7ea55d3f9..98842ff7c 100644
--- a/tests/strategy/strats/default_strategy.py
+++ b/tests/strategy/strats/default_strategy.py
@@ -29,7 +29,7 @@ class DefaultStrategy(IStrategy):
     stoploss = -0.10
 
     # Optimal ticker interval for the strategy
-    ticker_interval = '5m'
+    timeframe = '5m'
 
     # Optional order type mapping
     order_types = {
diff --git a/tests/strategy/strats/legacy_strategy.py b/tests/strategy/strats/legacy_strategy.py
index 89ce3f8cb..9cbce0ad5 100644
--- a/tests/strategy/strats/legacy_strategy.py
+++ b/tests/strategy/strats/legacy_strategy.py
@@ -31,6 +31,7 @@ class TestStrategyLegacy(IStrategy):
     stoploss = -0.10
 
     # Optimal ticker interval for the strategy
+    # Keep the legacy value here to test compatibility
     ticker_interval = '5m'
 
     def populate_indicators(self, dataframe: DataFrame) -> DataFrame:
diff --git a/tests/strategy/test_default_strategy.py b/tests/strategy/test_default_strategy.py
index 0b8ea9f85..1b1648db9 100644
--- a/tests/strategy/test_default_strategy.py
+++ b/tests/strategy/test_default_strategy.py
@@ -6,7 +6,7 @@ from .strats.default_strategy import DefaultStrategy
 def test_default_strategy_structure():
     assert hasattr(DefaultStrategy, 'minimal_roi')
     assert hasattr(DefaultStrategy, 'stoploss')
-    assert hasattr(DefaultStrategy, 'ticker_interval')
+    assert hasattr(DefaultStrategy, 'timeframe')
     assert hasattr(DefaultStrategy, 'populate_indicators')
     assert hasattr(DefaultStrategy, 'populate_buy_trend')
     assert hasattr(DefaultStrategy, 'populate_sell_trend')
@@ -18,7 +18,7 @@ def test_default_strategy(result):
     metadata = {'pair': 'ETH/BTC'}
     assert type(strategy.minimal_roi) is dict
     assert type(strategy.stoploss) is float
-    assert type(strategy.ticker_interval) is str
+    assert type(strategy.timeframe) is str
     indicators = strategy.populate_indicators(result, metadata)
     assert type(indicators) is DataFrame
     assert type(strategy.populate_buy_trend(indicators, metadata)) is DataFrame
diff --git a/tests/strategy/test_interface.py b/tests/strategy/test_interface.py
index 1c31aeb6a..381454622 100644
--- a/tests/strategy/test_interface.py
+++ b/tests/strategy/test_interface.py
@@ -9,15 +9,18 @@ from pandas import DataFrame
 
 from freqtrade.configuration import TimeRange
 from freqtrade.data.history import load_data
-from freqtrade.exceptions import DependencyException
+from freqtrade.exceptions import StrategyError
 from freqtrade.persistence import Trade
 from freqtrade.resolvers import StrategyResolver
-from tests.conftest import get_patched_exchange, log_has
+from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
+from freqtrade.data.dataprovider import DataProvider
+from tests.conftest import log_has, log_has_re
 
 from .strats.default_strategy import DefaultStrategy
 
 # Avoid to reinit the same object again and again
 _STRATEGY = DefaultStrategy(config={})
+_STRATEGY.dp = DataProvider({}, None, None)
 
 
 def test_returns_latest_signal(mocker, default_conf, ohlcv_history):
@@ -28,63 +31,60 @@ def test_returns_latest_signal(mocker, default_conf, ohlcv_history):
     mocked_history['buy'] = 0
     mocked_history.loc[1, 'sell'] = 1
 
-    mocker.patch.object(
-        _STRATEGY, '_analyze_ticker_internal',
-        return_value=mocked_history
-    )
-
-    assert _STRATEGY.get_signal('ETH/BTC', '5m', ohlcv_history) == (False, True)
+    assert _STRATEGY.get_signal('ETH/BTC', '5m', mocked_history) == (False, True)
     mocked_history.loc[1, 'sell'] = 0
     mocked_history.loc[1, 'buy'] = 1
 
-    mocker.patch.object(
-        _STRATEGY, '_analyze_ticker_internal',
-        return_value=mocked_history
-    )
-    assert _STRATEGY.get_signal('ETH/BTC', '5m', ohlcv_history) == (True, False)
+    assert _STRATEGY.get_signal('ETH/BTC', '5m', mocked_history) == (True, False)
     mocked_history.loc[1, 'sell'] = 0
     mocked_history.loc[1, 'buy'] = 0
 
-    mocker.patch.object(
-        _STRATEGY, '_analyze_ticker_internal',
-        return_value=mocked_history
-    )
-    assert _STRATEGY.get_signal('ETH/BTC', '5m', ohlcv_history) == (False, False)
+    assert _STRATEGY.get_signal('ETH/BTC', '5m', mocked_history) == (False, False)
 
 
-def test_get_signal_empty(default_conf, mocker, caplog):
-    assert (False, False) == _STRATEGY.get_signal('foo', default_conf['ticker_interval'],
-                                                  DataFrame())
-    assert log_has('Empty candle (OHLCV) data for pair foo', caplog)
-    caplog.clear()
-
-    assert (False, False) == _STRATEGY.get_signal('bar', default_conf['ticker_interval'],
-                                                  [])
-    assert log_has('Empty candle (OHLCV) data for pair bar', caplog)
-
-
-def test_get_signal_exception_valueerror(default_conf, mocker, caplog, ohlcv_history):
-    caplog.set_level(logging.INFO)
-    mocker.patch.object(
-        _STRATEGY, '_analyze_ticker_internal',
-        side_effect=ValueError('xyz')
-    )
-    assert (False, False) == _STRATEGY.get_signal('foo', default_conf['ticker_interval'],
-                                                  ohlcv_history)
-    assert log_has('Unable to analyze candle (OHLCV) data for pair foo: xyz', caplog)
-
-
-def test_get_signal_empty_dataframe(default_conf, mocker, caplog, ohlcv_history):
-    caplog.set_level(logging.INFO)
+def test_analyze_pair_empty(default_conf, mocker, caplog, ohlcv_history):
+    mocker.patch.object(_STRATEGY.dp, 'ohlcv', return_value=ohlcv_history)
     mocker.patch.object(
         _STRATEGY, '_analyze_ticker_internal',
         return_value=DataFrame([])
     )
     mocker.patch.object(_STRATEGY, 'assert_df')
 
-    assert (False, False) == _STRATEGY.get_signal('xyz', default_conf['ticker_interval'],
-                                                  ohlcv_history)
-    assert log_has('Empty dataframe for pair xyz', caplog)
+    _STRATEGY.analyze_pair('ETH/BTC')
+
+    assert log_has('Empty dataframe for pair ETH/BTC', caplog)
+
+
+def test_get_signal_empty(default_conf, mocker, caplog):
+    assert (False, False) == _STRATEGY.get_signal('foo', default_conf['timeframe'], DataFrame())
+    assert log_has('Empty candle (OHLCV) data for pair foo', caplog)
+    caplog.clear()
+
+    assert (False, False) == _STRATEGY.get_signal('bar', default_conf['timeframe'], None)
+    assert log_has('Empty candle (OHLCV) data for pair bar', caplog)
+    caplog.clear()
+
+    assert (False, False) == _STRATEGY.get_signal('baz', default_conf['timeframe'], DataFrame([]))
+    assert log_has('Empty candle (OHLCV) data for pair baz', caplog)
+
+
+def test_get_signal_exception_valueerror(default_conf, mocker, caplog, ohlcv_history):
+    caplog.set_level(logging.INFO)
+    mocker.patch.object(_STRATEGY.dp, 'ohlcv', return_value=ohlcv_history)
+    mocker.patch.object(
+        _STRATEGY, '_analyze_ticker_internal',
+        side_effect=ValueError('xyz')
+    )
+    _STRATEGY.analyze_pair('foo')
+    assert log_has_re(r'Strategy caused the following exception: xyz.*', caplog)
+    caplog.clear()
+
+    mocker.patch.object(
+        _STRATEGY, 'analyze_ticker',
+        side_effect=Exception('invalid ticker history ')
+    )
+    _STRATEGY.analyze_pair('foo')
+    assert log_has_re(r'Strategy caused the following exception: xyz.*', caplog)
 
 
 def test_get_signal_old_dataframe(default_conf, mocker, caplog, ohlcv_history):
@@ -98,13 +98,9 @@ def test_get_signal_old_dataframe(default_conf, mocker, caplog, ohlcv_history):
     mocked_history.loc[1, 'buy'] = 1
 
     caplog.set_level(logging.INFO)
-    mocker.patch.object(
-        _STRATEGY, '_analyze_ticker_internal',
-        return_value=mocked_history
-    )
     mocker.patch.object(_STRATEGY, 'assert_df')
-    assert (False, False) == _STRATEGY.get_signal('xyz', default_conf['ticker_interval'],
-                                                  ohlcv_history)
+
+    assert (False, False) == _STRATEGY.get_signal('xyz', default_conf['timeframe'], mocked_history)
     assert log_has('Outdated history for pair xyz. Last tick is 16 minutes old', caplog)
 
 
@@ -119,42 +115,42 @@ def test_assert_df_raise(default_conf, mocker, caplog, ohlcv_history):
     mocked_history.loc[1, 'buy'] = 1
 
     caplog.set_level(logging.INFO)
+    mocker.patch.object(_STRATEGY.dp, 'ohlcv', return_value=ohlcv_history)
+    mocker.patch.object(_STRATEGY.dp, 'get_analyzed_dataframe', return_value=(mocked_history, 0))
     mocker.patch.object(
         _STRATEGY, 'assert_df',
-        side_effect=DependencyException('Dataframe returned...')
+        side_effect=StrategyError('Dataframe returned...')
     )
-    assert (False, False) == _STRATEGY.get_signal('xyz', default_conf['ticker_interval'],
-                                                  ohlcv_history)
+    _STRATEGY.analyze_pair('xyz')
     assert log_has('Unable to analyze candle (OHLCV) data for pair xyz: Dataframe returned...',
                    caplog)
 
 
-def test_assert_df(default_conf, mocker, ohlcv_history):
+def test_assert_df(default_conf, mocker, ohlcv_history, caplog):
     # Ensure it's running when passed correctly
     _STRATEGY.assert_df(ohlcv_history, len(ohlcv_history),
                         ohlcv_history.loc[1, 'close'], ohlcv_history.loc[1, 'date'])
 
-    with pytest.raises(DependencyException, match=r"Dataframe returned from strategy.*length\."):
+    with pytest.raises(StrategyError, match=r"Dataframe returned from strategy.*length\."):
         _STRATEGY.assert_df(ohlcv_history, len(ohlcv_history) + 1,
                             ohlcv_history.loc[1, 'close'], ohlcv_history.loc[1, 'date'])
 
-    with pytest.raises(DependencyException,
+    with pytest.raises(StrategyError,
                        match=r"Dataframe returned from strategy.*last close price\."):
         _STRATEGY.assert_df(ohlcv_history, len(ohlcv_history),
                             ohlcv_history.loc[1, 'close'] + 0.01, ohlcv_history.loc[1, 'date'])
-    with pytest.raises(DependencyException,
+    with pytest.raises(StrategyError,
                        match=r"Dataframe returned from strategy.*last date\."):
         _STRATEGY.assert_df(ohlcv_history, len(ohlcv_history),
                             ohlcv_history.loc[1, 'close'], ohlcv_history.loc[0, 'date'])
 
-
-def test_get_signal_handles_exceptions(mocker, default_conf):
-    exchange = get_patched_exchange(mocker, default_conf)
-    mocker.patch.object(
-        _STRATEGY, 'analyze_ticker',
-        side_effect=Exception('invalid ticker history ')
-    )
-    assert _STRATEGY.get_signal(exchange, 'ETH/BTC', '5m') == (False, False)
+    _STRATEGY.disable_dataframe_checks = True
+    caplog.clear()
+    _STRATEGY.assert_df(ohlcv_history, len(ohlcv_history),
+                        ohlcv_history.loc[1, 'close'], ohlcv_history.loc[0, 'date'])
+    assert log_has_re(r"Dataframe returned from strategy.*last date\.", caplog)
+    # reset to avoid problems in other tests due to test leakage
+    _STRATEGY.disable_dataframe_checks = False
 
 
 def test_ohlcvdata_to_dataframe(default_conf, testdatadir) -> None:
@@ -333,6 +329,7 @@ def test__analyze_ticker_internal_skip_analyze(ohlcv_history, mocker, caplog) ->
 
     )
     strategy = DefaultStrategy({})
+    strategy.dp = DataProvider({}, None, None)
     strategy.process_only_new_candles = True
 
     ret = strategy._analyze_ticker_internal(ohlcv_history, {'pair': 'ETH/BTC'})
@@ -389,3 +386,51 @@ def test_is_pair_locked(default_conf):
     pair = 'ETH/BTC'
     strategy.unlock_pair(pair)
     assert not strategy.is_pair_locked(pair)
+
+
+def test_is_informative_pairs_callback(default_conf):
+    default_conf.update({'strategy': 'TestStrategyLegacy'})
+    strategy = StrategyResolver.load_strategy(default_conf)
+    # Should return empty
+    # Uses fallback to base implementation
+    assert [] == strategy.informative_pairs()
+
+
+@pytest.mark.parametrize('error', [
+    ValueError, KeyError, Exception,
+])
+def test_strategy_safe_wrapper_error(caplog, error):
+    def failing_method():
+        raise error('This is an error.')
+
+    def working_method(argumentpassedin):
+        return argumentpassedin
+
+    with pytest.raises(StrategyError, match=r'This is an error.'):
+        strategy_safe_wrapper(failing_method, message='DeadBeef')()
+
+    assert log_has_re(r'DeadBeef.*', caplog)
+    ret = strategy_safe_wrapper(failing_method, message='DeadBeef', default_retval=True)()
+
+    assert isinstance(ret, bool)
+    assert ret
+
+    caplog.clear()
+    # Test supressing error
+    ret = strategy_safe_wrapper(failing_method, message='DeadBeef', supress_error=True)()
+    assert log_has_re(r'DeadBeef.*', caplog)
+
+
+@pytest.mark.parametrize('value', [
+    1, 22, 55, True, False, {'a': 1, 'b': '112'},
+    [1, 2, 3, 4], (4, 2, 3, 6)
+])
+def test_strategy_safe_wrapper(value):
+
+    def working_method(argumentpassedin):
+        return argumentpassedin
+
+    ret = strategy_safe_wrapper(working_method, message='DeadBeef')(value)
+
+    assert type(ret) == type(value)
+    assert ret == value
diff --git a/tests/strategy/test_strategy.py b/tests/strategy/test_strategy.py
index 13ca68bf0..240f3d8ec 100644
--- a/tests/strategy/test_strategy.py
+++ b/tests/strategy/test_strategy.py
@@ -105,8 +105,9 @@ def test_strategy(result, default_conf):
     assert strategy.stoploss == -0.10
     assert default_conf['stoploss'] == -0.10
 
+    assert strategy.timeframe == '5m'
     assert strategy.ticker_interval == '5m'
-    assert default_conf['ticker_interval'] == '5m'
+    assert default_conf['timeframe'] == '5m'
 
     df_indicators = strategy.advise_indicators(result, metadata=metadata)
     assert 'adx' in df_indicators
@@ -176,19 +177,19 @@ def test_strategy_override_trailing_stop_positive(caplog, default_conf):
                    caplog)
 
 
-def test_strategy_override_ticker_interval(caplog, default_conf):
+def test_strategy_override_timeframe(caplog, default_conf):
     caplog.set_level(logging.INFO)
 
     default_conf.update({
         'strategy': 'DefaultStrategy',
-        'ticker_interval': 60,
+        'timeframe': 60,
         'stake_currency': 'ETH'
     })
     strategy = StrategyResolver.load_strategy(default_conf)
 
-    assert strategy.ticker_interval == 60
+    assert strategy.timeframe == 60
     assert strategy.stake_currency == 'ETH'
-    assert log_has("Override strategy 'ticker_interval' with value in config file: 60.",
+    assert log_has("Override strategy 'timeframe' with value in config file: 60.",
                    caplog)
 
 
@@ -357,8 +358,9 @@ def test_deprecate_populate_indicators(result, default_conf):
 
 
 @pytest.mark.filterwarnings("ignore:deprecated")
-def test_call_deprecated_function(result, monkeypatch, default_conf):
+def test_call_deprecated_function(result, monkeypatch, default_conf, caplog):
     default_location = Path(__file__).parent / "strats"
+    del default_conf['timeframe']
     default_conf.update({'strategy': 'TestStrategyLegacy',
                          'strategy_path': default_location})
     strategy = StrategyResolver.load_strategy(default_conf)
@@ -369,6 +371,8 @@ def test_call_deprecated_function(result, monkeypatch, default_conf):
     assert strategy._buy_fun_len == 2
     assert strategy._sell_fun_len == 2
     assert strategy.INTERFACE_VERSION == 1
+    assert strategy.timeframe == '5m'
+    assert strategy.ticker_interval == '5m'
 
     indicator_df = strategy.advise_indicators(result, metadata=metadata)
     assert isinstance(indicator_df, DataFrame)
@@ -382,6 +386,9 @@ def test_call_deprecated_function(result, monkeypatch, default_conf):
     assert isinstance(selldf, DataFrame)
     assert 'sell' in selldf
 
+    assert log_has("DEPRECATED: Please migrate to using 'timeframe' instead of 'ticker_interval'.",
+                   caplog)
+
 
 def test_strategy_interface_versioning(result, monkeypatch, default_conf):
     default_conf.update({'strategy': 'DefaultStrategy'})
diff --git a/tests/test_arguments.py b/tests/test_arguments.py
index 0052a61d0..457683598 100644
--- a/tests/test_arguments.py
+++ b/tests/test_arguments.py
@@ -131,7 +131,7 @@ def test_parse_args_backtesting_custom() -> None:
     assert call_args["verbosity"] == 0
     assert call_args["command"] == 'backtesting'
     assert call_args["func"] is not None
-    assert call_args["ticker_interval"] == '1m'
+    assert call_args["timeframe"] == '1m'
     assert type(call_args["strategy_list"]) is list
     assert len(call_args["strategy_list"]) == 2
 
diff --git a/tests/test_configuration.py b/tests/test_configuration.py
index f29f2eaf2..ca5d6eadc 100644
--- a/tests/test_configuration.py
+++ b/tests/test_configuration.py
@@ -73,7 +73,7 @@ def test_load_config_file_error(default_conf, mocker, caplog) -> None:
     mocker.patch('freqtrade.configuration.load_config.open', mocker.mock_open(read_data=filedata))
     mocker.patch.object(Path, "read_text", MagicMock(return_value=filedata))
 
-    with pytest.raises(OperationalException, match=f".*Please verify the following segment.*"):
+    with pytest.raises(OperationalException, match=r".*Please verify the following segment.*"):
         load_config_file('somefile')
 
 
@@ -87,7 +87,7 @@ def test_load_config_file_error_range(default_conf, mocker, caplog) -> None:
     assert isinstance(x, str)
     assert (x == '{"max_open_trades": 1, "stake_currency": "BTC", '
             '"stake_amount": .001, "fiat_display_currency": "USD", '
-            '"ticker_interval": "5m", "dry_run": true, ')
+            '"timeframe": "5m", "dry_run": true, "cance')
 
 
 def test__args_to_config(caplog):
@@ -401,8 +401,8 @@ def test_setup_configuration_without_arguments(mocker, default_conf, caplog) ->
     assert 'datadir' in config
     assert 'user_data_dir' in config
     assert log_has('Using data directory: {} ...'.format(config['datadir']), caplog)
-    assert 'ticker_interval' in config
-    assert not log_has('Parameter -i/--ticker-interval detected ...', caplog)
+    assert 'timeframe' in config
+    assert not log_has('Parameter -i/--timeframe detected ...', caplog)
 
     assert 'position_stacking' not in config
     assert not log_has('Parameter --enable-position-stacking detected ...', caplog)
@@ -448,8 +448,8 @@ def test_setup_configuration_with_arguments(mocker, default_conf, caplog) -> Non
     assert log_has('Using user-data directory: {} ...'.format(Path("/tmp/freqtrade")), caplog)
     assert 'user_data_dir' in config
 
-    assert 'ticker_interval' in config
-    assert log_has('Parameter -i/--ticker-interval detected ... Using ticker_interval: 1m ...',
+    assert 'timeframe' in config
+    assert log_has('Parameter -i/--timeframe detected ... Using timeframe: 1m ...',
                    caplog)
 
     assert 'position_stacking' in config
@@ -494,8 +494,8 @@ def test_setup_configuration_with_stratlist(mocker, default_conf, caplog) -> Non
     assert 'pair_whitelist' in config['exchange']
     assert 'datadir' in config
     assert log_has('Using data directory: {} ...'.format(config['datadir']), caplog)
-    assert 'ticker_interval' in config
-    assert log_has('Parameter -i/--ticker-interval detected ... Using ticker_interval: 1m ...',
+    assert 'timeframe' in config
+    assert log_has('Parameter -i/--timeframe detected ... Using timeframe: 1m ...',
                    caplog)
 
     assert 'strategy_list' in config
@@ -654,12 +654,14 @@ def test_set_loggers() -> None:
     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)
+    _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")
@@ -869,6 +871,14 @@ def test_load_config_default_exchange_name(all_conf) -> None:
         validate_config_schema(all_conf)
 
 
+def test_load_config_stoploss_exchange_limit_ratio(all_conf) -> None:
+    all_conf['order_types']['stoploss_on_exchange_limit_ratio'] = 1.15
+
+    with pytest.raises(ValidationError,
+                       match=r"1.15 is greater than the maximum"):
+        validate_config_schema(all_conf)
+
+
 @pytest.mark.parametrize("keys", [("exchange", "sandbox", False),
                                   ("exchange", "key", ""),
                                   ("exchange", "secret", ""),
@@ -1041,18 +1051,6 @@ def test_process_temporary_deprecated_settings(mocker, default_conf, setting, ca
     assert default_conf[setting[0]][setting[1]] == setting[5]
 
 
-def test_process_deprecated_setting_pairlists(mocker, default_conf, caplog):
-    patched_configuration_load_config_file(mocker, default_conf)
-    default_conf.update({'pairlist': {
-        'method': 'VolumePairList',
-        'config': {'precision_filter': True}
-    }})
-
-    process_temporary_deprecated_settings(default_conf)
-    assert log_has_re(r'DEPRECATED.*precision_filter.*', caplog)
-    assert log_has_re(r'DEPRECATED.*in pairlist is deprecated and must be moved*', caplog)
-
-
 def test_process_deprecated_setting_edge(mocker, edge_conf, caplog):
     patched_configuration_load_config_file(mocker, edge_conf)
     edge_conf.update({'edge': {
@@ -1060,8 +1058,9 @@ def test_process_deprecated_setting_edge(mocker, edge_conf, caplog):
         'capital_available_percentage': 0.5,
     }})
 
-    process_temporary_deprecated_settings(edge_conf)
-    assert log_has_re(r"DEPRECATED.*Using 'edge.capital_available_percentage'*", caplog)
+    with pytest.raises(OperationalException,
+                       match=r"DEPRECATED.*Using 'edge.capital_available_percentage'*"):
+        process_temporary_deprecated_settings(edge_conf)
 
 
 def test_check_conflicting_settings(mocker, default_conf, caplog):
@@ -1149,3 +1148,25 @@ def test_process_deprecated_setting(mocker, default_conf, caplog):
                                'sectionB', 'deprecated_setting')
     assert not log_has_re('DEPRECATED', caplog)
     assert default_conf['sectionA']['new_setting'] == 'valA'
+
+
+def test_process_deprecated_ticker_interval(mocker, default_conf, caplog):
+    message = "DEPRECATED: Please use 'timeframe' instead of 'ticker_interval."
+    config = deepcopy(default_conf)
+    process_temporary_deprecated_settings(config)
+    assert not log_has(message, caplog)
+
+    del config['timeframe']
+    config['ticker_interval'] = '15m'
+    process_temporary_deprecated_settings(config)
+    assert log_has(message, caplog)
+    assert config['ticker_interval'] == '15m'
+
+    config = deepcopy(default_conf)
+    # Have both timeframe and ticker interval in config
+    # Can also happen when using ticker_interval in configuration, and --timeframe as cli argument
+    config['timeframe'] = '5m'
+    config['ticker_interval'] = '4h'
+    with pytest.raises(OperationalException,
+                       match=r"Both 'timeframe' and 'ticker_interval' detected."):
+        process_temporary_deprecated_settings(config)
diff --git a/tests/test_docs.sh b/tests/test_docs.sh
index 09e142b99..8a354daad 100755
--- a/tests/test_docs.sh
+++ b/tests/test_docs.sh
@@ -2,7 +2,8 @@
 # Test Documentation boxes -
 # !!! : is not allowed!
 # !!!  "title" - Title needs to be quoted!
-grep -Er '^!{3}\s\S+:|^!{3}\s\S+\s[^"]' docs/*
+# !!!  Spaces at the beginning are not allowed
+grep -Er '^!{3}\s\S+:|^!{3}\s\S+\s[^"]|^\s+!{3}\s\S+' docs/*
 
 if  [ $? -ne 0 ]; then
     echo "Docs test success."
diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py
index c3e78c9be..b8b4a0f7a 100644
--- a/tests/test_freqtradebot.py
+++ b/tests/test_freqtradebot.py
@@ -9,20 +9,22 @@ from unittest.mock import ANY, MagicMock, PropertyMock
 
 import arrow
 import pytest
-import requests
 
-from freqtrade.constants import MATH_CLOSE_PREC, UNLIMITED_STAKE_AMOUNT
-from freqtrade.exceptions import (DependencyException, InvalidOrderException,
-                                  OperationalException, TemporaryError)
+from freqtrade.constants import (CANCEL_REASON, MATH_CLOSE_PREC,
+                                 UNLIMITED_STAKE_AMOUNT)
+from freqtrade.exceptions import (DependencyException, ExchangeError,
+                                  InvalidOrderException, OperationalException,
+                                  PricingError, TemporaryError)
 from freqtrade.freqtradebot import FreqtradeBot
 from freqtrade.persistence import Trade
 from freqtrade.rpc import RPCMessageType
 from freqtrade.state import RunMode, State
 from freqtrade.strategy.interface import SellCheckTuple, SellType
 from freqtrade.worker import Worker
-from tests.conftest import (get_patched_freqtradebot, get_patched_worker,
-                            log_has, log_has_re, patch_edge, patch_exchange,
-                            patch_get_signal, patch_wallet, patch_whitelist)
+from tests.conftest import (create_mock_trades, get_patched_freqtradebot,
+                            get_patched_worker, log_has, log_has_re,
+                            patch_edge, patch_exchange, patch_get_signal,
+                            patch_wallet, patch_whitelist)
 
 
 def patch_RPCManager(mocker) -> MagicMock:
@@ -48,13 +50,31 @@ def test_freqtradebot_state(mocker, default_conf, markets) -> None:
     assert freqtrade.state is State.STOPPED
 
 
-def test_cleanup(mocker, default_conf, caplog) -> None:
-    mock_cleanup = MagicMock()
-    mocker.patch('freqtrade.persistence.cleanup', mock_cleanup)
+def test_process_stopped(mocker, default_conf) -> None:
+
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
+    coo_mock = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.cancel_all_open_orders')
+    freqtrade.process_stopped()
+    assert coo_mock.call_count == 0
+
+    default_conf['cancel_open_orders_on_exit'] = True
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
+    freqtrade.process_stopped()
+    assert coo_mock.call_count == 1
+
+
+def test_bot_cleanup(mocker, default_conf, caplog) -> None:
+    mock_cleanup = mocker.patch('freqtrade.persistence.cleanup')
+    coo_mock = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.cancel_all_open_orders')
     freqtrade = get_patched_freqtradebot(mocker, default_conf)
     freqtrade.cleanup()
     assert log_has('Cleaning up modules ...', caplog)
     assert mock_cleanup.call_count == 1
+    assert coo_mock.call_count == 0
+
+    freqtrade.config['cancel_open_orders_on_exit'] = True
+    freqtrade.cleanup()
+    assert coo_mock.call_count == 1
 
 
 def test_order_dict_dry_run(default_conf, mocker, caplog) -> None:
@@ -575,7 +595,7 @@ def test_create_trade_minimal_amount(default_conf, ticker, limit_buy_order,
 
     freqtrade.create_trade('ETH/BTC')
     rate, amount = buy_mock.call_args[1]['rate'], buy_mock.call_args[1]['amount']
-    assert rate * amount >= default_conf['stake_amount']
+    assert rate * amount <= default_conf['stake_amount']
 
 
 def test_create_trade_too_small_stake_amount(default_conf, ticker, limit_buy_order,
@@ -742,7 +762,7 @@ def test_process_trade_creation(default_conf, ticker, limit_buy_order,
         'freqtrade.exchange.Exchange',
         fetch_ticker=ticker,
         buy=MagicMock(return_value={'id': limit_buy_order['id']}),
-        get_order=MagicMock(return_value=limit_buy_order),
+        fetch_order=MagicMock(return_value=limit_buy_order),
         get_fee=fee,
     )
     freqtrade = FreqtradeBot(default_conf)
@@ -762,7 +782,7 @@ def test_process_trade_creation(default_conf, ticker, limit_buy_order,
     assert trade.open_date is not None
     assert trade.exchange == 'bittrex'
     assert trade.open_rate == 0.00001098
-    assert trade.amount == 91.07468123861567
+    assert trade.amount == 91.07468123
 
     assert log_has(
         'Buy signal found: about create a new trade with stake_amount: 0.001 ...', caplog
@@ -811,7 +831,7 @@ def test_process_trade_handling(default_conf, ticker, limit_buy_order, fee, mock
         'freqtrade.exchange.Exchange',
         fetch_ticker=ticker,
         buy=MagicMock(return_value={'id': limit_buy_order['id']}),
-        get_order=MagicMock(return_value=limit_buy_order),
+        fetch_order=MagicMock(return_value=limit_buy_order),
         get_fee=fee,
     )
     freqtrade = FreqtradeBot(default_conf)
@@ -838,7 +858,7 @@ def test_process_trade_no_whitelist_pair(default_conf, ticker, limit_buy_order,
         'freqtrade.exchange.Exchange',
         fetch_ticker=ticker,
         buy=MagicMock(return_value={'id': limit_buy_order['id']}),
-        get_order=MagicMock(return_value=limit_buy_order),
+        fetch_order=MagicMock(return_value=limit_buy_order),
         get_fee=fee,
     )
     freqtrade = FreqtradeBot(default_conf)
@@ -891,6 +911,7 @@ def test_process_informative_pairs_added(default_conf, ticker, mocker) -> None:
         refresh_latest_ohlcv=refresh_mock,
     )
     inf_pairs = MagicMock(return_value=[("BTC/ETH", '1m'), ("ETH/USDT", "1h")])
+    mocker.patch('freqtrade.strategy.interface.IStrategy.get_signal', return_value=(False, False))
     mocker.patch('time.sleep', return_value=None)
 
     freqtrade = FreqtradeBot(default_conf)
@@ -903,7 +924,7 @@ def test_process_informative_pairs_added(default_conf, ticker, mocker) -> None:
     assert refresh_mock.call_count == 1
     assert ("BTC/ETH", "1m") in refresh_mock.call_args[0][0]
     assert ("ETH/USDT", "1h") in refresh_mock.call_args[0][0]
-    assert ("ETH/BTC", default_conf["ticker_interval"]) in refresh_mock.call_args[0][0]
+    assert ("ETH/BTC", default_conf["timeframe"]) in refresh_mock.call_args[0][0]
 
 
 @pytest.mark.parametrize("side,ask,bid,last,last_ab,expected", [
@@ -953,6 +974,7 @@ def test_execute_buy(mocker, default_conf, fee, limit_buy_order) -> None:
     patch_RPCManager(mocker)
     patch_exchange(mocker)
     freqtrade = FreqtradeBot(default_conf)
+    freqtrade.strategy.confirm_trade_entry = MagicMock(return_value=False)
     stake_amount = 2
     bid = 0.11
     buy_rate_mock = MagicMock(return_value=bid)
@@ -974,13 +996,21 @@ def test_execute_buy(mocker, default_conf, fee, limit_buy_order) -> None:
     )
     pair = 'ETH/BTC'
 
+    assert not freqtrade.execute_buy(pair, stake_amount)
+    assert buy_rate_mock.call_count == 1
+    assert buy_mm.call_count == 0
+    assert freqtrade.strategy.confirm_trade_entry.call_count == 1
+    buy_rate_mock.reset_mock()
+
+    freqtrade.strategy.confirm_trade_entry = MagicMock(return_value=True)
     assert freqtrade.execute_buy(pair, stake_amount)
     assert buy_rate_mock.call_count == 1
     assert buy_mm.call_count == 1
     call_args = buy_mm.call_args_list[0][1]
     assert call_args['pair'] == pair
     assert call_args['rate'] == bid
-    assert call_args['amount'] == stake_amount / bid
+    assert call_args['amount'] == round(stake_amount / bid, 8)
+    buy_rate_mock.reset_mock()
 
     # Should create an open trade with an open order id
     # As the order is not fulfilled yet
@@ -993,13 +1023,13 @@ def test_execute_buy(mocker, default_conf, fee, limit_buy_order) -> None:
     fix_price = 0.06
     assert freqtrade.execute_buy(pair, stake_amount, fix_price)
     # Make sure get_buy_rate wasn't called again
-    assert buy_rate_mock.call_count == 1
+    assert buy_rate_mock.call_count == 0
 
     assert buy_mm.call_count == 2
     call_args = buy_mm.call_args_list[1][1]
     assert call_args['pair'] == pair
     assert call_args['rate'] == fix_price
-    assert call_args['amount'] == stake_amount / fix_price
+    assert call_args['amount'] == round(stake_amount / fix_price, 8)
 
     # In case of closed order
     limit_buy_order['status'] = 'closed'
@@ -1039,11 +1069,44 @@ def test_execute_buy(mocker, default_conf, fee, limit_buy_order) -> None:
     assert not freqtrade.execute_buy(pair, stake_amount)
 
 
+def test_execute_buy_confirm_error(mocker, default_conf, fee, limit_buy_order) -> None:
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
+    mocker.patch.multiple(
+        'freqtrade.freqtradebot.FreqtradeBot',
+        get_buy_rate=MagicMock(return_value=0.11),
+        _get_min_pair_stake_amount=MagicMock(return_value=1)
+    )
+    mocker.patch.multiple(
+        'freqtrade.exchange.Exchange',
+        fetch_ticker=MagicMock(return_value={
+            'bid': 0.00001172,
+            'ask': 0.00001173,
+            'last': 0.00001172
+        }),
+        buy=MagicMock(return_value=limit_buy_order),
+        get_fee=fee,
+    )
+    stake_amount = 2
+    pair = 'ETH/BTC'
+
+    freqtrade.strategy.confirm_trade_entry = MagicMock(side_effect=ValueError)
+    assert freqtrade.execute_buy(pair, stake_amount)
+
+    freqtrade.strategy.confirm_trade_entry = MagicMock(side_effect=Exception)
+    assert freqtrade.execute_buy(pair, stake_amount)
+
+    freqtrade.strategy.confirm_trade_entry = MagicMock(return_value=True)
+    assert freqtrade.execute_buy(pair, stake_amount)
+
+    freqtrade.strategy.confirm_trade_entry = MagicMock(return_value=False)
+    assert not freqtrade.execute_buy(pair, stake_amount)
+
+
 def test_add_stoploss_on_exchange(mocker, default_conf, limit_buy_order) -> None:
     patch_RPCManager(mocker)
     patch_exchange(mocker)
     mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_trade', MagicMock(return_value=True))
-    mocker.patch('freqtrade.exchange.Exchange.get_order', return_value=limit_buy_order)
+    mocker.patch('freqtrade.exchange.Exchange.fetch_order', return_value=limit_buy_order)
     mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=[])
     mocker.patch('freqtrade.freqtradebot.FreqtradeBot.get_real_amount',
                  return_value=limit_buy_order['amount'])
@@ -1105,7 +1168,7 @@ def test_handle_stoploss_on_exchange(mocker, default_conf, fee, caplog,
     trade.stoploss_order_id = 100
 
     hanging_stoploss_order = MagicMock(return_value={'status': 'open'})
-    mocker.patch('freqtrade.exchange.Exchange.get_order', hanging_stoploss_order)
+    mocker.patch('freqtrade.exchange.Exchange.fetch_stoploss_order', hanging_stoploss_order)
 
     assert freqtrade.handle_stoploss_on_exchange(trade) is False
     assert trade.stoploss_order_id == 100
@@ -1118,7 +1181,7 @@ def test_handle_stoploss_on_exchange(mocker, default_conf, fee, caplog,
     trade.stoploss_order_id = 100
 
     canceled_stoploss_order = MagicMock(return_value={'status': 'canceled'})
-    mocker.patch('freqtrade.exchange.Exchange.get_order', canceled_stoploss_order)
+    mocker.patch('freqtrade.exchange.Exchange.fetch_stoploss_order', canceled_stoploss_order)
     stoploss.reset_mock()
 
     assert freqtrade.handle_stoploss_on_exchange(trade) is False
@@ -1140,9 +1203,10 @@ def test_handle_stoploss_on_exchange(mocker, default_conf, fee, caplog,
         'status': 'closed',
         'type': 'stop_loss_limit',
         'price': 3,
-        'average': 2
+        'average': 2,
+        'amount': limit_buy_order['amount'],
     })
-    mocker.patch('freqtrade.exchange.Exchange.get_order', stoploss_order_hit)
+    mocker.patch('freqtrade.exchange.Exchange.fetch_stoploss_order', stoploss_order_hit)
     assert freqtrade.handle_stoploss_on_exchange(trade) is True
     assert log_has('STOP_LOSS_LIMIT is hit for {}.'.format(trade), caplog)
     assert trade.stoploss_order_id is None
@@ -1150,18 +1214,19 @@ def test_handle_stoploss_on_exchange(mocker, default_conf, fee, caplog,
 
     mocker.patch(
         'freqtrade.exchange.Exchange.stoploss',
-        side_effect=DependencyException()
+        side_effect=ExchangeError()
     )
     trade.is_open = True
     freqtrade.handle_stoploss_on_exchange(trade)
     assert log_has('Unable to place a stoploss order on exchange.', caplog)
     assert trade.stoploss_order_id is None
 
-    # Fifth case: get_order returns InvalidOrder
+    # Fifth case: fetch_order returns InvalidOrder
     # It should try to add stoploss order
     trade.stoploss_order_id = 100
     stoploss.reset_mock()
-    mocker.patch('freqtrade.exchange.Exchange.get_order', side_effect=InvalidOrderException())
+    mocker.patch('freqtrade.exchange.Exchange.fetch_stoploss_order',
+                 side_effect=InvalidOrderException())
     mocker.patch('freqtrade.exchange.Exchange.stoploss', stoploss)
     freqtrade.handle_stoploss_on_exchange(trade)
     assert stoploss.call_count == 1
@@ -1171,7 +1236,7 @@ def test_handle_stoploss_on_exchange(mocker, default_conf, fee, caplog,
     trade.stoploss_order_id = None
     trade.is_open = False
     stoploss.reset_mock()
-    mocker.patch('freqtrade.exchange.Exchange.get_order')
+    mocker.patch('freqtrade.exchange.Exchange.fetch_order')
     mocker.patch('freqtrade.exchange.Exchange.stoploss', stoploss)
     assert freqtrade.handle_stoploss_on_exchange(trade) is False
     assert stoploss.call_count == 0
@@ -1192,8 +1257,8 @@ def test_handle_sle_cancel_cant_recreate(mocker, default_conf, fee, caplog,
         buy=MagicMock(return_value={'id': limit_buy_order['id']}),
         sell=MagicMock(return_value={'id': limit_sell_order['id']}),
         get_fee=fee,
-        get_order=MagicMock(return_value={'status': 'canceled'}),
-        stoploss=MagicMock(side_effect=DependencyException()),
+        fetch_stoploss_order=MagicMock(return_value={'status': 'canceled'}),
+        stoploss=MagicMock(side_effect=ExchangeError()),
     )
     freqtrade = FreqtradeBot(default_conf)
     patch_get_signal(freqtrade)
@@ -1226,7 +1291,7 @@ def test_create_stoploss_order_invalid_order(mocker, default_conf, caplog, fee,
         buy=MagicMock(return_value={'id': limit_buy_order['id']}),
         sell=sell_mock,
         get_fee=fee,
-        get_order=MagicMock(return_value={'status': 'canceled'}),
+        fetch_order=MagicMock(return_value={'status': 'canceled'}),
         stoploss=MagicMock(side_effect=InvalidOrderException()),
     )
     freqtrade = FreqtradeBot(default_conf)
@@ -1309,7 +1374,7 @@ def test_handle_stoploss_on_exchange_trailing(mocker, default_conf, fee, caplog,
         }
     })
 
-    mocker.patch('freqtrade.exchange.Exchange.get_order', stoploss_order_hanging)
+    mocker.patch('freqtrade.exchange.Exchange.fetch_stoploss_order', stoploss_order_hanging)
 
     # stoploss initially at 5%
     assert freqtrade.handle_trade(trade) is False
@@ -1324,7 +1389,7 @@ def test_handle_stoploss_on_exchange_trailing(mocker, default_conf, fee, caplog,
 
     cancel_order_mock = MagicMock()
     stoploss_order_mock = MagicMock()
-    mocker.patch('freqtrade.exchange.Exchange.cancel_order', cancel_order_mock)
+    mocker.patch('freqtrade.exchange.Exchange.cancel_stoploss_order', cancel_order_mock)
     mocker.patch('freqtrade.exchange.Exchange.stoploss', stoploss_order_mock)
 
     # stoploss should not be updated as the interval is 60 seconds
@@ -1342,7 +1407,7 @@ def test_handle_stoploss_on_exchange_trailing(mocker, default_conf, fee, caplog,
     assert freqtrade.handle_stoploss_on_exchange(trade) is False
 
     cancel_order_mock.assert_called_once_with(100, 'ETH/BTC')
-    stoploss_order_mock.assert_called_once_with(amount=85.32423208191126,
+    stoploss_order_mock.assert_called_once_with(amount=85.32423208,
                                                 pair='ETH/BTC',
                                                 order_types=freqtrade.strategy.order_types,
                                                 stop_price=0.00002346 * 0.95)
@@ -1407,8 +1472,9 @@ def test_handle_stoploss_on_exchange_trailing_error(mocker, default_conf, fee, c
             'stopPrice': '0.1'
         }
     }
-    mocker.patch('freqtrade.exchange.Exchange.cancel_order', side_effect=InvalidOrderException())
-    mocker.patch('freqtrade.exchange.Exchange.get_order', stoploss_order_hanging)
+    mocker.patch('freqtrade.exchange.Exchange.cancel_stoploss_order',
+                 side_effect=InvalidOrderException())
+    mocker.patch('freqtrade.exchange.Exchange.fetch_stoploss_order', stoploss_order_hanging)
     freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging)
     assert log_has_re(r"Could not cancel stoploss order abcd for pair ETH/BTC.*", caplog)
 
@@ -1417,8 +1483,8 @@ def test_handle_stoploss_on_exchange_trailing_error(mocker, default_conf, fee, c
 
     # Fail creating stoploss order
     caplog.clear()
-    cancel_mock = mocker.patch("freqtrade.exchange.Exchange.cancel_order", MagicMock())
-    mocker.patch("freqtrade.exchange.Exchange.stoploss", side_effect=DependencyException())
+    cancel_mock = mocker.patch("freqtrade.exchange.Exchange.cancel_stoploss_order", MagicMock())
+    mocker.patch("freqtrade.exchange.Exchange.stoploss", side_effect=ExchangeError())
     freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging)
     assert cancel_mock.call_count == 1
     assert log_has_re(r"Could not create trailing stoploss order for pair ETH/BTC\..*", caplog)
@@ -1488,7 +1554,7 @@ def test_tsl_on_exchange_compatible_with_edge(mocker, edge_conf, fee, caplog,
         }
     })
 
-    mocker.patch('freqtrade.exchange.Exchange.get_order', stoploss_order_hanging)
+    mocker.patch('freqtrade.exchange.Exchange.fetch_stoploss_order', stoploss_order_hanging)
 
     # stoploss initially at 20% as edge dictated it.
     assert freqtrade.handle_trade(trade) is False
@@ -1497,7 +1563,7 @@ def test_tsl_on_exchange_compatible_with_edge(mocker, edge_conf, fee, caplog,
 
     cancel_order_mock = MagicMock()
     stoploss_order_mock = MagicMock()
-    mocker.patch('freqtrade.exchange.Exchange.cancel_order', cancel_order_mock)
+    mocker.patch('freqtrade.exchange.Exchange.cancel_stoploss_order', cancel_order_mock)
     mocker.patch('freqtrade.exchange.Binance.stoploss', stoploss_order_mock)
 
     # price goes down 5%
@@ -1529,7 +1595,7 @@ def test_tsl_on_exchange_compatible_with_edge(mocker, edge_conf, fee, caplog,
     # stoploss should be set to 1% as trailing is on
     assert trade.stop_loss == 0.00002346 * 0.99
     cancel_order_mock.assert_called_once_with(100, 'NEO/BTC')
-    stoploss_order_mock.assert_called_once_with(amount=2132892.491467577,
+    stoploss_order_mock.assert_called_once_with(amount=2132892.49146757,
                                                 pair='NEO/BTC',
                                                 order_types=freqtrade.strategy.order_types,
                                                 stop_price=0.00002346 * 0.99)
@@ -1565,7 +1631,7 @@ def test_exit_positions(mocker, default_conf, limit_buy_order, caplog) -> None:
     freqtrade = get_patched_freqtradebot(mocker, default_conf)
 
     mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_trade', MagicMock(return_value=True))
-    mocker.patch('freqtrade.exchange.Exchange.get_order', return_value=limit_buy_order)
+    mocker.patch('freqtrade.exchange.Exchange.fetch_order', return_value=limit_buy_order)
     mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=[])
     mocker.patch('freqtrade.freqtradebot.FreqtradeBot.get_real_amount',
                  return_value=limit_buy_order['amount'])
@@ -1589,11 +1655,12 @@ def test_exit_positions(mocker, default_conf, limit_buy_order, caplog) -> None:
 
 def test_exit_positions_exception(mocker, default_conf, limit_buy_order, caplog) -> None:
     freqtrade = get_patched_freqtradebot(mocker, default_conf)
-    mocker.patch('freqtrade.exchange.Exchange.get_order', return_value=limit_buy_order)
+    mocker.patch('freqtrade.exchange.Exchange.fetch_order', return_value=limit_buy_order)
 
     trade = MagicMock()
     trade.open_order_id = None
     trade.open_fee = 0.001
+    trade.pair = 'ETH/BTC'
     trades = [trade]
 
     # Test raise of DependencyException exception
@@ -1603,14 +1670,14 @@ def test_exit_positions_exception(mocker, default_conf, limit_buy_order, caplog)
     )
     n = freqtrade.exit_positions(trades)
     assert n == 0
-    assert log_has('Unable to sell trade: ', caplog)
+    assert log_has('Unable to sell trade ETH/BTC: ', caplog)
 
 
 def test_update_trade_state(mocker, default_conf, limit_buy_order, caplog) -> None:
     freqtrade = get_patched_freqtradebot(mocker, default_conf)
 
     mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_trade', MagicMock(return_value=True))
-    mocker.patch('freqtrade.exchange.Exchange.get_order', return_value=limit_buy_order)
+    mocker.patch('freqtrade.exchange.Exchange.fetch_order', return_value=limit_buy_order)
     mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=[])
     mocker.patch('freqtrade.freqtradebot.FreqtradeBot.get_real_amount',
                  return_value=limit_buy_order['amount'])
@@ -1649,8 +1716,8 @@ def test_update_trade_state(mocker, default_conf, limit_buy_order, caplog) -> No
 def test_update_trade_state_withorderdict(default_conf, trades_for_order, limit_buy_order, fee,
                                           mocker):
     mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=trades_for_order)
-    # get_order should not be called!!
-    mocker.patch('freqtrade.exchange.Exchange.get_order', MagicMock(side_effect=ValueError))
+    # fetch_order should not be called!!
+    mocker.patch('freqtrade.exchange.Exchange.fetch_order', MagicMock(side_effect=ValueError))
     patch_exchange(mocker)
     Trade.session = MagicMock()
     amount = sum(x['amount'] for x in trades_for_order)
@@ -1660,6 +1727,7 @@ def test_update_trade_state_withorderdict(default_conf, trades_for_order, limit_
         amount=amount,
         exchange='binance',
         open_rate=0.245441,
+        open_date=arrow.utcnow().datetime,
         fee_open=fee.return_value,
         fee_close=fee.return_value,
         open_order_id="123456",
@@ -1674,8 +1742,8 @@ def test_update_trade_state_withorderdict_rounding_fee(default_conf, trades_for_
                                                        limit_buy_order, mocker, caplog):
     trades_for_order[0]['amount'] = limit_buy_order['amount'] + 1e-14
     mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=trades_for_order)
-    # get_order should not be called!!
-    mocker.patch('freqtrade.exchange.Exchange.get_order', MagicMock(side_effect=ValueError))
+    # fetch_order should not be called!!
+    mocker.patch('freqtrade.exchange.Exchange.fetch_order', MagicMock(side_effect=ValueError))
     patch_exchange(mocker)
     Trade.session = MagicMock()
     amount = sum(x['amount'] for x in trades_for_order)
@@ -1700,7 +1768,7 @@ def test_update_trade_state_withorderdict_rounding_fee(default_conf, trades_for_
 def test_update_trade_state_exception(mocker, default_conf,
                                       limit_buy_order, caplog) -> None:
     freqtrade = get_patched_freqtradebot(mocker, default_conf)
-    mocker.patch('freqtrade.exchange.Exchange.get_order', return_value=limit_buy_order)
+    mocker.patch('freqtrade.exchange.Exchange.fetch_order', return_value=limit_buy_order)
 
     trade = MagicMock()
     trade.open_order_id = '123'
@@ -1717,7 +1785,7 @@ def test_update_trade_state_exception(mocker, default_conf,
 
 def test_update_trade_state_orderexception(mocker, default_conf, caplog) -> None:
     freqtrade = get_patched_freqtradebot(mocker, default_conf)
-    mocker.patch('freqtrade.exchange.Exchange.get_order',
+    mocker.patch('freqtrade.exchange.Exchange.fetch_order',
                  MagicMock(side_effect=InvalidOrderException))
 
     trade = MagicMock()
@@ -1733,8 +1801,8 @@ def test_update_trade_state_orderexception(mocker, default_conf, caplog) -> None
 
 def test_update_trade_state_sell(default_conf, trades_for_order, limit_sell_order, mocker):
     mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=trades_for_order)
-    # get_order should not be called!!
-    mocker.patch('freqtrade.exchange.Exchange.get_order', MagicMock(side_effect=ValueError))
+    # fetch_order should not be called!!
+    mocker.patch('freqtrade.exchange.Exchange.fetch_order', MagicMock(side_effect=ValueError))
     wallet_mock = MagicMock()
     mocker.patch('freqtrade.wallets.Wallets.update', wallet_mock)
 
@@ -1750,6 +1818,7 @@ def test_update_trade_state_sell(default_conf, trades_for_order, limit_sell_orde
         open_rate=0.245441,
         fee_open=0.0025,
         fee_close=0.0025,
+        open_date=arrow.utcnow().datetime,
         open_order_id="123456",
         is_open=True,
     )
@@ -1939,15 +2008,85 @@ def test_close_trade(default_conf, ticker, limit_buy_order, limit_sell_order,
         freqtrade.handle_trade(trade)
 
 
-def test_check_handle_timedout_buy(default_conf, ticker, limit_buy_order_old, open_trade,
-                                   fee, mocker) -> None:
+def test_bot_loop_start_called_once(mocker, default_conf, caplog):
+    ftbot = get_patched_freqtradebot(mocker, default_conf)
+    patch_get_signal(ftbot)
+    ftbot.strategy.bot_loop_start = MagicMock(side_effect=ValueError)
+    ftbot.strategy.analyze = MagicMock()
+
+    ftbot.process()
+    assert log_has_re(r'Strategy caused the following exception.*', caplog)
+    assert ftbot.strategy.bot_loop_start.call_count == 1
+    assert ftbot.strategy.analyze.call_count == 1
+
+
+def test_check_handle_timedout_buy_usercustom(default_conf, ticker, limit_buy_order_old, open_trade,
+                                              fee, mocker) -> None:
+    default_conf["unfilledtimeout"] = {"buy": 1400, "sell": 30}
+
     rpc_mock = patch_RPCManager(mocker)
     cancel_order_mock = MagicMock(return_value=limit_buy_order_old)
+    cancel_buy_order = deepcopy(limit_buy_order_old)
+    cancel_buy_order['status'] = 'canceled'
+    cancel_order_wr_mock = MagicMock(return_value=cancel_buy_order)
+
     patch_exchange(mocker)
     mocker.patch.multiple(
         'freqtrade.exchange.Exchange',
         fetch_ticker=ticker,
-        get_order=MagicMock(return_value=limit_buy_order_old),
+        fetch_order=MagicMock(return_value=limit_buy_order_old),
+        cancel_order_with_result=cancel_order_wr_mock,
+        cancel_order=cancel_order_mock,
+        get_fee=fee
+    )
+    freqtrade = FreqtradeBot(default_conf)
+
+    Trade.session.add(open_trade)
+
+    # Ensure default is to return empty (so not mocked yet)
+    freqtrade.check_handle_timedout()
+    assert cancel_order_mock.call_count == 0
+
+    # Return false - trade remains open
+    freqtrade.strategy.check_buy_timeout = MagicMock(return_value=False)
+    freqtrade.check_handle_timedout()
+    assert cancel_order_mock.call_count == 0
+    trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all()
+    nb_trades = len(trades)
+    assert nb_trades == 1
+    assert freqtrade.strategy.check_buy_timeout.call_count == 1
+
+    # Raise Keyerror ... (no impact on trade)
+    freqtrade.strategy.check_buy_timeout = MagicMock(side_effect=KeyError)
+    freqtrade.check_handle_timedout()
+    assert cancel_order_mock.call_count == 0
+    trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all()
+    nb_trades = len(trades)
+    assert nb_trades == 1
+    assert freqtrade.strategy.check_buy_timeout.call_count == 1
+
+    freqtrade.strategy.check_buy_timeout = MagicMock(return_value=True)
+    # Trade should be closed since the function returns true
+    freqtrade.check_handle_timedout()
+    assert cancel_order_wr_mock.call_count == 1
+    assert rpc_mock.call_count == 1
+    trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all()
+    nb_trades = len(trades)
+    assert nb_trades == 0
+    assert freqtrade.strategy.check_buy_timeout.call_count == 1
+
+
+def test_check_handle_timedout_buy(default_conf, ticker, limit_buy_order_old, open_trade,
+                                   fee, mocker) -> None:
+    rpc_mock = patch_RPCManager(mocker)
+    limit_buy_cancel = deepcopy(limit_buy_order_old)
+    limit_buy_cancel['status'] = 'canceled'
+    cancel_order_mock = MagicMock(return_value=limit_buy_cancel)
+    patch_exchange(mocker)
+    mocker.patch.multiple(
+        'freqtrade.exchange.Exchange',
+        fetch_ticker=ticker,
+        fetch_order=MagicMock(return_value=limit_buy_order_old),
         cancel_order_with_result=cancel_order_mock,
         get_fee=fee
     )
@@ -1955,6 +2094,7 @@ def test_check_handle_timedout_buy(default_conf, ticker, limit_buy_order_old, op
 
     Trade.session.add(open_trade)
 
+    freqtrade.strategy.check_buy_timeout = MagicMock(return_value=False)
     # check it does cancel buy orders over the time limit
     freqtrade.check_handle_timedout()
     assert cancel_order_mock.call_count == 1
@@ -1962,6 +2102,8 @@ def test_check_handle_timedout_buy(default_conf, ticker, limit_buy_order_old, op
     trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all()
     nb_trades = len(trades)
     assert nb_trades == 0
+    # Custom user buy-timeout is never called
+    assert freqtrade.strategy.check_buy_timeout.call_count == 0
 
 
 def test_check_handle_cancelled_buy(default_conf, ticker, limit_buy_order_old, open_trade,
@@ -1974,7 +2116,7 @@ def test_check_handle_cancelled_buy(default_conf, ticker, limit_buy_order_old, o
     mocker.patch.multiple(
         'freqtrade.exchange.Exchange',
         fetch_ticker=ticker,
-        get_order=MagicMock(return_value=limit_buy_order_old),
+        fetch_order=MagicMock(return_value=limit_buy_order_old),
         cancel_order=cancel_order_mock,
         get_fee=fee
     )
@@ -2001,7 +2143,7 @@ def test_check_handle_timedout_buy_exception(default_conf, ticker, limit_buy_ord
         'freqtrade.exchange.Exchange',
         validate_pairs=MagicMock(),
         fetch_ticker=ticker,
-        get_order=MagicMock(side_effect=DependencyException),
+        fetch_order=MagicMock(side_effect=ExchangeError),
         cancel_order=cancel_order_mock,
         get_fee=fee
     )
@@ -2018,6 +2160,54 @@ def test_check_handle_timedout_buy_exception(default_conf, ticker, limit_buy_ord
     assert nb_trades == 1
 
 
+def test_check_handle_timedout_sell_usercustom(default_conf, ticker, limit_sell_order_old, mocker,
+                                               open_trade) -> None:
+    default_conf["unfilledtimeout"] = {"buy": 1440, "sell": 1440}
+    rpc_mock = patch_RPCManager(mocker)
+    cancel_order_mock = MagicMock()
+    patch_exchange(mocker)
+    mocker.patch.multiple(
+        'freqtrade.exchange.Exchange',
+        fetch_ticker=ticker,
+        fetch_order=MagicMock(return_value=limit_sell_order_old),
+        cancel_order=cancel_order_mock
+    )
+    freqtrade = FreqtradeBot(default_conf)
+
+    open_trade.open_date = arrow.utcnow().shift(hours=-5).datetime
+    open_trade.close_date = arrow.utcnow().shift(minutes=-601).datetime
+    open_trade.is_open = False
+
+    Trade.session.add(open_trade)
+    # Ensure default is false
+    freqtrade.check_handle_timedout()
+    assert cancel_order_mock.call_count == 0
+
+    freqtrade.strategy.check_sell_timeout = MagicMock(return_value=False)
+    # Return false - No impact
+    freqtrade.check_handle_timedout()
+    assert cancel_order_mock.call_count == 0
+    assert rpc_mock.call_count == 0
+    assert open_trade.is_open is False
+    assert freqtrade.strategy.check_sell_timeout.call_count == 1
+
+    freqtrade.strategy.check_sell_timeout = MagicMock(side_effect=KeyError)
+    # Return Error - No impact
+    freqtrade.check_handle_timedout()
+    assert cancel_order_mock.call_count == 0
+    assert rpc_mock.call_count == 0
+    assert open_trade.is_open is False
+    assert freqtrade.strategy.check_sell_timeout.call_count == 1
+
+    # Return True - sells!
+    freqtrade.strategy.check_sell_timeout = MagicMock(return_value=True)
+    freqtrade.check_handle_timedout()
+    assert cancel_order_mock.call_count == 1
+    assert rpc_mock.call_count == 1
+    assert open_trade.is_open is True
+    assert freqtrade.strategy.check_sell_timeout.call_count == 1
+
+
 def test_check_handle_timedout_sell(default_conf, ticker, limit_sell_order_old, mocker,
                                     open_trade) -> None:
     rpc_mock = patch_RPCManager(mocker)
@@ -2026,7 +2216,7 @@ def test_check_handle_timedout_sell(default_conf, ticker, limit_sell_order_old,
     mocker.patch.multiple(
         'freqtrade.exchange.Exchange',
         fetch_ticker=ticker,
-        get_order=MagicMock(return_value=limit_sell_order_old),
+        fetch_order=MagicMock(return_value=limit_sell_order_old),
         cancel_order=cancel_order_mock
     )
     freqtrade = FreqtradeBot(default_conf)
@@ -2037,11 +2227,14 @@ def test_check_handle_timedout_sell(default_conf, ticker, limit_sell_order_old,
 
     Trade.session.add(open_trade)
 
+    freqtrade.strategy.check_sell_timeout = MagicMock(return_value=False)
     # check it does cancel sell orders over the time limit
     freqtrade.check_handle_timedout()
     assert cancel_order_mock.call_count == 1
     assert rpc_mock.call_count == 1
     assert open_trade.is_open is True
+    # Custom user sell-timeout is never called
+    assert freqtrade.strategy.check_sell_timeout.call_count == 0
 
 
 def test_check_handle_cancelled_sell(default_conf, ticker, limit_sell_order_old, open_trade,
@@ -2054,7 +2247,7 @@ def test_check_handle_cancelled_sell(default_conf, ticker, limit_sell_order_old,
     mocker.patch.multiple(
         'freqtrade.exchange.Exchange',
         fetch_ticker=ticker,
-        get_order=MagicMock(return_value=limit_sell_order_old),
+        fetch_order=MagicMock(return_value=limit_sell_order_old),
         cancel_order_with_result=cancel_order_mock
     )
     freqtrade = FreqtradeBot(default_conf)
@@ -2076,12 +2269,15 @@ def test_check_handle_cancelled_sell(default_conf, ticker, limit_sell_order_old,
 def test_check_handle_timedout_partial(default_conf, ticker, limit_buy_order_old_partial,
                                        open_trade, mocker) -> None:
     rpc_mock = patch_RPCManager(mocker)
-    cancel_order_mock = MagicMock(return_value=limit_buy_order_old_partial)
+    limit_buy_canceled = deepcopy(limit_buy_order_old_partial)
+    limit_buy_canceled['status'] = 'canceled'
+
+    cancel_order_mock = MagicMock(return_value=limit_buy_canceled)
     patch_exchange(mocker)
     mocker.patch.multiple(
         'freqtrade.exchange.Exchange',
         fetch_ticker=ticker,
-        get_order=MagicMock(return_value=limit_buy_order_old_partial),
+        fetch_order=MagicMock(return_value=limit_buy_order_old_partial),
         cancel_order_with_result=cancel_order_mock
     )
     freqtrade = FreqtradeBot(default_conf)
@@ -2104,11 +2300,12 @@ def test_check_handle_timedout_partial_fee(default_conf, ticker, open_trade, cap
                                            limit_buy_order_old_partial_canceled, mocker) -> None:
     rpc_mock = patch_RPCManager(mocker)
     cancel_order_mock = MagicMock(return_value=limit_buy_order_old_partial_canceled)
+    mocker.patch('freqtrade.wallets.Wallets.get_free', MagicMock(return_value=0))
     patch_exchange(mocker)
     mocker.patch.multiple(
         'freqtrade.exchange.Exchange',
         fetch_ticker=ticker,
-        get_order=MagicMock(return_value=limit_buy_order_old_partial),
+        fetch_order=MagicMock(return_value=limit_buy_order_old_partial),
         cancel_order_with_result=cancel_order_mock,
         get_trades_for_order=MagicMock(return_value=trades_for_order),
     )
@@ -2123,7 +2320,7 @@ def test_check_handle_timedout_partial_fee(default_conf, ticker, open_trade, cap
     # and apply fees if necessary.
     freqtrade.check_handle_timedout()
 
-    assert log_has_re(r"Applying fee on amount for Trade.* Order", caplog)
+    assert log_has_re(r"Applying fee on amount for Trade.*", caplog)
 
     assert cancel_order_mock.call_count == 1
     assert rpc_mock.call_count == 2
@@ -2131,9 +2328,10 @@ def test_check_handle_timedout_partial_fee(default_conf, ticker, open_trade, cap
     assert len(trades) == 1
     # Verify that trade has been updated
     assert trades[0].amount == (limit_buy_order_old_partial['amount'] -
-                                limit_buy_order_old_partial['remaining']) - 0.0001
+                                limit_buy_order_old_partial['remaining']) - 0.023
     assert trades[0].open_order_id is None
-    assert trades[0].fee_open == 0
+    assert trades[0].fee_updated('buy')
+    assert pytest.approx(trades[0].fee_open) == 0.001
 
 
 def test_check_handle_timedout_partial_except(default_conf, ticker, open_trade, caplog, fee,
@@ -2145,7 +2343,7 @@ def test_check_handle_timedout_partial_except(default_conf, ticker, open_trade,
     mocker.patch.multiple(
         'freqtrade.exchange.Exchange',
         fetch_ticker=ticker,
-        get_order=MagicMock(return_value=limit_buy_order_old_partial),
+        fetch_order=MagicMock(return_value=limit_buy_order_old_partial),
         cancel_order_with_result=cancel_order_mock,
         get_trades_for_order=MagicMock(return_value=trades_for_order),
     )
@@ -2183,13 +2381,13 @@ def test_check_handle_timedout_exception(default_conf, ticker, open_trade, mocke
 
     mocker.patch.multiple(
         'freqtrade.freqtradebot.FreqtradeBot',
-        handle_timedout_limit_buy=MagicMock(),
-        handle_timedout_limit_sell=MagicMock(),
+        handle_cancel_buy=MagicMock(),
+        handle_cancel_sell=MagicMock(),
     )
     mocker.patch.multiple(
         'freqtrade.exchange.Exchange',
         fetch_ticker=ticker,
-        get_order=MagicMock(side_effect=requests.exceptions.RequestException('Oh snap')),
+        fetch_order=MagicMock(side_effect=ExchangeError('Oh snap')),
         cancel_order=cancel_order_mock
     )
     freqtrade = FreqtradeBot(default_conf)
@@ -2204,28 +2402,61 @@ def test_check_handle_timedout_exception(default_conf, ticker, open_trade, mocke
                       caplog)
 
 
-def test_handle_timedout_limit_buy(mocker, caplog, default_conf, limit_buy_order) -> None:
+def test_handle_cancel_buy(mocker, caplog, default_conf, limit_buy_order) -> None:
     patch_RPCManager(mocker)
     patch_exchange(mocker)
-    cancel_order_mock = MagicMock(return_value=limit_buy_order)
+    cancel_buy_order = deepcopy(limit_buy_order)
+    cancel_buy_order['status'] = 'canceled'
+    del cancel_buy_order['filled']
+
+    cancel_order_mock = MagicMock(return_value=cancel_buy_order)
     mocker.patch('freqtrade.exchange.Exchange.cancel_order_with_result', cancel_order_mock)
 
     freqtrade = FreqtradeBot(default_conf)
+    freqtrade._notify_buy_cancel = MagicMock()
 
     Trade.session = MagicMock()
     trade = MagicMock()
     trade.pair = 'LTC/ETH'
-    limit_buy_order['remaining'] = limit_buy_order['amount']
-    assert freqtrade.handle_timedout_limit_buy(trade, limit_buy_order)
+    limit_buy_order['filled'] = 0.0
+    limit_buy_order['status'] = 'open'
+    reason = CANCEL_REASON['TIMEOUT']
+    assert freqtrade.handle_cancel_buy(trade, limit_buy_order, reason)
     assert cancel_order_mock.call_count == 1
 
     cancel_order_mock.reset_mock()
-    limit_buy_order['amount'] = 2
-    assert not freqtrade.handle_timedout_limit_buy(trade, limit_buy_order)
+    limit_buy_order['filled'] = 2
+    assert not freqtrade.handle_cancel_buy(trade, limit_buy_order, reason)
     assert cancel_order_mock.call_count == 1
 
-    mocker.patch('freqtrade.exchange.Exchange.cancel_order', side_effect=InvalidOrderException)
-    assert not freqtrade.handle_timedout_limit_buy(trade, limit_buy_order)
+    # Order remained open for some reason (cancel failed)
+    cancel_buy_order['status'] = 'open'
+    cancel_order_mock = MagicMock(return_value=cancel_buy_order)
+    mocker.patch('freqtrade.exchange.Exchange.cancel_order_with_result', cancel_order_mock)
+    assert not freqtrade.handle_cancel_buy(trade, limit_buy_order, reason)
+    assert log_has_re(r"Order .* for .* not cancelled.", caplog)
+
+
+@pytest.mark.parametrize("limit_buy_order_canceled_empty", ['binance', 'ftx', 'kraken', 'bittrex'],
+                         indirect=['limit_buy_order_canceled_empty'])
+def test_handle_cancel_buy_exchanges(mocker, caplog, default_conf,
+                                     limit_buy_order_canceled_empty) -> None:
+    patch_RPCManager(mocker)
+    patch_exchange(mocker)
+    cancel_order_mock = mocker.patch(
+        'freqtrade.exchange.Exchange.cancel_order_with_result',
+        return_value=limit_buy_order_canceled_empty)
+    nofiy_mock = mocker.patch('freqtrade.freqtradebot.FreqtradeBot._notify_buy_cancel')
+    freqtrade = FreqtradeBot(default_conf)
+
+    Trade.session = MagicMock()
+    reason = CANCEL_REASON['TIMEOUT']
+    trade = MagicMock()
+    trade.pair = 'LTC/ETH'
+    assert freqtrade.handle_cancel_buy(trade, limit_buy_order_canceled_empty, reason)
+    assert cancel_order_mock.call_count == 0
+    assert log_has_re(r'Buy order fully cancelled. Removing .* from database\.', caplog)
+    assert nofiy_mock.call_count == 1
 
 
 @pytest.mark.parametrize('cancelorder', [
@@ -2234,8 +2465,8 @@ def test_handle_timedout_limit_buy(mocker, caplog, default_conf, limit_buy_order
     'String Return value',
     123
 ])
-def test_handle_timedout_limit_buy_corder_empty(mocker, default_conf, limit_buy_order,
-                                                cancelorder) -> None:
+def test_handle_cancel_buy_corder_empty(mocker, default_conf, limit_buy_order,
+                                        cancelorder) -> None:
     patch_RPCManager(mocker)
     patch_exchange(mocker)
     cancel_order_mock = MagicMock(return_value=cancelorder)
@@ -2245,42 +2476,80 @@ def test_handle_timedout_limit_buy_corder_empty(mocker, default_conf, limit_buy_
     )
 
     freqtrade = FreqtradeBot(default_conf)
+    freqtrade._notify_buy_cancel = MagicMock()
 
     Trade.session = MagicMock()
     trade = MagicMock()
     trade.pair = 'LTC/ETH'
-    limit_buy_order['remaining'] = limit_buy_order['amount']
-    assert freqtrade.handle_timedout_limit_buy(trade, limit_buy_order)
+    limit_buy_order['filled'] = 0.0
+    limit_buy_order['status'] = 'open'
+    reason = CANCEL_REASON['TIMEOUT']
+    assert freqtrade.handle_cancel_buy(trade, limit_buy_order, reason)
     assert cancel_order_mock.call_count == 1
 
     cancel_order_mock.reset_mock()
-    limit_buy_order['amount'] = 2
-    assert not freqtrade.handle_timedout_limit_buy(trade, limit_buy_order)
+    limit_buy_order['filled'] = 1.0
+    assert not freqtrade.handle_cancel_buy(trade, limit_buy_order, reason)
     assert cancel_order_mock.call_count == 1
 
 
-def test_handle_timedout_limit_sell(mocker, default_conf) -> None:
-    patch_RPCManager(mocker)
+def test_handle_cancel_sell_limit(mocker, default_conf, fee) -> None:
+    send_msg_mock = patch_RPCManager(mocker)
     patch_exchange(mocker)
     cancel_order_mock = MagicMock()
     mocker.patch.multiple(
         'freqtrade.exchange.Exchange',
-        cancel_order=cancel_order_mock
+        cancel_order=cancel_order_mock,
     )
+    mocker.patch('freqtrade.freqtradebot.FreqtradeBot.get_sell_rate', return_value=0.245441)
+
+    freqtrade = FreqtradeBot(default_conf)
+
+    trade = Trade(
+        pair='LTC/ETH',
+        amount=2,
+        exchange='binance',
+        open_rate=0.245441,
+        open_order_id="123456",
+        open_date=arrow.utcnow().datetime,
+        fee_open=fee.return_value,
+        fee_close=fee.return_value,
+    )
+    order = {'remaining': 1,
+             'amount': 1,
+             'status': "open"}
+    reason = CANCEL_REASON['TIMEOUT']
+    assert freqtrade.handle_cancel_sell(trade, order, reason)
+    assert cancel_order_mock.call_count == 1
+    assert send_msg_mock.call_count == 1
+
+    send_msg_mock.reset_mock()
+
+    order['amount'] = 2
+    assert freqtrade.handle_cancel_sell(trade, order, reason) == CANCEL_REASON['PARTIALLY_FILLED']
+    # Assert cancel_order was not called (callcount remains unchanged)
+    assert cancel_order_mock.call_count == 1
+    assert send_msg_mock.call_count == 1
+    assert freqtrade.handle_cancel_sell(trade, order, reason) == CANCEL_REASON['PARTIALLY_FILLED']
+    # Message should not be iterated again
+    assert trade.sell_order_status == CANCEL_REASON['PARTIALLY_FILLED']
+    assert send_msg_mock.call_count == 1
+
+
+def test_handle_cancel_sell_cancel_exception(mocker, default_conf) -> None:
+    patch_RPCManager(mocker)
+    patch_exchange(mocker)
+    mocker.patch(
+        'freqtrade.exchange.Exchange.cancel_order', side_effect=InvalidOrderException())
 
     freqtrade = FreqtradeBot(default_conf)
 
     trade = MagicMock()
+    reason = CANCEL_REASON['TIMEOUT']
     order = {'remaining': 1,
              'amount': 1,
              'status': "open"}
-    assert freqtrade.handle_timedout_limit_sell(trade, order)
-    assert cancel_order_mock.call_count == 1
-    order['amount'] = 2
-    assert (freqtrade.handle_timedout_limit_sell(trade, order)
-            == 'partially filled - keeping order open')
-    # Assert cancel_order was not called (callcount remains unchanged)
-    assert cancel_order_mock.call_count == 1
+    assert freqtrade.handle_cancel_sell(trade, order, reason) == 'error cancelling order'
 
 
 def test_execute_sell_up(default_conf, ticker, fee, ticker_sell_up, mocker) -> None:
@@ -2294,30 +2563,42 @@ def test_execute_sell_up(default_conf, ticker, fee, ticker_sell_up, mocker) -> N
     patch_whitelist(mocker, default_conf)
     freqtrade = FreqtradeBot(default_conf)
     patch_get_signal(freqtrade)
+    freqtrade.strategy.confirm_trade_exit = MagicMock(return_value=False)
 
     # Create some test data
     freqtrade.enter_positions()
+    rpc_mock.reset_mock()
 
     trade = Trade.query.first()
     assert trade
+    assert freqtrade.strategy.confirm_trade_exit.call_count == 0
 
     # Increase the price and sell it
     mocker.patch.multiple(
         'freqtrade.exchange.Exchange',
         fetch_ticker=ticker_sell_up
     )
+    # Prevented sell ...
+    freqtrade.execute_sell(trade=trade, limit=ticker_sell_up()['bid'], sell_reason=SellType.ROI)
+    assert rpc_mock.call_count == 0
+    assert freqtrade.strategy.confirm_trade_exit.call_count == 1
+
+    # Repatch with true
+    freqtrade.strategy.confirm_trade_exit = MagicMock(return_value=True)
 
     freqtrade.execute_sell(trade=trade, limit=ticker_sell_up()['bid'], sell_reason=SellType.ROI)
+    assert freqtrade.strategy.confirm_trade_exit.call_count == 1
 
-    assert rpc_mock.call_count == 2
+    assert rpc_mock.call_count == 1
     last_msg = rpc_mock.call_args_list[-1][0][0]
     assert {
+        'trade_id': 1,
         'type': RPCMessageType.SELL_NOTIFICATION,
         'exchange': 'Bittrex',
         'pair': 'ETH/BTC',
         'gain': 'profit',
         'limit': 1.172e-05,
-        'amount': 91.07468123861567,
+        'amount': 91.07468123,
         'order_type': 'limit',
         'open_rate': 1.098e-05,
         'current_rate': 1.173e-05,
@@ -2362,11 +2643,12 @@ def test_execute_sell_down(default_conf, ticker, fee, ticker_sell_down, mocker)
     last_msg = rpc_mock.call_args_list[-1][0][0]
     assert {
         'type': RPCMessageType.SELL_NOTIFICATION,
+        'trade_id': 1,
         'exchange': 'Bittrex',
         'pair': 'ETH/BTC',
         'gain': 'loss',
         'limit': 1.044e-05,
-        'amount': 91.07468123861567,
+        'amount': 91.07468123,
         'order_type': 'limit',
         'open_rate': 1.098e-05,
         'current_rate': 1.043e-05,
@@ -2418,11 +2700,12 @@ def test_execute_sell_down_stoploss_on_exchange_dry_run(default_conf, ticker, fe
 
     assert {
         'type': RPCMessageType.SELL_NOTIFICATION,
+        'trade_id': 1,
         'exchange': 'Bittrex',
         'pair': 'ETH/BTC',
         'gain': 'loss',
         'limit': 1.08801e-05,
-        'amount': 91.07468123861567,
+        'amount': 91.07468123,
         'order_type': 'limit',
         'open_rate': 1.098e-05,
         'current_rate': 1.043e-05,
@@ -2439,7 +2722,8 @@ def test_execute_sell_down_stoploss_on_exchange_dry_run(default_conf, ticker, fe
 
 def test_execute_sell_sloe_cancel_exception(mocker, default_conf, ticker, fee, caplog) -> None:
     freqtrade = get_patched_freqtradebot(mocker, default_conf)
-    mocker.patch('freqtrade.exchange.Exchange.cancel_order', side_effect=InvalidOrderException())
+    mocker.patch('freqtrade.exchange.Exchange.cancel_stoploss_order',
+                 side_effect=InvalidOrderException())
     mocker.patch('freqtrade.wallets.Wallets.get_free', MagicMock(return_value=300))
     sellmock = MagicMock()
     patch_exchange(mocker)
@@ -2487,7 +2771,7 @@ def test_execute_sell_with_stoploss_on_exchange(default_conf, ticker, fee, ticke
         amount_to_precision=lambda s, x, y: y,
         price_to_precision=lambda s, x, y: y,
         stoploss=stoploss,
-        cancel_order=cancel_order,
+        cancel_stoploss_order=cancel_order,
     )
 
     freqtrade = FreqtradeBot(default_conf)
@@ -2578,7 +2862,7 @@ def test_may_execute_sell_after_stoploss_on_exchange_hit(default_conf, ticker, f
         "fee": None,
         "trades": None
     })
-    mocker.patch('freqtrade.exchange.Exchange.get_order', stoploss_executed)
+    mocker.patch('freqtrade.exchange.Exchange.fetch_stoploss_order', stoploss_executed)
 
     freqtrade.exit_positions(trades)
     assert trade.stoploss_order_id is None
@@ -2622,11 +2906,12 @@ def test_execute_sell_market_order(default_conf, ticker, fee,
     last_msg = rpc_mock.call_args_list[-1][0][0]
     assert {
         'type': RPCMessageType.SELL_NOTIFICATION,
+        'trade_id': 1,
         'exchange': 'Bittrex',
         'pair': 'ETH/BTC',
         'gain': 'profit',
         'limit': 1.172e-05,
-        'amount': 91.07468123861567,
+        'amount': 91.07468123,
         'order_type': 'market',
         'open_rate': 1.098e-05,
         'current_rate': 1.173e-05,
@@ -2963,10 +3248,8 @@ def test_trailing_stop_loss(default_conf, limit_buy_order, fee, caplog, mocker)
     caplog.set_level(logging.DEBUG)
     # Sell as trailing-stop is reached
     assert freqtrade.handle_trade(trade) is True
-    assert log_has(
-        f"ETH/BTC - HIT STOP: current price at 0.000012, "
-        f"stoploss is 0.000015, "
-        f"initial stoploss was at 0.000010, trade opened at 0.000011", caplog)
+    assert log_has("ETH/BTC - HIT STOP: current price at 0.000012, stoploss is 0.000015, "
+                   "initial stoploss was at 0.000010, trade opened at 0.000011", caplog)
     assert trade.sell_reason == SellType.TRAILING_STOP_LOSS.value
 
 
@@ -3009,8 +3292,8 @@ def test_trailing_stop_loss_positive(default_conf, limit_buy_order, fee,
                  }))
     # stop-loss not reached, adjusted stoploss
     assert freqtrade.handle_trade(trade) is False
-    assert log_has(f"ETH/BTC - Using positive stoploss: 0.01 offset: 0 profit: 0.2666%", caplog)
-    assert log_has(f"ETH/BTC - Adjusting stoploss...", caplog)
+    assert log_has("ETH/BTC - Using positive stoploss: 0.01 offset: 0 profit: 0.2666%", caplog)
+    assert log_has("ETH/BTC - Adjusting stoploss...", caplog)
     assert trade.stop_loss == 0.0000138501
 
     mocker.patch('freqtrade.exchange.Exchange.fetch_ticker',
@@ -3066,9 +3349,8 @@ def test_trailing_stop_loss_offset(default_conf, limit_buy_order, fee,
                  }))
     # stop-loss not reached, adjusted stoploss
     assert freqtrade.handle_trade(trade) is False
-    assert log_has(f"ETH/BTC - Using positive stoploss: 0.01 offset: 0.011 profit: 0.2666%",
-                   caplog)
-    assert log_has(f"ETH/BTC - Adjusting stoploss...", caplog)
+    assert log_has("ETH/BTC - Using positive stoploss: 0.01 offset: 0.011 profit: 0.2666%", caplog)
+    assert log_has("ETH/BTC - Adjusting stoploss...", caplog)
     assert trade.stop_loss == 0.0000138501
 
     mocker.patch('freqtrade.exchange.Exchange.fetch_ticker',
@@ -3132,7 +3414,7 @@ def test_tsl_only_offset_reached(default_conf, limit_buy_order, fee,
     # stop-loss should not be adjusted as offset is not reached yet
     assert freqtrade.handle_trade(trade) is False
 
-    assert not log_has(f"ETH/BTC - Adjusting stoploss...", caplog)
+    assert not log_has("ETH/BTC - Adjusting stoploss...", caplog)
     assert trade.stop_loss == 0.0000098910
 
     # price rises above the offset (rises 12% when the offset is 5.5%)
@@ -3144,9 +3426,8 @@ def test_tsl_only_offset_reached(default_conf, limit_buy_order, fee,
                  }))
 
     assert freqtrade.handle_trade(trade) is False
-    assert log_has(f"ETH/BTC - Using positive stoploss: 0.05 offset: 0.055 profit: 0.1218%",
-                   caplog)
-    assert log_has(f"ETH/BTC - Adjusting stoploss...", caplog)
+    assert log_has("ETH/BTC - Using positive stoploss: 0.05 offset: 0.055 profit: 0.1218%", caplog)
+    assert log_has("ETH/BTC - Adjusting stoploss...", caplog)
     assert trade.stop_loss == 0.0000117705
 
 
@@ -3187,8 +3468,6 @@ def test_disable_ignore_roi_if_buy_signal(default_conf, limit_buy_order,
 
 def test_get_real_amount_quote(default_conf, trades_for_order, buy_order_fee, fee, caplog, mocker):
     mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=trades_for_order)
-    patch_RPCManager(mocker)
-    patch_exchange(mocker)
     amount = sum(x['amount'] for x in trades_for_order)
     trade = Trade(
         pair='LTC/ETH',
@@ -3199,21 +3478,43 @@ def test_get_real_amount_quote(default_conf, trades_for_order, buy_order_fee, fe
         fee_close=fee.return_value,
         open_order_id="123456"
     )
-    freqtrade = FreqtradeBot(default_conf)
-    patch_get_signal(freqtrade)
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
 
     # Amount is reduced by "fee"
     assert freqtrade.get_real_amount(trade, buy_order_fee) == amount - (amount * 0.001)
     assert log_has('Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8.00000000, '
-                   'open_rate=0.24544100, open_since=closed) (from 8.0 to 7.992) from Trades',
+                   'open_rate=0.24544100, open_since=closed) (from 8.0 to 7.992).',
                    caplog)
 
 
+def test_get_real_amount_quote_dust(default_conf, trades_for_order, buy_order_fee, fee,
+                                    caplog, mocker):
+    mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=trades_for_order)
+    walletmock = mocker.patch('freqtrade.wallets.Wallets.update')
+    mocker.patch('freqtrade.wallets.Wallets.get_free', return_value=8.1122)
+    amount = sum(x['amount'] for x in trades_for_order)
+    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"
+    )
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
+
+    walletmock.reset_mock()
+    # Amount is kept as is
+    assert freqtrade.get_real_amount(trade, buy_order_fee) == amount
+    assert walletmock.call_count == 1
+    assert log_has_re(r'Fee amount for Trade.* was in base currency '
+                      '- Eating Fee 0.008 into dust', caplog)
+
+
 def test_get_real_amount_no_trade(default_conf, buy_order_fee, caplog, mocker, fee):
     mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=[])
 
-    patch_RPCManager(mocker)
-    patch_exchange(mocker)
     amount = buy_order_fee['amount']
     trade = Trade(
         pair='LTC/ETH',
@@ -3224,8 +3525,7 @@ def test_get_real_amount_no_trade(default_conf, buy_order_fee, caplog, mocker, f
         fee_close=fee.return_value,
         open_order_id="123456"
     )
-    freqtrade = FreqtradeBot(default_conf)
-    patch_get_signal(freqtrade)
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
 
     # Amount is reduced by "fee"
     assert freqtrade.get_real_amount(trade, buy_order_fee) == amount
@@ -3237,8 +3537,6 @@ def test_get_real_amount_no_trade(default_conf, buy_order_fee, caplog, mocker, f
 def test_get_real_amount_stake(default_conf, trades_for_order, buy_order_fee, fee, mocker):
     trades_for_order[0]['fee']['currency'] = 'ETH'
 
-    patch_RPCManager(mocker)
-    patch_exchange(mocker)
     mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=trades_for_order)
     amount = sum(x['amount'] for x in trades_for_order)
     trade = Trade(
@@ -3250,8 +3548,7 @@ def test_get_real_amount_stake(default_conf, trades_for_order, buy_order_fee, fe
         open_rate=0.245441,
         open_order_id="123456"
     )
-    freqtrade = FreqtradeBot(default_conf)
-    patch_get_signal(freqtrade)
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
 
     # Amount does not change
     assert freqtrade.get_real_amount(trade, buy_order_fee) == amount
@@ -3264,8 +3561,6 @@ def test_get_real_amount_no_currency_in_fee(default_conf, trades_for_order, buy_
     limit_buy_order['fee'] = {'cost': 0.004, 'currency': None}
     trades_for_order[0]['fee']['currency'] = None
 
-    patch_RPCManager(mocker)
-    patch_exchange(mocker)
     mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=trades_for_order)
     amount = sum(x['amount'] for x in trades_for_order)
     trade = Trade(
@@ -3277,8 +3572,7 @@ def test_get_real_amount_no_currency_in_fee(default_conf, trades_for_order, buy_
         open_rate=0.245441,
         open_order_id="123456"
     )
-    freqtrade = FreqtradeBot(default_conf)
-    patch_get_signal(freqtrade)
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
 
     # Amount does not change
     assert freqtrade.get_real_amount(trade, limit_buy_order) == amount
@@ -3288,8 +3582,6 @@ def test_get_real_amount_BNB(default_conf, trades_for_order, buy_order_fee, fee,
     trades_for_order[0]['fee']['currency'] = 'BNB'
     trades_for_order[0]['fee']['cost'] = 0.00094518
 
-    patch_RPCManager(mocker)
-    patch_exchange(mocker)
     mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=trades_for_order)
     amount = sum(x['amount'] for x in trades_for_order)
     trade = Trade(
@@ -3301,16 +3593,13 @@ def test_get_real_amount_BNB(default_conf, trades_for_order, buy_order_fee, fee,
         open_rate=0.245441,
         open_order_id="123456"
     )
-    freqtrade = FreqtradeBot(default_conf)
-    patch_get_signal(freqtrade)
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
 
     # Amount does not change
     assert freqtrade.get_real_amount(trade, buy_order_fee) == amount
 
 
 def test_get_real_amount_multi(default_conf, trades_for_order2, buy_order_fee, caplog, fee, mocker):
-    patch_RPCManager(mocker)
-    patch_exchange(mocker)
     mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=trades_for_order2)
     amount = float(sum(x['amount'] for x in trades_for_order2))
     trade = Trade(
@@ -3322,13 +3611,12 @@ def test_get_real_amount_multi(default_conf, trades_for_order2, buy_order_fee, c
         open_rate=0.245441,
         open_order_id="123456"
     )
-    freqtrade = FreqtradeBot(default_conf)
-    patch_get_signal(freqtrade)
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
 
     # Amount is reduced by "fee"
     assert freqtrade.get_real_amount(trade, buy_order_fee) == amount - (amount * 0.001)
     assert log_has('Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8.00000000, '
-                   'open_rate=0.24544100, open_since=closed) (from 8.0 to 7.992) from Trades',
+                   'open_rate=0.24544100, open_since=closed) (from 8.0 to 7.992).',
                    caplog)
 
 
@@ -3337,8 +3625,6 @@ def test_get_real_amount_fromorder(default_conf, trades_for_order, buy_order_fee
     limit_buy_order = deepcopy(buy_order_fee)
     limit_buy_order['fee'] = {'cost': 0.004, 'currency': 'LTC'}
 
-    patch_RPCManager(mocker)
-    patch_exchange(mocker)
     mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order',
                  return_value=[trades_for_order])
     amount = float(sum(x['amount'] for x in trades_for_order))
@@ -3351,13 +3637,12 @@ def test_get_real_amount_fromorder(default_conf, trades_for_order, buy_order_fee
         open_rate=0.245441,
         open_order_id="123456"
     )
-    freqtrade = FreqtradeBot(default_conf)
-    patch_get_signal(freqtrade)
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
 
     # Amount is reduced by "fee"
     assert freqtrade.get_real_amount(trade, limit_buy_order) == amount - 0.004
     assert log_has('Applying fee on amount for Trade(id=None, pair=LTC/ETH, amount=8.00000000, '
-                   'open_rate=0.24544100, open_since=closed) (from 8.0 to 7.996) from Order',
+                   'open_rate=0.24544100, open_since=closed) (from 8.0 to 7.996).',
                    caplog)
 
 
@@ -3365,8 +3650,6 @@ def test_get_real_amount_invalid_order(default_conf, trades_for_order, buy_order
     limit_buy_order = deepcopy(buy_order_fee)
     limit_buy_order['fee'] = {'cost': 0.004}
 
-    patch_RPCManager(mocker)
-    patch_exchange(mocker)
     mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=[])
     amount = float(sum(x['amount'] for x in trades_for_order))
     trade = Trade(
@@ -3378,8 +3661,7 @@ def test_get_real_amount_invalid_order(default_conf, trades_for_order, buy_order
         open_rate=0.245441,
         open_order_id="123456"
     )
-    freqtrade = FreqtradeBot(default_conf)
-    patch_get_signal(freqtrade)
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
 
     # Amount does not change
     assert freqtrade.get_real_amount(trade, limit_buy_order) == amount
@@ -3389,8 +3671,6 @@ def test_get_real_amount_wrong_amount(default_conf, trades_for_order, buy_order_
     limit_buy_order = deepcopy(buy_order_fee)
     limit_buy_order['amount'] = limit_buy_order['amount'] - 0.001
 
-    patch_RPCManager(mocker)
-    patch_exchange(mocker)
     mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=trades_for_order)
     amount = float(sum(x['amount'] for x in trades_for_order))
     trade = Trade(
@@ -3402,8 +3682,7 @@ def test_get_real_amount_wrong_amount(default_conf, trades_for_order, buy_order_
         fee_close=fee.return_value,
         open_order_id="123456"
     )
-    freqtrade = FreqtradeBot(default_conf)
-    patch_get_signal(freqtrade)
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
 
     # Amount does not change
     with pytest.raises(DependencyException, match=r"Half bought\? Amounts don't match"):
@@ -3416,8 +3695,6 @@ def test_get_real_amount_wrong_amount_rounding(default_conf, trades_for_order, b
     limit_buy_order = deepcopy(buy_order_fee)
     trades_for_order[0]['amount'] = trades_for_order[0]['amount'] + 1e-15
 
-    patch_RPCManager(mocker)
-    patch_exchange(mocker)
     mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=trades_for_order)
     amount = float(sum(x['amount'] for x in trades_for_order))
     trade = Trade(
@@ -3429,8 +3706,7 @@ def test_get_real_amount_wrong_amount_rounding(default_conf, trades_for_order, b
         open_rate=0.245441,
         open_order_id="123456"
     )
-    freqtrade = FreqtradeBot(default_conf)
-    patch_get_signal(freqtrade)
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
 
     # Amount changes by fee amount.
     assert isclose(freqtrade.get_real_amount(trade, limit_buy_order), amount - (amount * 0.001),
@@ -3441,8 +3717,6 @@ def test_get_real_amount_invalid(default_conf, trades_for_order, buy_order_fee,
     # Remove "Currency" from fee dict
     trades_for_order[0]['fee'] = {'cost': 0.008}
 
-    patch_RPCManager(mocker)
-    patch_exchange(mocker)
     mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=trades_for_order)
     amount = sum(x['amount'] for x in trades_for_order)
     trade = Trade(
@@ -3455,15 +3729,12 @@ def test_get_real_amount_invalid(default_conf, trades_for_order, buy_order_fee,
 
         open_order_id="123456"
     )
-    freqtrade = FreqtradeBot(default_conf)
-    patch_get_signal(freqtrade)
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
     # Amount does not change
     assert freqtrade.get_real_amount(trade, buy_order_fee) == amount
 
 
 def test_get_real_amount_open_trade(default_conf, fee, mocker):
-    patch_RPCManager(mocker)
-    patch_exchange(mocker)
     amount = 12345
     trade = Trade(
         pair='LTC/ETH',
@@ -3478,19 +3749,48 @@ def test_get_real_amount_open_trade(default_conf, fee, mocker):
         'id': 'mocked_order',
         'amount': amount,
         'status': 'open',
+        'side': 'buy',
     }
-    freqtrade = FreqtradeBot(default_conf)
-    patch_get_signal(freqtrade)
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
     assert freqtrade.get_real_amount(trade, order) == amount
 
 
+@pytest.mark.parametrize('amount,fee_abs,wallet,amount_exp', [
+    (8.0, 0.0, 10, 8),
+    (8.0, 0.0, 0, 8),
+    (8.0, 0.1, 0, 7.9),
+    (8.0, 0.1, 10, 8),
+    (8.0, 0.1, 8.0, 8.0),
+    (8.0, 0.1, 7.9, 7.9),
+])
+def test_apply_fee_conditional(default_conf, fee, caplog, mocker,
+                               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"
+    )
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
+
+    walletmock.reset_mock()
+    # Amount is kept as is
+    assert freqtrade.apply_fee_conditional(trade, 'LTC', amount, fee_abs) == amount_exp
+    assert walletmock.call_count == 1
+
+
 def test_order_book_depth_of_market(default_conf, ticker, limit_buy_order, fee, mocker,
                                     order_book_l2):
     default_conf['bid_strategy']['check_depth_of_market']['enabled'] = True
     default_conf['bid_strategy']['check_depth_of_market']['bids_to_ask_delta'] = 0.1
     patch_RPCManager(mocker)
     patch_exchange(mocker)
-    mocker.patch('freqtrade.exchange.Exchange.get_order_book', order_book_l2)
+    mocker.patch('freqtrade.exchange.Exchange.fetch_l2_order_book', order_book_l2)
     mocker.patch.multiple(
         'freqtrade.exchange.Exchange',
         fetch_ticker=ticker,
@@ -3527,7 +3827,7 @@ def test_order_book_depth_of_market_high_delta(default_conf, ticker, limit_buy_o
     default_conf['bid_strategy']['check_depth_of_market']['bids_to_ask_delta'] = 100
     patch_RPCManager(mocker)
     patch_exchange(mocker)
-    mocker.patch('freqtrade.exchange.Exchange.get_order_book', order_book_l2)
+    mocker.patch('freqtrade.exchange.Exchange.fetch_l2_order_book', order_book_l2)
     mocker.patch.multiple(
         'freqtrade.exchange.Exchange',
         fetch_ticker=ticker,
@@ -3552,7 +3852,7 @@ def test_order_book_bid_strategy1(mocker, default_conf, order_book_l2) -> None:
     ticker_mock = MagicMock(return_value={'ask': 0.045, 'last': 0.046})
     mocker.patch.multiple(
         'freqtrade.exchange.Exchange',
-        get_order_book=order_book_l2,
+        fetch_l2_order_book=order_book_l2,
         fetch_ticker=ticker_mock,
 
     )
@@ -3567,29 +3867,26 @@ def test_order_book_bid_strategy1(mocker, default_conf, order_book_l2) -> None:
     assert ticker_mock.call_count == 0
 
 
-def test_order_book_bid_strategy2(mocker, default_conf, order_book_l2) -> None:
-    """
-    test if function get_buy_rate will return the ask rate (since its value is lower)
-    instead of the order book rate (even if enabled)
-    """
+def test_order_book_bid_strategy_exception(mocker, default_conf, caplog) -> None:
     patch_exchange(mocker)
     ticker_mock = MagicMock(return_value={'ask': 0.042, 'last': 0.046})
     mocker.patch.multiple(
         'freqtrade.exchange.Exchange',
-        get_order_book=order_book_l2,
+        fetch_l2_order_book=MagicMock(return_value={'bids': [[]], 'asks': [[]]}),
         fetch_ticker=ticker_mock,
 
     )
     default_conf['exchange']['name'] = 'binance'
     default_conf['bid_strategy']['use_order_book'] = True
-    default_conf['bid_strategy']['order_book_top'] = 2
+    default_conf['bid_strategy']['order_book_top'] = 1
     default_conf['bid_strategy']['ask_last_balance'] = 0
     default_conf['telegram']['enabled'] = False
 
     freqtrade = FreqtradeBot(default_conf)
     # orderbook shall be used even if tickers would be lower.
-    assert freqtrade.get_buy_rate('ETH/BTC', True) != 0.042
-    assert ticker_mock.call_count == 0
+    with pytest.raises(PricingError):
+        freqtrade.get_buy_rate('ETH/BTC', refresh=True)
+    assert log_has_re(r'Buy Price from orderbook could not be determined.', caplog)
 
 
 def test_check_depth_of_market_buy(default_conf, mocker, order_book_l2) -> None:
@@ -3599,7 +3896,7 @@ def test_check_depth_of_market_buy(default_conf, mocker, order_book_l2) -> None:
     patch_exchange(mocker)
     mocker.patch.multiple(
         'freqtrade.exchange.Exchange',
-        get_order_book=order_book_l2
+        fetch_l2_order_book=order_book_l2
     )
     default_conf['telegram']['enabled'] = False
     default_conf['exchange']['name'] = 'binance'
@@ -3613,11 +3910,11 @@ def test_check_depth_of_market_buy(default_conf, mocker, order_book_l2) -> None:
 
 
 def test_order_book_ask_strategy(default_conf, limit_buy_order, limit_sell_order,
-                                 fee, mocker, order_book_l2) -> None:
+                                 fee, mocker, order_book_l2, caplog) -> None:
     """
     test order book ask strategy
     """
-    mocker.patch('freqtrade.exchange.Exchange.get_order_book', order_book_l2)
+    mocker.patch('freqtrade.exchange.Exchange.fetch_l2_order_book', order_book_l2)
     default_conf['exchange']['name'] = 'binance'
     default_conf['ask_strategy']['use_order_book'] = True
     default_conf['ask_strategy']['order_book_min'] = 1
@@ -3651,6 +3948,13 @@ def test_order_book_ask_strategy(default_conf, limit_buy_order, limit_sell_order
 
     patch_get_signal(freqtrade, value=(False, True))
     assert freqtrade.handle_trade(trade) is True
+    assert trade.close_rate_requested == order_book_l2.return_value['asks'][0][0]
+
+    mocker.patch('freqtrade.exchange.Exchange.fetch_l2_order_book',
+                 return_value={'bids': [[]], 'asks': [[]]})
+    with pytest.raises(PricingError):
+        freqtrade.handle_trade(trade)
+    assert log_has('Sell Price at location 1 from orderbook could not be determined.', caplog)
 
 
 @pytest.mark.parametrize('side,ask,bid,expected', [
@@ -3691,9 +3995,8 @@ def test_get_sell_rate_orderbook(default_conf, mocker, caplog, side, expected, o
     default_conf['ask_strategy']['use_order_book'] = True
     default_conf['ask_strategy']['order_book_min'] = 1
     default_conf['ask_strategy']['order_book_max'] = 2
-    # TODO: min/max is irrelevant for this test until refactoring
     pair = "ETH/BTC"
-    mocker.patch('freqtrade.exchange.Exchange.get_order_book', order_book_l2)
+    mocker.patch('freqtrade.exchange.Exchange.fetch_l2_order_book', order_book_l2)
     ft = get_patched_freqtradebot(mocker, default_conf)
     rate = ft.get_sell_rate(pair, True)
     assert not log_has("Using cached sell rate for ETH/BTC.", caplog)
@@ -3704,6 +4007,44 @@ def test_get_sell_rate_orderbook(default_conf, mocker, caplog, side, expected, o
     assert log_has("Using cached sell rate for ETH/BTC.", caplog)
 
 
+def test_get_sell_rate_orderbook_exception(default_conf, mocker, caplog):
+    # Test orderbook mode
+    default_conf['ask_strategy']['price_side'] = 'ask'
+    default_conf['ask_strategy']['use_order_book'] = True
+    default_conf['ask_strategy']['order_book_min'] = 1
+    default_conf['ask_strategy']['order_book_max'] = 2
+    pair = "ETH/BTC"
+    # Test What happens if the exchange returns an empty orderbook.
+    mocker.patch('freqtrade.exchange.Exchange.fetch_l2_order_book',
+                 return_value={'bids': [[]], 'asks': [[]]})
+    ft = get_patched_freqtradebot(mocker, default_conf)
+    with pytest.raises(PricingError):
+        ft.get_sell_rate(pair, True)
+    assert log_has("Sell Price at location from orderbook could not be determined.", caplog)
+
+
+def test_get_sell_rate_exception(default_conf, mocker, caplog):
+    # Ticker on one side can be empty in certain circumstances.
+    default_conf['ask_strategy']['price_side'] = 'ask'
+    pair = "ETH/BTC"
+    mocker.patch('freqtrade.exchange.Exchange.fetch_ticker',
+                 return_value={'ask': None, 'bid': 0.12})
+    ft = get_patched_freqtradebot(mocker, default_conf)
+    with pytest.raises(PricingError, match=r"Sell-Rate for ETH/BTC was empty."):
+        ft.get_sell_rate(pair, True)
+
+    ft.config['ask_strategy']['price_side'] = 'bid'
+    assert ft.get_sell_rate(pair, True) == 0.12
+    # Reverse sides
+    mocker.patch('freqtrade.exchange.Exchange.fetch_ticker',
+                 return_value={'ask': 0.13, 'bid': None})
+    with pytest.raises(PricingError, match=r"Sell-Rate for ETH/BTC was empty."):
+        ft.get_sell_rate(pair, True)
+
+    ft.config['ask_strategy']['price_side'] = 'ask'
+    assert ft.get_sell_rate(pair, True) == 0.13
+
+
 def test_startup_state(default_conf, mocker):
     default_conf['pairlist'] = {'method': 'VolumePairList',
                                 'config': {'number_assets': 20}
@@ -3760,3 +4101,36 @@ def test_sync_wallet_dry_run(mocker, default_conf, ticker, fee, limit_buy_order,
     assert log_has_re(r"Unable to create trade for XRP/BTC: "
                       r"Available balance \(0.0 BTC\) is lower than stake amount \(0.001 BTC\)",
                       caplog)
+
+
+@pytest.mark.usefixtures("init_persistence")
+def test_cancel_all_open_orders(mocker, default_conf, fee, limit_buy_order, limit_sell_order):
+    default_conf['cancel_open_orders_on_exit'] = True
+    mocker.patch('freqtrade.exchange.Exchange.fetch_order',
+                 side_effect=[DependencyException(), limit_sell_order, limit_buy_order])
+    buy_mock = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_cancel_buy')
+    sell_mock = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.handle_cancel_sell')
+
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
+    create_mock_trades(fee)
+    trades = Trade.query.all()
+    assert len(trades) == 4
+    freqtrade.cancel_all_open_orders()
+    assert buy_mock.call_count == 1
+    assert sell_mock.call_count == 1
+
+
+@pytest.mark.usefixtures("init_persistence")
+def test_check_for_open_trades(mocker, default_conf, fee, limit_buy_order, limit_sell_order):
+    freqtrade = get_patched_freqtradebot(mocker, default_conf)
+
+    freqtrade.check_for_open_trades()
+    assert freqtrade.rpc.send_msg.call_count == 0
+
+    create_mock_trades(fee)
+    trade = Trade.query.first()
+    trade.is_open = True
+
+    freqtrade.check_for_open_trades()
+    assert freqtrade.rpc.send_msg.call_count == 1
+    assert 'Handle these trades manually' in freqtrade.rpc.send_msg.call_args[0][0]['status']
diff --git a/tests/test_integration.py b/tests/test_integration.py
index c40da7e9d..9695977ac 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -44,6 +44,8 @@ def test_may_execute_sell_stoploss_on_exchange_multi(default_conf, ticker, fee,
     }
     stoploss_order_closed = stoploss_order_open.copy()
     stoploss_order_closed['status'] = 'closed'
+    stoploss_order_closed['filled'] = stoploss_order_closed['amount']
+
     # Sell first trade based on stoploss, keep 2nd and 3rd trade open
     stoploss_order_mock = MagicMock(
         side_effect=[stoploss_order_closed, stoploss_order_open, stoploss_order_open])
@@ -60,14 +62,13 @@ def test_may_execute_sell_stoploss_on_exchange_multi(default_conf, ticker, fee,
         get_fee=fee,
         amount_to_precision=lambda s, x, y: y,
         price_to_precision=lambda s, x, y: y,
-        get_order=stoploss_order_mock,
-        cancel_order=cancel_order_mock,
+        fetch_stoploss_order=stoploss_order_mock,
+        cancel_stoploss_order=cancel_order_mock,
     )
 
     mocker.patch.multiple(
         'freqtrade.freqtradebot.FreqtradeBot',
         create_stoploss_order=MagicMock(return_value=True),
-        update_trade_state=MagicMock(),
         _notify_sell=MagicMock(),
     )
     mocker.patch("freqtrade.strategy.interface.IStrategy.should_sell", should_sell_mock)
@@ -78,10 +79,15 @@ def test_may_execute_sell_stoploss_on_exchange_multi(default_conf, ticker, fee,
     freqtrade.strategy.order_types['stoploss_on_exchange'] = True
     # Switch ordertype to market to close trade immediately
     freqtrade.strategy.order_types['sell'] = 'market'
+    freqtrade.strategy.confirm_trade_entry = MagicMock(return_value=True)
+    freqtrade.strategy.confirm_trade_exit = MagicMock(return_value=True)
     patch_get_signal(freqtrade)
 
     # Create some test data
     freqtrade.enter_positions()
+    assert freqtrade.strategy.confirm_trade_entry.call_count == 3
+    freqtrade.strategy.confirm_trade_entry.reset_mock()
+    assert freqtrade.strategy.confirm_trade_exit.call_count == 0
     wallets_mock.reset_mock()
     Trade.session = MagicMock()
 
@@ -94,11 +100,15 @@ def test_may_execute_sell_stoploss_on_exchange_multi(default_conf, ticker, fee,
     n = freqtrade.exit_positions(trades)
     assert n == 2
     assert should_sell_mock.call_count == 2
+    assert freqtrade.strategy.confirm_trade_entry.call_count == 0
+    assert freqtrade.strategy.confirm_trade_exit.call_count == 1
+    freqtrade.strategy.confirm_trade_exit.reset_mock()
 
     # Only order for 3rd trade needs to be cancelled
     assert cancel_order_mock.call_count == 1
-    # Wallets must be updated between stoploss cancellation and selling.
-    assert wallets_mock.call_count == 2
+    # Wallets must be updated between stoploss cancellation and selling, and will be updated again
+    # during update_trade_state
+    assert wallets_mock.call_count == 4
 
     trade = trades[0]
     assert trade.sell_reason == SellType.STOPLOSS_ON_EXCHANGE.value
@@ -144,7 +154,6 @@ def test_forcebuy_last_unlimited(default_conf, ticker, fee, limit_buy_order, moc
     mocker.patch.multiple(
         'freqtrade.freqtradebot.FreqtradeBot',
         create_stoploss_order=MagicMock(return_value=True),
-        update_trade_state=MagicMock(),
         _notify_sell=MagicMock(),
     )
     should_sell_mock = MagicMock(side_effect=[
diff --git a/tests/test_main.py b/tests/test_main.py
index 70b784002..d5309ae3f 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -35,12 +35,12 @@ def test_parse_args_backtesting(mocker) -> None:
         main(['backtesting'])
     assert backtesting_mock.call_count == 1
     call_args = backtesting_mock.call_args[0][0]
-    assert call_args["config"] == ['config.json']
-    assert call_args["verbosity"] == 0
-    assert call_args["command"] == 'backtesting'
-    assert call_args["func"] is not None
-    assert callable(call_args["func"])
-    assert call_args["ticker_interval"] is None
+    assert call_args['config'] == ['config.json']
+    assert call_args['verbosity'] == 0
+    assert call_args['command'] == 'backtesting'
+    assert call_args['func'] is not None
+    assert callable(call_args['func'])
+    assert call_args['timeframe'] is None
 
 
 def test_main_start_hyperopt(mocker) -> None:
@@ -115,12 +115,38 @@ def test_main_operational_exception(mocker, default_conf, caplog) -> None:
     assert log_has('Oh snap!', caplog)
 
 
-def test_main_reload_conf(mocker, default_conf, caplog) -> None:
+def test_main_operational_exception1(mocker, default_conf, caplog) -> None:
+    patch_exchange(mocker)
+    mocker.patch(
+        'freqtrade.commands.list_commands.available_exchanges',
+        MagicMock(side_effect=ValueError('Oh snap!'))
+    )
+    patched_configuration_load_config_file(mocker, default_conf)
+
+    args = ['list-exchanges']
+
+    # Test Main + the KeyboardInterrupt exception
+    with pytest.raises(SystemExit):
+        main(args)
+
+    assert log_has('Fatal exception!', caplog)
+    assert not log_has_re(r'SIGINT.*', caplog)
+    mocker.patch(
+        'freqtrade.commands.list_commands.available_exchanges',
+        MagicMock(side_effect=KeyboardInterrupt)
+    )
+    with pytest.raises(SystemExit):
+        main(args)
+
+    assert log_has_re(r'SIGINT.*', caplog)
+
+
+def test_main_reload_config(mocker, default_conf, caplog) -> None:
     patch_exchange(mocker)
     mocker.patch('freqtrade.freqtradebot.FreqtradeBot.cleanup', MagicMock())
     # Simulate Running, reload, running workflow
     worker_mock = MagicMock(side_effect=[State.RUNNING,
-                                         State.RELOAD_CONF,
+                                         State.RELOAD_CONFIG,
                                          State.RUNNING,
                                          OperationalException("Oh snap!")])
     mocker.patch('freqtrade.worker.Worker._worker', worker_mock)
diff --git a/tests/test_misc.py b/tests/test_misc.py
index 832bcb3a9..a185cbba4 100644
--- a/tests/test_misc.py
+++ b/tests/test_misc.py
@@ -9,7 +9,9 @@ import pytest
 from freqtrade.data.converter import ohlcv_to_dataframe
 from freqtrade.misc import (datesarray_to_datetimearray, file_dump_json,
                             file_load_json, format_ms_time, pair_to_filename,
-                            plural, safe_value_fallback, shorten_date)
+                            plural, render_template,
+                            render_template_with_fallback, safe_value_fallback,
+                            safe_value_fallback2, shorten_date)
 
 
 def test_shorten_date() -> None:
@@ -94,24 +96,40 @@ def test_format_ms_time() -> None:
 
 
 def test_safe_value_fallback():
+    dict1 = {'keya': None, 'keyb': 2, 'keyc': 5, 'keyd': None}
+    assert safe_value_fallback(dict1, 'keya', 'keyb') == 2
+    assert safe_value_fallback(dict1, 'keyb', 'keya') == 2
+
+    assert safe_value_fallback(dict1, 'keyb', 'keyc') == 2
+    assert safe_value_fallback(dict1, 'keya', 'keyc') == 5
+
+    assert safe_value_fallback(dict1, 'keyc', 'keyb') == 5
+
+    assert safe_value_fallback(dict1, 'keya', 'keyd') is None
+
+    assert safe_value_fallback(dict1, 'keyNo', 'keyNo') is None
+    assert safe_value_fallback(dict1, 'keyNo', 'keyNo', 55) == 55
+
+
+def test_safe_value_fallback2():
     dict1 = {'keya': None, 'keyb': 2, 'keyc': 5, 'keyd': None}
     dict2 = {'keya': 20, 'keyb': None, 'keyc': 6, 'keyd': None}
-    assert safe_value_fallback(dict1, dict2, 'keya', 'keya') == 20
-    assert safe_value_fallback(dict2, dict1, 'keya', 'keya') == 20
+    assert safe_value_fallback2(dict1, dict2, 'keya', 'keya') == 20
+    assert safe_value_fallback2(dict2, dict1, 'keya', 'keya') == 20
 
-    assert safe_value_fallback(dict1, dict2, 'keyb', 'keyb') == 2
-    assert safe_value_fallback(dict2, dict1, 'keyb', 'keyb') == 2
+    assert safe_value_fallback2(dict1, dict2, 'keyb', 'keyb') == 2
+    assert safe_value_fallback2(dict2, dict1, 'keyb', 'keyb') == 2
 
-    assert safe_value_fallback(dict1, dict2, 'keyc', 'keyc') == 5
-    assert safe_value_fallback(dict2, dict1, 'keyc', 'keyc') == 6
+    assert safe_value_fallback2(dict1, dict2, 'keyc', 'keyc') == 5
+    assert safe_value_fallback2(dict2, dict1, 'keyc', 'keyc') == 6
 
-    assert safe_value_fallback(dict1, dict2, 'keyd', 'keyd') is None
-    assert safe_value_fallback(dict2, dict1, 'keyd', 'keyd') is None
-    assert safe_value_fallback(dict2, dict1, 'keyd', 'keyd', 1234) == 1234
+    assert safe_value_fallback2(dict1, dict2, 'keyd', 'keyd') is None
+    assert safe_value_fallback2(dict2, dict1, 'keyd', 'keyd') is None
+    assert safe_value_fallback2(dict2, dict1, 'keyd', 'keyd', 1234) == 1234
 
-    assert safe_value_fallback(dict1, dict2, 'keyNo', 'keyNo') is None
-    assert safe_value_fallback(dict2, dict1, 'keyNo', 'keyNo') is None
-    assert safe_value_fallback(dict2, dict1, 'keyNo', 'keyNo', 1234) == 1234
+    assert safe_value_fallback2(dict1, dict2, 'keyNo', 'keyNo') is None
+    assert safe_value_fallback2(dict2, dict1, 'keyNo', 'keyNo') is None
+    assert safe_value_fallback2(dict2, dict1, 'keyNo', 'keyNo', 1234) == 1234
 
 
 def test_plural() -> None:
@@ -144,3 +162,17 @@ def test_plural() -> None:
     assert plural(1.5, "ox", "oxen") == "oxen"
     assert plural(-0.5, "ox", "oxen") == "oxen"
     assert plural(-1.5, "ox", "oxen") == "oxen"
+
+
+def test_render_template_fallback(mocker):
+    from jinja2.exceptions import TemplateNotFound
+    with pytest.raises(TemplateNotFound):
+        val = render_template(
+            templatefile='subtemplates/indicators_does-not-exist.j2',)
+
+    val = render_template_with_fallback(
+        templatefile='subtemplates/indicators_does-not-exist.j2',
+        templatefallbackfile='subtemplates/indicators_minimal.j2',
+    )
+    assert isinstance(val, str)
+    assert 'if self.dp' in val
diff --git a/tests/test_persistence.py b/tests/test_persistence.py
index ceac24356..65c83e05b 100644
--- a/tests/test_persistence.py
+++ b/tests/test_persistence.py
@@ -298,7 +298,7 @@ def test_calc_profit(limit_buy_order, limit_sell_order, fee):
         fee_close=fee.return_value,
         exchange='bittrex',
     )
-    trade.open_order_id = 'profit_percent'
+    trade.open_order_id = 'something'
     trade.update(limit_buy_order)  # Buy @ 0.00001099
 
     # Custom closing rate and regular fee rate
@@ -332,7 +332,7 @@ def test_calc_profit_ratio(limit_buy_order, limit_sell_order, fee):
         fee_close=fee.return_value,
         exchange='bittrex',
     )
-    trade.open_order_id = 'profit_percent'
+    trade.open_order_id = 'something'
     trade.update(limit_buy_order)  # Buy @ 0.00001099
 
     # Get percent of profit with a custom rate (Higher than open rate)
@@ -457,6 +457,7 @@ def test_migrate_old(mocker, default_conf, fee):
     assert trade.close_rate_requested is None
     assert trade.is_open == 1
     assert trade.amount == amount
+    assert trade.amount_requested == amount
     assert trade.stake_amount == default_conf.get("stake_amount")
     assert trade.pair == "ETC/BTC"
     assert trade.exchange == "bittrex"
@@ -465,6 +466,11 @@ def test_migrate_old(mocker, default_conf, fee):
     assert trade.initial_stop_loss == 0.0
     assert trade.open_trade_price == trade._calc_open_trade_price()
     assert trade.close_profit_abs is None
+    assert trade.fee_open_cost is None
+    assert trade.fee_open_currency is None
+    assert trade.fee_close_cost is None
+    assert trade.fee_close_currency is None
+    assert trade.timeframe is None
 
     trade = Trade.query.filter(Trade.id == 2).first()
     assert trade.close_rate is not None
@@ -473,6 +479,7 @@ def test_migrate_old(mocker, default_conf, fee):
     assert trade.close_rate_requested is None
     assert trade.close_rate is not None
     assert pytest.approx(trade.close_profit_abs) == trade.calc_profit()
+    assert trade.sell_order_status is None
 
 
 def test_migrate_new(mocker, default_conf, fee, caplog):
@@ -507,11 +514,11 @@ def test_migrate_new(mocker, default_conf, fee, caplog):
                                 );"""
     insert_table_old = """INSERT INTO trades (exchange, pair, is_open, fee,
                           open_rate, stake_amount, amount, open_date,
-                          stop_loss, initial_stop_loss, max_rate)
+                          stop_loss, initial_stop_loss, max_rate, ticker_interval)
                           VALUES ('binance', 'ETC/BTC', 1, {fee},
                           0.00258580, {stake}, {amount},
                           '2019-11-28 12:44:24.000000',
-                          0.0, 0.0, 0.0)
+                          0.0, 0.0, 0.0, '5m')
                           """.format(fee=fee.return_value,
                                      stake=default_conf.get("stake_amount"),
                                      amount=amount
@@ -540,6 +547,7 @@ def test_migrate_new(mocker, default_conf, fee, caplog):
     assert trade.close_rate_requested is None
     assert trade.is_open == 1
     assert trade.amount == amount
+    assert trade.amount_requested == amount
     assert trade.stake_amount == default_conf.get("stake_amount")
     assert trade.pair == "ETC/BTC"
     assert trade.exchange == "binance"
@@ -549,7 +557,7 @@ def test_migrate_new(mocker, default_conf, fee, caplog):
     assert trade.initial_stop_loss == 0.0
     assert trade.sell_reason is None
     assert trade.strategy is None
-    assert trade.ticker_interval is None
+    assert trade.timeframe == '5m'
     assert trade.stoploss_order_id is None
     assert trade.stoploss_last_update is None
     assert log_has("trying trades_bak1", caplog)
@@ -719,6 +727,7 @@ def test_to_json(default_conf, fee):
         pair='ETH/BTC',
         stake_amount=0.001,
         amount=123.0,
+        amount_requested=123.0,
         fee_open=fee.return_value,
         fee_close=fee.return_value,
         open_date=arrow.utcnow().shift(hours=-2).datetime,
@@ -734,34 +743,54 @@ def test_to_json(default_conf, fee):
                       'is_open': None,
                       'open_date_hum': '2 hours ago',
                       'open_date': trade.open_date.strftime("%Y-%m-%d %H:%M:%S"),
+                      'open_timestamp': int(trade.open_date.timestamp() * 1000),
                       'open_order_id': 'dry_run_buy_12345',
                       'close_date_hum': None,
                       'close_date': None,
+                      'close_timestamp': None,
                       'open_rate': 0.123,
                       'open_rate_requested': None,
                       'open_trade_price': 15.1668225,
                       'fee_close': 0.0025,
+                      'fee_close_cost': None,
+                      'fee_close_currency': None,
                       'fee_open': 0.0025,
+                      'fee_open_cost': None,
+                      'fee_open_currency': None,
                       'close_rate': None,
                       'close_rate_requested': None,
                       'amount': 123.0,
+                      'amount_requested': 123.0,
                       'stake_amount': 0.001,
                       'close_profit': None,
+                      'close_profit_abs': None,
                       'sell_reason': None,
+                      'sell_order_status': None,
                       'stop_loss': None,
+                      'stop_loss_abs': None,
+                      'stop_loss_ratio': None,
                       'stop_loss_pct': None,
+                      'stoploss_order_id': None,
+                      'stoploss_last_update': None,
+                      'stoploss_last_update_timestamp': None,
                       'initial_stop_loss': None,
+                      'initial_stop_loss_abs': None,
                       'initial_stop_loss_pct': None,
+                      'initial_stop_loss_ratio': None,
                       'min_rate': None,
                       'max_rate': None,
                       'strategy': None,
-                      'ticker_interval': None}
+                      'ticker_interval': None,
+                      'timeframe': None,
+                      'exchange': 'bittrex',
+                      }
 
     # Simulate dry_run entries
     trade = Trade(
         pair='XRP/BTC',
         stake_amount=0.001,
         amount=100.0,
+        amount_requested=101.0,
         fee_open=fee.return_value,
         fee_close=fee.return_value,
         open_date=arrow.utcnow().shift(hours=-2).datetime,
@@ -777,20 +806,35 @@ def test_to_json(default_conf, fee):
                       'pair': 'XRP/BTC',
                       'open_date_hum': '2 hours ago',
                       'open_date': trade.open_date.strftime("%Y-%m-%d %H:%M:%S"),
+                      'open_timestamp': int(trade.open_date.timestamp() * 1000),
                       'close_date_hum': 'an hour ago',
                       'close_date': trade.close_date.strftime("%Y-%m-%d %H:%M:%S"),
+                      'close_timestamp': int(trade.close_date.timestamp() * 1000),
                       'open_rate': 0.123,
                       'close_rate': 0.125,
                       'amount': 100.0,
+                      'amount_requested': 101.0,
                       'stake_amount': 0.001,
                       'stop_loss': None,
+                      'stop_loss_abs': None,
                       'stop_loss_pct': None,
+                      'stop_loss_ratio': None,
+                      'stoploss_order_id': None,
+                      'stoploss_last_update': None,
+                      'stoploss_last_update_timestamp': None,
                       'initial_stop_loss': None,
+                      'initial_stop_loss_abs': None,
                       'initial_stop_loss_pct': None,
+                      'initial_stop_loss_ratio': None,
                       'close_profit': None,
+                      'close_profit_abs': None,
                       'close_rate_requested': None,
                       'fee_close': 0.0025,
+                      'fee_close_cost': None,
+                      'fee_close_currency': None,
                       'fee_open': 0.0025,
+                      'fee_open_cost': None,
+                      'fee_open_currency': None,
                       'is_open': None,
                       'max_rate': None,
                       'min_rate': None,
@@ -798,8 +842,12 @@ def test_to_json(default_conf, fee):
                       'open_rate_requested': None,
                       'open_trade_price': 12.33075,
                       'sell_reason': None,
+                      'sell_order_status': None,
                       'strategy': None,
-                      'ticker_interval': None}
+                      'ticker_interval': None,
+                      'timeframe': None,
+                      'exchange': 'bittrex',
+                      }
 
 
 def test_stoploss_reinitialization(default_conf, fee):
@@ -862,6 +910,75 @@ def test_stoploss_reinitialization(default_conf, fee):
     assert trade_adj.initial_stop_loss_pct == -0.04
 
 
+def test_update_fee(fee):
+    trade = Trade(
+        pair='ETH/BTC',
+        stake_amount=0.001,
+        fee_open=fee.return_value,
+        open_date=arrow.utcnow().shift(hours=-2).datetime,
+        amount=10,
+        fee_close=fee.return_value,
+        exchange='bittrex',
+        open_rate=1,
+        max_rate=1,
+    )
+    fee_cost = 0.15
+    fee_currency = 'BTC'
+    fee_rate = 0.0075
+    assert trade.fee_open_currency is None
+    assert not trade.fee_updated('buy')
+    assert not trade.fee_updated('sell')
+
+    trade.update_fee(fee_cost, fee_currency, fee_rate, 'buy')
+    assert trade.fee_updated('buy')
+    assert not trade.fee_updated('sell')
+    assert trade.fee_open_currency == fee_currency
+    assert trade.fee_open_cost == fee_cost
+    assert trade.fee_open == fee_rate
+    # Setting buy rate should "guess" close rate
+    assert trade.fee_close == fee_rate
+    assert trade.fee_close_currency is None
+    assert trade.fee_close_cost is None
+
+    fee_rate = 0.0076
+    trade.update_fee(fee_cost, fee_currency, fee_rate, 'sell')
+    assert trade.fee_updated('buy')
+    assert trade.fee_updated('sell')
+    assert trade.fee_close == 0.0076
+    assert trade.fee_close_cost == fee_cost
+    assert trade.fee_close == fee_rate
+
+
+def test_fee_updated(fee):
+    trade = Trade(
+        pair='ETH/BTC',
+        stake_amount=0.001,
+        fee_open=fee.return_value,
+        open_date=arrow.utcnow().shift(hours=-2).datetime,
+        amount=10,
+        fee_close=fee.return_value,
+        exchange='bittrex',
+        open_rate=1,
+        max_rate=1,
+    )
+
+    assert trade.fee_open_currency is None
+    assert not trade.fee_updated('buy')
+    assert not trade.fee_updated('sell')
+    assert not trade.fee_updated('asdf')
+
+    trade.update_fee(0.15, 'BTC', 0.0075, 'buy')
+    assert trade.fee_updated('buy')
+    assert not trade.fee_updated('sell')
+    assert trade.fee_open_currency is not None
+    assert trade.fee_close_currency is None
+
+    trade.update_fee(0.15, 'ABC', 0.0075, 'sell')
+    assert trade.fee_updated('buy')
+    assert trade.fee_updated('sell')
+    assert not trade.fee_updated('asfd')
+
+
 @pytest.mark.usefixtures("init_persistence")
 def test_total_open_trades_stakes(fee):
 
@@ -878,7 +995,7 @@ def test_get_overall_performance(fee):
     create_mock_trades(fee)
     res = Trade.get_overall_performance()
 
-    assert len(res) == 1
+    assert len(res) == 2
     assert 'pair' in res[0]
     assert 'profit' in res[0]
     assert 'count' in res[0]
@@ -893,5 +1010,5 @@ def test_get_best_pair(fee):
     create_mock_trades(fee)
     res = Trade.get_best_pair()
     assert len(res) == 2
-    assert res[0] == 'ETC/BTC'
-    assert res[1] == 0.005
+    assert res[0] == 'XRP/BTC'
+    assert res[1] == 0.01
diff --git a/tests/test_plotting.py b/tests/test_plotting.py
index 0258b94d1..8f4512c4b 100644
--- a/tests/test_plotting.py
+++ b/tests/test_plotting.py
@@ -21,7 +21,7 @@ from freqtrade.plot.plotting import (add_indicators, add_profit,
                                      load_and_plot_trades, plot_profit,
                                      plot_trades, store_plot_file)
 from freqtrade.resolvers import StrategyResolver
-from tests.conftest import get_args, log_has, log_has_re
+from tests.conftest import get_args, log_has, log_has_re, patch_exchange
 
 
 def fig_generating_mock(fig, *args, **kwargs):
@@ -47,7 +47,7 @@ def generate_empty_figure():
 def test_init_plotscript(default_conf, mocker, testdatadir):
     default_conf['timerange'] = "20180110-20180112"
     default_conf['trade_source'] = "file"
-    default_conf['ticker_interval'] = "5m"
+    default_conf['timeframe'] = "5m"
     default_conf["datadir"] = testdatadir
     default_conf['exportfilename'] = testdatadir / "backtest-result_test.json"
     ret = init_plotscript(default_conf)
@@ -124,7 +124,7 @@ def test_plot_trades(testdatadir, caplog):
     trade_sell = find_trace_in_fig_data(figure.data, 'Sell - Profit')
     assert isinstance(trade_sell, go.Scatter)
     assert trade_sell.yaxis == 'y'
-    assert len(trades.loc[trades['profitperc'] > 0]) == len(trade_sell.x)
+    assert len(trades.loc[trades['profit_percent'] > 0]) == len(trade_sell.x)
     assert trade_sell.marker.color == 'green'
     assert trade_sell.marker.symbol == 'square-open'
     assert trade_sell.text[0] == '4.0%, roi, 15 min'
@@ -132,7 +132,7 @@ def test_plot_trades(testdatadir, caplog):
     trade_sell_loss = find_trace_in_fig_data(figure.data, 'Sell - Loss')
     assert isinstance(trade_sell_loss, go.Scatter)
     assert trade_sell_loss.yaxis == 'y'
-    assert len(trades.loc[trades['profitperc'] <= 0]) == len(trade_sell_loss.x)
+    assert len(trades.loc[trades['profit_percent'] <= 0]) == len(trade_sell_loss.x)
     assert trade_sell_loss.marker.color == 'red'
     assert trade_sell_loss.marker.symbol == 'square-open'
     assert trade_sell_loss.text[5] == '-10.4%, stop_loss, 720 min'
@@ -316,6 +316,8 @@ def test_start_plot_dataframe(mocker):
 
 
 def test_load_and_plot_trades(default_conf, mocker, caplog, testdatadir):
+    patch_exchange(mocker)
+
     default_conf['trade_source'] = 'file'
     default_conf["datadir"] = testdatadir
     default_conf['exportfilename'] = testdatadir / "backtest-result_test.json"
@@ -374,7 +376,7 @@ def test_start_plot_profit_error(mocker):
 def test_plot_profit(default_conf, mocker, testdatadir, caplog):
     default_conf['trade_source'] = 'file'
     default_conf["datadir"] = testdatadir
-    default_conf['exportfilename'] = testdatadir / "backtest-result_test.json"
+    default_conf['exportfilename'] = testdatadir / "backtest-result_test_nofile.json"
     default_conf['pairs'] = ["ETH/BTC", "LTC/BTC"]
 
     profit_mock = MagicMock()
@@ -384,6 +386,12 @@ def test_plot_profit(default_conf, mocker, testdatadir, caplog):
         generate_profit_graph=profit_mock,
         store_plot_file=store_mock
     )
+    with pytest.raises(OperationalException,
+                       match=r"No trades found, cannot generate Profit-plot.*"):
+        plot_profit(default_conf)
+
+    default_conf['exportfilename'] = testdatadir / "backtest-result_test.json"
+
     plot_profit(default_conf)
 
     # Plot-profit generates one combined plot
diff --git a/tests/testdata/XRP_ETH-trades.json.gz b/tests/testdata/XRP_ETH-trades.json.gz
index 69b92cac8..dad822005 100644
Binary files a/tests/testdata/XRP_ETH-trades.json.gz and b/tests/testdata/XRP_ETH-trades.json.gz differ
diff --git a/tests/testdata/XRP_OLD-trades.json.gz b/tests/testdata/XRP_OLD-trades.json.gz
new file mode 100644
index 000000000..69b92cac8
Binary files /dev/null and b/tests/testdata/XRP_OLD-trades.json.gz differ
diff --git a/tests/testdata/backtest-result_test copy.json b/tests/testdata/backtest-result_test copy.json
new file mode 100644
index 000000000..0395830d4
--- /dev/null
+++ b/tests/testdata/backtest-result_test copy.json	
@@ -0,0 +1,7 @@
+{
+    "ASDF": {,
+        "trades": [],
+        "metrics":[],
+    }
+}
+[["TRX/BTC",0.03990025,1515568500.0,1515568800.0,27,5,9.64e-05,0.00010074887218045112,false,"roi"],["ADA/BTC",0.03990025,1515568500.0,1515569400.0,27,15,4.756e-05,4.9705563909774425e-05,false,"roi"],["XLM/BTC",0.03990025,1515569100.0,1515569700.0,29,10,3.339e-05,3.489631578947368e-05,false,"roi"],["TRX/BTC",0.03990025,1515569100.0,1515570000.0,29,15,9.696e-05,0.00010133413533834584,false,"roi"],["ETH/BTC",-0.0,1515569700.0,1515573300.0,31,60,0.0943,0.09477268170426063,false,"roi"],["XMR/BTC",0.00997506,1515570000.0,1515571800.0,32,30,0.02719607,0.02760503345864661,false,"roi"],["ZEC/BTC",0.0,1515572100.0,1515578100.0,39,100,0.04634952,0.046581848421052625,false,"roi"],["NXT/BTC",-0.0,1515595500.0,1515599400.0,117,65,3.066e-05,3.081368421052631e-05,false,"roi"],["LTC/BTC",0.0,1515602100.0,1515604500.0,139,40,0.0168999,0.016984611278195488,false,"roi"],["ETH/BTC",-0.0,1515602400.0,1515604800.0,140,40,0.09132568,0.0917834528320802,false,"roi"],["ETH/BTC",-0.0,1515610200.0,1515613500.0,166,55,0.08898003,0.08942604518796991,false,"roi"],["ETH/BTC",0.0,1515622500.0,1515625200.0,207,45,0.08560008,0.08602915308270676,false,"roi"],["ETC/BTC",0.00997506,1515624600.0,1515626400.0,214,30,0.00249083,0.0025282860902255634,false,"roi"],["NXT/BTC",-0.0,1515626100.0,1515629700.0,219,60,3.022e-05,3.037147869674185e-05,false,"roi"],["ETC/BTC",0.01995012,1515627600.0,1515629100.0,224,25,0.002437,0.0024980776942355883,false,"roi"],["ZEC/BTC",0.00997506,1515628800.0,1515630900.0,228,35,0.04771803,0.04843559436090225,false,"roi"],["XLM/BTC",-0.10448878,1515642000.0,1515644700.0,272,45,3.651e-05,3.2859000000000005e-05,false,"stop_loss"],["ETH/BTC",0.00997506,1515642900.0,1515644700.0,275,30,0.08824105,0.08956798308270676,false,"roi"],["ETC/BTC",-0.0,1515643200.0,1515646200.0,276,50,0.00243,0.002442180451127819,false,"roi"],["ZEC/BTC",0.01995012,1515645000.0,1515646500.0,282,25,0.04545064,0.046589753784461146,false,"roi"],["XLM/BTC",0.01995012,1515645000.0,1515646200.0,282,20,3.372e-05,3.456511278195488e-05,false,"roi"],["XMR/BTC",0.01995012,1515646500.0,1515647700.0,287,20,0.02644,0.02710265664160401,false,"roi"],["ETH/BTC",-0.0,1515669600.0,1515672000.0,364,40,0.08812,0.08856170426065162,false,"roi"],["XMR/BTC",-0.0,1515670500.0,1515672900.0,367,40,0.02683577,0.026970285137844607,false,"roi"],["ADA/BTC",0.01995012,1515679200.0,1515680700.0,396,25,4.919e-05,5.04228320802005e-05,false,"roi"],["ETH/BTC",-0.0,1515698700.0,1515702900.0,461,70,0.08784896,0.08828930566416039,false,"roi"],["ADA/BTC",-0.0,1515710100.0,1515713400.0,499,55,5.105e-05,5.130588972431077e-05,false,"roi"],["XLM/BTC",0.00997506,1515711300.0,1515713100.0,503,30,3.96e-05,4.019548872180451e-05,false,"roi"],["NXT/BTC",-0.0,1515711300.0,1515713700.0,503,40,2.885e-05,2.899461152882205e-05,false,"roi"],["XMR/BTC",0.00997506,1515713400.0,1515715500.0,510,35,0.02645,0.026847744360902256,false,"roi"],["ZEC/BTC",-0.0,1515714900.0,1515719700.0,515,80,0.048,0.04824060150375939,false,"roi"],["XLM/BTC",0.01995012,1515791700.0,1515793200.0,771,25,4.692e-05,4.809593984962405e-05,false,"roi"],["ETC/BTC",-0.0,1515804900.0,1515824400.0,815,325,0.00256966,0.0025825405012531327,false,"roi"],["ADA/BTC",0.0,1515840900.0,1515843300.0,935,40,6.262e-05,6.293388471177944e-05,false,"roi"],["XLM/BTC",0.0,1515848700.0,1516025400.0,961,2945,4.73e-05,4.753709273182957e-05,false,"roi"],["ADA/BTC",-0.0,1515850200.0,1515854700.0,966,75,6.063e-05,6.0933909774436085e-05,false,"roi"],["TRX/BTC",-0.0,1515850800.0,1515886200.0,968,590,0.00011082,0.00011137548872180449,false,"roi"],["ADA/BTC",-0.0,1515856500.0,1515858900.0,987,40,5.93e-05,5.9597243107769415e-05,false,"roi"],["ZEC/BTC",-0.0,1515861000.0,1515863400.0,1002,40,0.04850003,0.04874313791979949,false,"roi"],["ETH/BTC",-0.0,1515881100.0,1515911100.0,1069,500,0.09825019,0.09874267215538847,false,"roi"],["ADA/BTC",0.0,1515889200.0,1515970500.0,1096,1355,6.018e-05,6.048165413533834e-05,false,"roi"],["ETH/BTC",-0.0,1515933900.0,1515936300.0,1245,40,0.09758999,0.0980791628822055,false,"roi"],["ETC/BTC",0.00997506,1515943800.0,1515945600.0,1278,30,0.00311,0.0031567669172932328,false,"roi"],["ETC/BTC",-0.0,1515962700.0,1515968100.0,1341,90,0.00312401,0.003139669197994987,false,"roi"],["LTC/BTC",0.0,1515972900.0,1515976200.0,1375,55,0.0174679,0.017555458395989976,false,"roi"],["DASH/BTC",-0.0,1515973500.0,1515975900.0,1377,40,0.07346846,0.07383672295739348,false,"roi"],["ETH/BTC",-0.0,1515983100.0,1515985500.0,1409,40,0.097994,0.09848519799498745,false,"roi"],["ETH/BTC",-0.0,1516000800.0,1516003200.0,1468,40,0.09659,0.09707416040100249,false,"roi"],["TRX/BTC",0.00997506,1516004400.0,1516006500.0,1480,35,9.987e-05,0.00010137180451127818,false,"roi"],["ETH/BTC",0.0,1516018200.0,1516071000.0,1526,880,0.0948969,0.09537257368421052,false,"roi"],["DASH/BTC",-0.0,1516025400.0,1516038000.0,1550,210,0.071,0.07135588972431077,false,"roi"],["ZEC/BTC",-0.0,1516026600.0,1516029000.0,1554,40,0.04600501,0.046235611553884705,false,"roi"],["TRX/BTC",-0.0,1516039800.0,1516044300.0,1598,75,9.438e-05,9.485308270676691e-05,false,"roi"],["XMR/BTC",-0.0,1516041300.0,1516043700.0,1603,40,0.03040001,0.030552391002506264,false,"roi"],["ADA/BTC",-0.10448878,1516047900.0,1516091100.0,1625,720,5.837e-05,5.2533e-05,false,"stop_loss"],["ZEC/BTC",-0.0,1516048800.0,1516053600.0,1628,80,0.046036,0.04626675689223057,false,"roi"],["ETC/BTC",-0.0,1516062600.0,1516065000.0,1674,40,0.0028685,0.0028828784461152877,false,"roi"],["DASH/BTC",0.0,1516065300.0,1516070100.0,1683,80,0.06731755,0.0676549813283208,false,"roi"],["ETH/BTC",0.0,1516088700.0,1516092000.0,1761,55,0.09217614,0.09263817578947368,false,"roi"],["LTC/BTC",0.01995012,1516091700.0,1516092900.0,1771,20,0.0165,0.016913533834586467,false,"roi"],["TRX/BTC",0.03990025,1516091700.0,1516092000.0,1771,5,7.953e-05,8.311781954887218e-05,false,"roi"],["ZEC/BTC",-0.0,1516092300.0,1516096200.0,1773,65,0.045202,0.04542857644110275,false,"roi"],["ADA/BTC",0.00997506,1516094100.0,1516095900.0,1779,30,5.248e-05,5.326917293233082e-05,false,"roi"],["XMR/BTC",0.0,1516094100.0,1516096500.0,1779,40,0.02892318,0.02906815834586466,false,"roi"],["ADA/BTC",0.01995012,1516096200.0,1516097400.0,1786,20,5.158e-05,5.287273182957392e-05,false,"roi"],["ZEC/BTC",0.00997506,1516097100.0,1516099200.0,1789,35,0.04357584,0.044231115789473675,false,"roi"],["XMR/BTC",0.00997506,1516097100.0,1516098900.0,1789,30,0.02828232,0.02870761804511278,false,"roi"],["ADA/BTC",0.00997506,1516110300.0,1516112400.0,1833,35,5.362e-05,5.4426315789473676e-05,false,"roi"],["ADA/BTC",-0.0,1516123800.0,1516127100.0,1878,55,5.302e-05,5.328576441102756e-05,false,"roi"],["ETH/BTC",0.00997506,1516126500.0,1516128300.0,1887,30,0.09129999,0.09267292218045112,false,"roi"],["XLM/BTC",0.01995012,1516126500.0,1516127700.0,1887,20,3.808e-05,3.903438596491228e-05,false,"roi"],["XMR/BTC",0.00997506,1516129200.0,1516131000.0,1896,30,0.02811012,0.028532828571428567,false,"roi"],["ETC/BTC",-0.10448878,1516137900.0,1516141500.0,1925,60,0.00258379,0.002325411,false,"stop_loss"],["NXT/BTC",-0.10448878,1516137900.0,1516142700.0,1925,80,2.559e-05,2.3031e-05,false,"stop_loss"],["TRX/BTC",-0.10448878,1516138500.0,1516141500.0,1927,50,7.62e-05,6.858e-05,false,"stop_loss"],["LTC/BTC",0.03990025,1516141800.0,1516142400.0,1938,10,0.0151,0.015781203007518795,false,"roi"],["ETC/BTC",0.03990025,1516141800.0,1516142100.0,1938,5,0.00229844,0.002402129022556391,false,"roi"],["ETC/BTC",0.03990025,1516142400.0,1516142700.0,1940,5,0.00235676,0.00246308,false,"roi"],["DASH/BTC",0.01995012,1516142700.0,1516143900.0,1941,20,0.0630692,0.06464988170426066,false,"roi"],["NXT/BTC",0.03990025,1516143000.0,1516143300.0,1942,5,2.2e-05,2.2992481203007514e-05,false,"roi"],["ADA/BTC",0.00997506,1516159800.0,1516161600.0,1998,30,4.974e-05,5.048796992481203e-05,false,"roi"],["TRX/BTC",0.01995012,1516161300.0,1516162500.0,2003,20,7.108e-05,7.28614536340852e-05,false,"roi"],["ZEC/BTC",-0.0,1516181700.0,1516184100.0,2071,40,0.04327,0.04348689223057644,false,"roi"],["ADA/BTC",-0.0,1516184400.0,1516208400.0,2080,400,4.997e-05,5.022047619047618e-05,false,"roi"],["DASH/BTC",-0.0,1516185000.0,1516188300.0,2082,55,0.06836818,0.06871087764411027,false,"roi"],["XLM/BTC",-0.0,1516185000.0,1516187400.0,2082,40,3.63e-05,3.648195488721804e-05,false,"roi"],["XMR/BTC",-0.0,1516192200.0,1516226700.0,2106,575,0.0281,0.02824085213032581,false,"roi"],["ETH/BTC",-0.0,1516192500.0,1516208100.0,2107,260,0.08651001,0.08694364413533832,false,"roi"],["ADA/BTC",-0.0,1516251600.0,1516254900.0,2304,55,5.633e-05,5.6612355889724306e-05,false,"roi"],["DASH/BTC",0.00997506,1516252800.0,1516254900.0,2308,35,0.06988494,0.07093584135338346,false,"roi"],["ADA/BTC",-0.0,1516260900.0,1516263300.0,2335,40,5.545e-05,5.572794486215538e-05,false,"roi"],["LTC/BTC",-0.0,1516266000.0,1516268400.0,2352,40,0.01633527,0.016417151052631574,false,"roi"],["ETC/BTC",-0.0,1516293600.0,1516296000.0,2444,40,0.00269734,0.0027108605012531326,false,"roi"],["XLM/BTC",0.01995012,1516298700.0,1516300200.0,2461,25,4.475e-05,4.587155388471177e-05,false,"roi"],["NXT/BTC",0.00997506,1516299900.0,1516301700.0,2465,30,2.79e-05,2.8319548872180444e-05,false,"roi"],["ZEC/BTC",0.0,1516306200.0,1516308600.0,2486,40,0.04439326,0.04461578260651629,false,"roi"],["XLM/BTC",0.0,1516311000.0,1516322100.0,2502,185,4.49e-05,4.51250626566416e-05,false,"roi"],["XMR/BTC",-0.0,1516312500.0,1516338300.0,2507,430,0.02855,0.028693107769423555,false,"roi"],["ADA/BTC",0.0,1516313400.0,1516315800.0,2510,40,5.796e-05,5.8250526315789473e-05,false,"roi"],["ZEC/BTC",0.0,1516319400.0,1516321800.0,2530,40,0.04340323,0.04362079005012531,false,"roi"],["ZEC/BTC",0.0,1516380300.0,1516383300.0,2733,50,0.04454455,0.04476783095238095,false,"roi"],["ADA/BTC",-0.0,1516382100.0,1516391700.0,2739,160,5.62e-05,5.648170426065162e-05,false,"roi"],["XLM/BTC",-0.0,1516382400.0,1516392900.0,2740,175,4.339e-05,4.360749373433584e-05,false,"roi"],["TRX/BTC",0.0,1516423500.0,1516469700.0,2877,770,0.0001009,0.00010140576441102757,false,"roi"],["ETC/BTC",-0.0,1516423800.0,1516461300.0,2878,625,0.00270505,0.002718609147869674,false,"roi"],["XMR/BTC",-0.0,1516423800.0,1516431600.0,2878,130,0.03000002,0.030150396040100245,false,"roi"],["ADA/BTC",-0.0,1516438800.0,1516441200.0,2928,40,5.46e-05,5.4873684210526304e-05,false,"roi"],["XMR/BTC",-0.10448878,1516472700.0,1516852200.0,3041,6325,0.03082222,0.027739998000000002,false,"stop_loss"],["ETH/BTC",-0.0,1516487100.0,1516490100.0,3089,50,0.08969999,0.09014961401002504,false,"roi"],["LTC/BTC",0.0,1516503000.0,1516545000.0,3142,700,0.01632501,0.01640683962406015,false,"roi"],["DASH/BTC",-0.0,1516530000.0,1516532400.0,3232,40,0.070538,0.07089157393483708,false,"roi"],["ADA/BTC",-0.0,1516549800.0,1516560300.0,3298,175,5.301e-05,5.3275714285714276e-05,false,"roi"],["XLM/BTC",0.0,1516551600.0,1516554000.0,3304,40,3.955e-05,3.9748245614035085e-05,false,"roi"],["ETC/BTC",0.00997506,1516569300.0,1516571100.0,3363,30,0.00258505,0.002623922932330827,false,"roi"],["XLM/BTC",-0.0,1516569300.0,1516571700.0,3363,40,3.903e-05,3.922563909774435e-05,false,"roi"],["ADA/BTC",-0.0,1516581300.0,1516617300.0,3403,600,5.236e-05,5.262245614035087e-05,false,"roi"],["TRX/BTC",0.0,1516584600.0,1516587000.0,3414,40,9.028e-05,9.073253132832079e-05,false,"roi"],["ETC/BTC",-0.0,1516623900.0,1516631700.0,3545,130,0.002687,0.002700468671679198,false,"roi"],["XLM/BTC",-0.0,1516626900.0,1516629300.0,3555,40,4.168e-05,4.1888922305764405e-05,false,"roi"],["TRX/BTC",0.00997506,1516629600.0,1516631400.0,3564,30,8.821e-05,8.953646616541353e-05,false,"roi"],["ADA/BTC",-0.0,1516636500.0,1516639200.0,3587,45,5.172e-05,5.1979248120300745e-05,false,"roi"],["NXT/BTC",0.01995012,1516637100.0,1516638300.0,3589,20,3.026e-05,3.101839598997494e-05,false,"roi"],["DASH/BTC",0.0,1516650600.0,1516666200.0,3634,260,0.07064,0.07099408521303258,false,"roi"],["LTC/BTC",0.0,1516656300.0,1516658700.0,3653,40,0.01644483,0.01652726022556391,false,"roi"],["XLM/BTC",0.00997506,1516665900.0,1516667700.0,3685,30,4.331e-05,4.3961278195488714e-05,false,"roi"],["NXT/BTC",0.01995012,1516672200.0,1516673700.0,3706,25,3.2e-05,3.2802005012531326e-05,false,"roi"],["ETH/BTC",0.0,1516681500.0,1516684500.0,3737,50,0.09167706,0.09213659413533835,false,"roi"],["DASH/BTC",0.0,1516692900.0,1516698000.0,3775,85,0.0692498,0.06959691679197995,false,"roi"],["NXT/BTC",0.0,1516704600.0,1516712700.0,3814,135,3.182e-05,3.197949874686716e-05,false,"roi"],["ZEC/BTC",-0.0,1516705500.0,1516723500.0,3817,300,0.04088,0.04108491228070175,false,"roi"],["ADA/BTC",-0.0,1516719300.0,1516721700.0,3863,40,5.15e-05,5.175814536340851e-05,false,"roi"],["ETH/BTC",0.0,1516725300.0,1516752300.0,3883,450,0.09071698,0.09117170170426064,false,"roi"],["NXT/BTC",-0.0,1516728300.0,1516733100.0,3893,80,3.128e-05,3.1436791979949865e-05,false,"roi"],["TRX/BTC",-0.0,1516738500.0,1516744800.0,3927,105,9.555e-05,9.602894736842104e-05,false,"roi"],["ZEC/BTC",-0.0,1516746600.0,1516749000.0,3954,40,0.04080001,0.041004521328320796,false,"roi"],["ADA/BTC",-0.0,1516751400.0,1516764900.0,3970,225,5.163e-05,5.1888796992481196e-05,false,"roi"],["ZEC/BTC",0.0,1516753200.0,1516758600.0,3976,90,0.04040781,0.04061035541353383,false,"roi"],["ADA/BTC",-0.0,1516776300.0,1516778700.0,4053,40,5.132e-05,5.157724310776942e-05,false,"roi"],["ADA/BTC",0.03990025,1516803300.0,1516803900.0,4143,10,5.198e-05,5.432496240601503e-05,false,"roi"],["NXT/BTC",-0.0,1516805400.0,1516811700.0,4150,105,3.054e-05,3.069308270676692e-05,false,"roi"],["TRX/BTC",0.0,1516806600.0,1516810500.0,4154,65,9.263e-05,9.309431077694235e-05,false,"roi"],["ADA/BTC",-0.0,1516833600.0,1516836300.0,4244,45,5.514e-05,5.5416390977443596e-05,false,"roi"],["XLM/BTC",0.0,1516841400.0,1516843800.0,4270,40,4.921e-05,4.9456666666666664e-05,false,"roi"],["ETC/BTC",0.0,1516868100.0,1516882500.0,4359,240,0.0026,0.002613032581453634,false,"roi"],["XMR/BTC",-0.0,1516875900.0,1516896900.0,4385,350,0.02799871,0.028139054411027563,false,"roi"],["ZEC/BTC",-0.0,1516878000.0,1516880700.0,4392,45,0.04078902,0.0409934762406015,false,"roi"],["NXT/BTC",-0.0,1516885500.0,1516887900.0,4417,40,2.89e-05,2.904486215538847e-05,false,"roi"],["ZEC/BTC",-0.0,1516886400.0,1516889100.0,4420,45,0.041103,0.041309030075187964,false,"roi"],["XLM/BTC",0.00997506,1516895100.0,1516896900.0,4449,30,5.428e-05,5.5096240601503756e-05,false,"roi"],["XLM/BTC",-0.0,1516902300.0,1516922100.0,4473,330,5.414e-05,5.441137844611528e-05,false,"roi"],["ZEC/BTC",-0.0,1516914900.0,1516917300.0,4515,40,0.04140777,0.0416153277443609,false,"roi"],["ETC/BTC",0.0,1516932300.0,1516934700.0,4573,40,0.00254309,0.002555837318295739,false,"roi"],["ADA/BTC",-0.0,1516935300.0,1516979400.0,4583,735,5.607e-05,5.6351052631578935e-05,false,"roi"],["ETC/BTC",0.0,1516947000.0,1516958700.0,4622,195,0.00253806,0.0025507821052631577,false,"roi"],["ZEC/BTC",-0.0,1516951500.0,1516960500.0,4637,150,0.0415,0.04170802005012531,false,"roi"],["XLM/BTC",0.00997506,1516960500.0,1516962300.0,4667,30,5.321e-05,5.401015037593984e-05,false,"roi"],["XMR/BTC",-0.0,1516982700.0,1516985100.0,4741,40,0.02772046,0.02785940967418546,false,"roi"],["ETH/BTC",0.0,1517009700.0,1517012100.0,4831,40,0.09461341,0.09508766268170425,false,"roi"],["XLM/BTC",-0.0,1517013300.0,1517016600.0,4843,55,5.615e-05,5.643145363408521e-05,false,"roi"],["ADA/BTC",-0.07877175,1517013900.0,1517287500.0,4845,4560,5.556e-05,5.144e-05,true,"force_sell"],["DASH/BTC",-0.0,1517020200.0,1517052300.0,4866,535,0.06900001,0.06934587471177944,false,"roi"],["ETH/BTC",-0.0,1517034300.0,1517036700.0,4913,40,0.09449985,0.09497353345864659,false,"roi"],["ZEC/BTC",-0.04815133,1517046000.0,1517287200.0,4952,4020,0.0410697,0.03928809,true,"force_sell"],["XMR/BTC",-0.0,1517053500.0,1517056200.0,4977,45,0.0285,0.02864285714285714,false,"roi"],["XMR/BTC",-0.0,1517056500.0,1517066700.0,4987,170,0.02866372,0.02880739779448621,false,"roi"],["ETH/BTC",-0.0,1517068200.0,1517071800.0,5026,60,0.095381,0.09585910025062655,false,"roi"],["DASH/BTC",-0.0,1517072700.0,1517075100.0,5041,40,0.06759092,0.06792972160401002,false,"roi"],["ETC/BTC",-0.0,1517096400.0,1517101500.0,5120,85,0.00258501,0.002597967443609022,false,"roi"],["DASH/BTC",-0.0,1517106300.0,1517127000.0,5153,345,0.06698502,0.0673207845112782,false,"roi"],["DASH/BTC",-0.0,1517135100.0,1517157000.0,5249,365,0.0677177,0.06805713709273183,false,"roi"],["XLM/BTC",0.0,1517171700.0,1517175300.0,5371,60,5.215e-05,5.2411403508771925e-05,false,"roi"],["ETC/BTC",0.00997506,1517176800.0,1517178600.0,5388,30,0.00273809,0.002779264285714285,false,"roi"],["ETC/BTC",0.00997506,1517184000.0,1517185800.0,5412,30,0.00274632,0.002787618045112782,false,"roi"],["LTC/BTC",0.0,1517192100.0,1517194800.0,5439,45,0.01622478,0.016306107218045113,false,"roi"],["DASH/BTC",-0.0,1517195100.0,1517197500.0,5449,40,0.069,0.06934586466165413,false,"roi"],["TRX/BTC",-0.0,1517203200.0,1517208900.0,5476,95,8.755e-05,8.798884711779448e-05,false,"roi"],["DASH/BTC",-0.0,1517209200.0,1517253900.0,5496,745,0.06825763,0.06859977350877192,false,"roi"],["DASH/BTC",-0.0,1517255100.0,1517257500.0,5649,40,0.06713892,0.06747545593984962,false,"roi"],["TRX/BTC",-0.0199116,1517268600.0,1517287500.0,5694,315,8.934e-05,8.8e-05,true,"force_sell"]]