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/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..b6333fb13 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.8.2-slim-buster +FROM python:3.8.3-slim-buster RUN apt-get update \ && apt-get -y install curl build-essential libssl-dev \ diff --git a/Dockerfile.armhf b/Dockerfile.armhf new file mode 100644 index 000000000..d6e2aa3a1 --- /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 \ + && 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..cfb384702 100644 --- a/README.md +++ b/README.md @@ -68,40 +68,42 @@ 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 ticker intervals (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..d37a6b336 100644 --- a/config.json.example +++ b/config.json.example @@ -6,6 +6,7 @@ "fiat_display_currency": "USD", "ticker_interval": "5m", "dry_run": false, + "cancel_open_orders_on_exit": false, "trailing_stop": false, "unfilledtimeout": { "buy": 10, diff --git a/config_binance.json.example b/config_binance.json.example index d324ce883..5d7b6b656 100644 --- a/config_binance.json.example +++ b/config_binance.json.example @@ -6,6 +6,7 @@ "fiat_display_currency": "USD", "ticker_interval": "5m", "dry_run": true, + "cancel_open_orders_on_exit": false, "trailing_stop": false, "unfilledtimeout": { "buy": 10, diff --git a/config_full.json.example b/config_full.json.example index 181740b9a..0cd265cbe 100644 --- a/config_full.json.example +++ b/config_full.json.example @@ -8,6 +8,7 @@ "amend_last_stake_amount": false, "last_stake_amount_min_ratio": 0.5, "dry_run": false, + "cancel_open_orders_on_exit": false, "ticker_interval": "5m", "trailing_stop": false, "trailing_stop_positive": 0.005, @@ -120,6 +121,7 @@ "enabled": false, "listen_ip_address": "127.0.0.1", "listen_port": 8080, + "jwt_secret_key": "somethingrandom", "username": "freqtrader", "password": "SuperSecurePassword" }, diff --git a/config_kraken.json.example b/config_kraken.json.example index dcf4c552a..54fbf4a00 100644 --- a/config_kraken.json.example +++ b/config_kraken.json.example @@ -6,6 +6,7 @@ "fiat_display_currency": "EUR", "ticker_interval": "5m", "dry_run": true, + "cancel_open_orders_on_exit": false, "trailing_stop": false, "unfilledtimeout": { "buy": 10, diff --git a/docs/backtesting.md b/docs/backtesting.md index 3d08d5332..9b2997510 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -17,8 +17,12 @@ For details on downloading, please refer to the [Data Downloading](data-download 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 @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md index 338299781..93e53de6f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -51,6 +51,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `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) @@ -80,14 +81,14 @@ 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.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 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.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 @@ -108,7 +109,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `forcebuy_enable` | Enables the RPC Commands to force a buy. More information below.
**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 @@ -544,32 +545,33 @@ 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 +## Pairlists and Pairlist Handlers -Pairlists define the list of pairs that the bot should trade. -There are [`StaticPairList`](#static-pair-list) and dynamic Whitelists available. +Pairlist Handlers define the list of pairs (pairlist) that the bot should trade. They are configured in the `pairlists` section of the configuration settings. -[`PrecisionFilter`](#precision-filter) and [`PriceFilter`](#price-pair-filter) act as filters, removing low-value pairs. +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). -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. +Additionaly, [`PrecisionFilter`](#precisionfilter), [`PriceFilter`](#pricefilter), [`ShuffleFilter`](#shufflefilter) and [`SpreadFilter`](#spreadfilter) act as Pairlist Filters, removing certain pairs and/or moving their positions in the pairlist. -Inactive markets and blacklisted pairs are always removed from the resulting `pair_whitelist`. +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. -### Available Pairlists +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) +* [`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 +587,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. I 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 @@ -606,29 +608,41 @@ It uses configuration from `exchange.pair_whitelist` and `exchange.pair_blacklis ], ``` -#### Precision Filter +#### PrecisionFilter -Filters low-value coins which would not allow setting a stoploss. +Filters low-value coins which would not allow setting stoplosses. -#### Price Pair Filter +#### PriceFilter 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. + +Currently, only `low_price_ratio` setting is implemented, where a raise of 1 price unit (pip) is below 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. +Calculation example: -These pairs are dangerous since it may be impossible to place the desired stoploss - and often result in high losses. +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. Here is what the PriceFilters takes over. + +#### 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`). -#### Spread Filter -Removes pairs that have a difference between asks and bids above the specified ratio (default `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": { @@ -642,7 +656,9 @@ The below example blacklists `BNB/BTC`, uses `VolumePairList` with `20` assets, "sort_key": "quoteVolume", }, {"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/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/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/hyperopt.md b/docs/hyperopt.md index 11161e58b..8efc51a39 100644 --- a/docs/hyperopt.md +++ b/docs/hyperopt.md @@ -487,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 diff --git a/docs/installation.md b/docs/installation.md index 88e2ef6eb..f017bef96 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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/requirements-docs.txt b/docs/requirements-docs.txt index 1c0e280ae..b34f93c95 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,2 +1,2 @@ -mkdocs-material==5.1.3 +mkdocs-material==5.2.1 mdx_truly_sane_lists==1.2 diff --git a/docs/rest-api.md b/docs/rest-api.md index b68364f39..7f1a95b12 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -11,6 +11,7 @@ Sample configuration: "enabled": true, "listen_ip_address": "127.0.0.1", "listen_port": 8080, + "jwt_secret_key": "somethingrandom", "username": "Freqtrader", "password": "SuperSecret1!" }, @@ -29,7 +30,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,6 +39,9 @@ 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. @@ -202,3 +206,28 @@ 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"} +``` diff --git a/docs/sql_cheatsheet.md b/docs/sql_cheatsheet.md index b7b38c3dc..895a0536a 100644 --- a/docs/sql_cheatsheet.md +++ b/docs/sql_cheatsheet.md @@ -1,13 +1,20 @@ # 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 ``` ## Open the DB + ```bash sqlite3 .open @@ -16,45 +23,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, + ticker_interval 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 diff --git a/docs/strategy-advanced.md b/docs/strategy-advanced.md index 39e92d651..69e2256a1 100644 --- a/docs/strategy-advanced.md +++ b/docs/strategy-advanced.md @@ -20,7 +20,7 @@ It applies a tight timeout for higher priced assets, while allowing more time to The function must return either `True` (cancel order) or `False` (keep order alive). ``` python -from datetime import datetime, timestamp +from datetime import datetime, timedelta from freqtrade.persistence import Trade class Awesomestrategy(IStrategy): @@ -59,7 +59,7 @@ class Awesomestrategy(IStrategy): ### Custom order timeout example (using additional data) ``` python -from datetime import datetime, timestamp +from datetime import datetime from freqtrade.persistence import Trade class Awesomestrategy(IStrategy): diff --git a/docs/strategy-customization.md b/docs/strategy-customization.md index c4fc55811..7197b0fba 100644 --- a/docs/strategy-customization.md +++ b/docs/strategy-customization.md @@ -324,67 +324,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. @@ -404,6 +351,125 @@ 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). +- `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 ATR 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 ATR. 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... + + ticker_interval = '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, metadata): + # Get the informative pair + informative = self.dp.get_pair_dataframe(pair=metadata['pair'], timeframe='1d') + # Get the 14 day ATR. + atr = ta.ATR(informative, timeperiod=14) + # Do other stuff +``` + +#### *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 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(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. @@ -426,6 +492,7 @@ 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. diff --git a/docs/utils.md b/docs/utils.md index 57210ac7e..7ed31376f 100644 --- a/docs/utils.md +++ b/docs/utils.md @@ -521,3 +521,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/freqtrade/__init__.py b/freqtrade/__init__.py index 647682d70..602aece94 100644 --- a/freqtrade/__init__.py +++ b/freqtrade/__init__.py @@ -1,5 +1,5 @@ """ Freqtrade bot """ -__version__ = '2020.4' +__version__ = '2020.5' if __version__ == 'develop': diff --git a/freqtrade/commands/__init__.py b/freqtrade/commands/__init__.py index f80c74e05..2d0c7733c 100644 --- a/freqtrade/commands/__init__.py +++ b/freqtrade/commands/__init__.py @@ -19,7 +19,8 @@ from freqtrade.commands.list_commands import (start_list_exchanges, start_list_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..1b7bbfeb5 100644 --- a/freqtrade/commands/arguments.py +++ b/freqtrade/commands/arguments.py @@ -64,6 +64,8 @@ ARGS_PLOT_DATAFRAME = ["pairs", "indicators1", "indicators2", "plot_limit", ARGS_PLOT_PROFIT = ["pairs", "timerange", "export", "exportfilename", "db_url", "trade_source", "ticker_interval"] +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", @@ -78,7 +80,7 @@ ARGS_HYPEROPT_SHOW = ["hyperopt_list_best", "hyperopt_list_profitable", "hyperop NO_CONF_REQURIED = ["convert-data", "convert-trade-data", "download-data", "list-timeframes", "list-markets", "list-pairs", "list-strategies", "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"] @@ -163,7 +165,7 @@ class Arguments: 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 +181,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 +194,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 +233,24 @@ 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) + # 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) - # 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) + # 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 +269,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 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 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..87098f53c 100644 --- a/freqtrade/commands/build_config_commands.py +++ b/freqtrade/commands/build_config_commands.py @@ -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 498ea9359..ee9208c33 100644 --- a/freqtrade/commands/cli_options.py +++ b/freqtrade/commands/cli_options.py @@ -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'], @@ -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', diff --git a/freqtrade/commands/deploy_commands.py b/freqtrade/commands/deploy_commands.py index a29ba346f..86562fa7c 100644 --- a/freqtrade/commands/deploy_commands.py +++ b/freqtrade/commands/deploy_commands.py @@ -51,7 +51,7 @@ def deploy_new_strategy(strategy_name: str, strategy_path: Path, subtemplate: st ) additional_methods = render_template_with_fallback( templatefile=f"subtemplates/strategy_methods_{subtemplate}.j2", - templatefallbackfile=f"subtemplates/strategy_methods_empty.j2", + templatefallbackfile="subtemplates/strategy_methods_empty.j2", ) strategy_text = render_template(templatefile='base_strategy.py.j2', diff --git a/freqtrade/commands/hyperopt_commands.py b/freqtrade/commands/hyperopt_commands.py index 3f61ea66c..517f47d16 100755 --- a/freqtrade/commands/hyperopt_commands.py +++ b/freqtrade/commands/hyperopt_commands.py @@ -38,33 +38,33 @@ def start_hyperopt_list(args: Dict[str, Any]) -> None: 'filter_max_total_profit': config.get('hyperopt_list_max_total_profit', 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 +78,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 = { @@ -96,89 +96,87 @@ def start_hyperopt_show(args: Dict[str, Any]) -> 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] + epochs = [x for x in epochs if x['results_metrics']['profit'] > 0] if filteroptions['filter_min_trades'] > 0: - trials = [ - x for x in trials + epochs = [ + x for x in epochs if x['results_metrics']['trade_count'] > filteroptions['filter_min_trades'] ] if filteroptions['filter_max_trades'] > 0: - trials = [ - x for x in trials + epochs = [ + x for x in epochs 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 + 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: - trials = [x for x in trials if x['results_metrics']['trade_count'] > 0] - trials = [ - x for x in trials + 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'] ] 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'] + 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: - 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'] + 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: - trials = [x for x in trials if x['results_metrics']['trade_count'] > 0] - trials = [ - x for x in trials + 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: - trials = [x for x in trials if x['results_metrics']['trade_count'] > 0] - trials = [ - x for x in trials + 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'] ] - logger.info(f"{len(trials)} " + + logger.info(f"{len(epochs)} " + ("best " if filteroptions['only_best'] else "") + ("profitable " if filteroptions['only_profitable'] else "") + "epochs found.") - return trials + return epochs diff --git a/freqtrade/commands/list_commands.py b/freqtrade/commands/list_commands.py index 327901dc0..e5131f9b2 100644 --- a/freqtrade/commands/list_commands.py +++ b/freqtrade/commands/list_commands.py @@ -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/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..7edd9bca1 100644 --- a/freqtrade/configuration/configuration.py +++ b/freqtrade/configuration/configuration.py @@ -351,8 +351,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..3999ea422 100644 --- a/freqtrade/configuration/deprecated_settings.py +++ b/freqtrade/configuration/deprecated_settings.py @@ -58,29 +58,6 @@ 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( diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 54f620631..c1bf30f17 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,14 @@ ORDERBOOK_SIDES = ['ask', 'bid'] ORDERTYPE_POSSIBILITIES = ['limit', 'market'] ORDERTIF_POSSIBILITIES = ['gtc', 'fok', 'ioc'] AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList', - 'PrecisionFilter', 'PriceFilter', 'SpreadFilter'] + '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' @@ -85,6 +91,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', @@ -318,3 +325,13 @@ 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 +ListPairsWithTimeframes = List[Tuple[str, str]] diff --git a/freqtrade/data/btanalysis.py b/freqtrade/data/btanalysis.py index b0c642c1d..f98135c27 100644 --- a/freqtrade/data/btanalysis.py +++ b/freqtrade/data/btanalysis.py @@ -194,7 +194,10 @@ def create_cum_profit(df: pd.DataFrame, trades: pd.DataFrame, col_name: str, :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 diff --git a/freqtrade/data/converter.py b/freqtrade/data/converter.py index 77371bf27..0ef7955a4 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,9 +187,10 @@ 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() diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index 1df710152..a01344364 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -5,26 +5,30 @@ including ticker and orderbook data, live and historical candle (OHLCV) data Common Interface for bot and strategy to access data. """ import logging -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional from pandas import DataFrame from freqtrade.data.history import load_pair_history +from freqtrade.exceptions import DependencyException, OperationalException from freqtrade.exchange import Exchange from freqtrade.state import RunMode +from freqtrade.constants import ListPairsWithTimeframes + 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 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 +38,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. @@ -95,19 +99,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 DependencyException: + 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 +125,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..4f3f75a87 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,40 @@ 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') + 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..d5d7c16db 100644 --- a/freqtrade/data/history/idatahandler.py +++ b/freqtrade/data/history/idatahandler.py @@ -8,16 +8,20 @@ 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.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): @@ -89,23 +93,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 +127,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..01320f129 100644 --- a/freqtrade/data/history/jsondatahandler.py +++ b/freqtrade/data/history/jsondatahandler.py @@ -1,6 +1,7 @@ +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 @@ -8,8 +9,11 @@ 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.data.converter import trades_dict_to_list -from .idatahandler import IDataHandler +from .idatahandler import IDataHandler, TradeList + +logger = logging.getLogger(__name__) class JsonDataHandler(IDataHandler): @@ -113,24 +117,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 +146,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..c19d4552a 100644 --- a/freqtrade/edge/edge_positioning.py +++ b/freqtrade/edge/edge_positioning.py @@ -238,20 +238,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 +251,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 diff --git a/freqtrade/exceptions.py b/freqtrade/exceptions.py index 553a691ef..7cfed87e8 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: diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index 875628af9..4279f392c 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -20,7 +20,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 +30,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: """ @@ -72,7 +72,7 @@ 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 diff --git a/freqtrade/exchange/common.py b/freqtrade/exchange/common.py index b38ed35a3..a10d41247 100644 --- a/freqtrade/exchange/common.py +++ b/freqtrade/exchange/common.py @@ -1,6 +1,6 @@ import logging -from freqtrade.exceptions import DependencyException, TemporaryError +from freqtrade.exceptions import TemporaryError logger = logging.getLogger(__name__) @@ -93,7 +93,7 @@ def retrier_async(f): 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: count -= 1 @@ -111,7 +111,7 @@ def retrier(f): count = kwargs.pop('count', API_RETRY_COUNT) try: return f(*args, **kwargs) - except (TemporaryError, DependencyException) as ex: + except TemporaryError as ex: logger.warning('%s() returned exception: "%s"', f.__name__, ex) if count > 0: count -= 1 diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 1a0565959..af745e8d0 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -18,12 +18,12 @@ 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.data.converter import ohlcv_to_dataframe, trades_dict_to_list from freqtrade.exceptions import (DependencyException, InvalidOrderException, OperationalException, 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_fallback +from freqtrade.constants import ListPairsWithTimeframes CcxtModuleType = Any @@ -367,8 +367,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: """ @@ -472,26 +471,31 @@ class Exchange: 'pair': pair, 'price': 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"]}) @@ -672,7 +676,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). @@ -769,7 +773,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,7 +793,7 @@ 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.' @@ -803,7 +807,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 +818,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 +827,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 +837,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 +861,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,7 +882,7 @@ 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. """ @@ -990,7 +998,7 @@ class Exchange: raise OperationalException(e) from e @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 @@ -1041,9 +1049,9 @@ class Exchange: return matched_trades - except ccxt.NetworkError as 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 @@ -1063,6 +1071,61 @@ class Exchange: 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_fallback(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) + else: + # If Fee currency is a different currency + try: + comb = self.get_valid_pair_combination(fee_curr, self._config['stake_currency']) + tick = self.fetch_ticker(comb) + + fee_to_quote_rate = safe_value_fallback(tick, tick, 'last', 'ask') + return round((order['fee']['cost'] * fee_to_quote_rate) / order['cost'], 8) + except DependencyException: + 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)) + # calculate rate ? (order['fee']['cost'] / (order['amount'] * order['price'])) + def is_exchange_bad(exchange_name: str) -> bool: return exchange_name in BAD_EXCHANGES diff --git a/freqtrade/exchange/kraken.py b/freqtrade/exchange/kraken.py index 243f1a6d6..932d82a27 100644 --- a/freqtrade/exchange/kraken.py +++ b/freqtrade/exchange/kraken.py @@ -7,7 +7,7 @@ import ccxt from freqtrade.exceptions import (DependencyException, InvalidOrderException, OperationalException, TemporaryError) from freqtrade.exchange import Exchange -from freqtrade.exchange.exchange import retrier +from freqtrade.exchange.common import retrier logger = logging.getLogger(__name__) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 7ae87e807..d4afa1d60 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -7,7 +7,7 @@ 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 @@ -18,7 +18,7 @@ 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, InvalidOrderException, PricingError from freqtrade.exchange import timeframe_to_minutes, timeframe_to_next_date from freqtrade.misc import safe_value_fallback from freqtrade.pairlist.pairlistmanager import PairListManager @@ -54,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) @@ -68,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') @@ -113,6 +116,9 @@ class FreqtradeBot: """ logger.info('Cleaning up modules ...') + if self.config['cancel_open_orders_on_exit']: + self.cancel_all_open_orders() + self.rpc.cleanup() persistence.cleanup() @@ -139,10 +145,10 @@ class FreqtradeBot: # 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()) with self._sell_lock: @@ -162,9 +168,17 @@ 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 _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() @@ -181,12 +195,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 @@ -252,12 +260,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) @@ -438,7 +453,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() @@ -610,7 +625,7 @@ 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: @@ -627,7 +642,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] @@ -654,8 +669,11 @@ 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']] self._sell_rate_cache[pair] = rate @@ -682,16 +700,23 @@ class FreqtradeBot: self.dataprovider.ohlcv(trade.pair, self.strategy.ticker_interval)) 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.info(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}") @@ -752,7 +777,7 @@ class FreqtradeBot: # We check if stoploss order is fulfilled if stoploss_order and stoploss_order['status'] == 'closed': 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'])) @@ -866,88 +891,111 @@ class FreqtradeBot: 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 + 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']) - self.handle_timedout_limit_buy(trade, order) - self.wallets.update() - order_type = self.strategy.order_types['buy'] - self._notify_buy_cancel(trade, order_type) - - elif (order['side'] == 'sell' and ( - trade_state_update + 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))): - 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) + 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.get_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) 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_fallback(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 @@ -957,11 +1005,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: """ @@ -982,7 +1036,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( @@ -1032,7 +1086,7 @@ 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 @@ -1084,6 +1138,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) @@ -1123,7 +1182,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. @@ -1131,84 +1190,125 @@ 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.get_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(order['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'] # 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/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index f29f599a6..3bf211d99 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -20,6 +20,7 @@ from freqtrade.exceptions import OperationalException from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds from freqtrade.optimize.optimize_reports import (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,10 +64,19 @@ class Backtesting: self.strategylist: List[IStrategy] = [] self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config) + self.pairlists = PairListManager(self.exchange, self.config) + if 'VolumePairList' in self.pairlists.name_list: + raise OperationalException("VolumePairList not allowed for backtesting.") + + self.pairlists.refresh_pairlist() + + if len(self.pairlists.whitelist) == 0: + raise OperationalException("No pair in whitelist.") + if config.get('fee'): self.fee = config['fee'] else: - self.fee = self.exchange.get_fee(symbol=self.config['exchange']['pair_whitelist'][0]) + self.fee = self.exchange.get_fee(symbol=self.pairlists.whitelist[0]) if self.config.get('runmode') != RunMode.HYPEROPT: self.dataprovider = DataProvider(self.config, self.exchange) @@ -111,7 +121,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, diff --git a/freqtrade/optimize/hyperopt.py b/freqtrade/optimize/hyperopt.py index 79b6b8cb0..3a28de785 100644 --- a/freqtrade/optimize/hyperopt.py +++ b/freqtrade/optimize/hyperopt.py @@ -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: """ @@ -376,24 +375,31 @@ 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' @@ -420,7 +426,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: """ @@ -564,43 +570,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) @@ -626,8 +617,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!") @@ -663,7 +655,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) @@ -676,7 +668,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): @@ -697,23 +688,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/optimize_reports.py b/freqtrade/optimize/optimize_reports.py index 646afb5df..1fc4d721e 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 @@ -34,118 +34,173 @@ 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 _get_line_floatfmt() -> List[str]: """ - Generates and returns a text table for the given backtest data and the results dataframe + 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.index), + '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(): 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]) - ]) + tabular_data.append(_generate_result_line(results, max_open_trades, 'TOTAL')) + return tabular_data + + +def generate_text_table(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(tabular_data, headers=headers, + return tabulate(output, headers=headers, floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") # type: ignore -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_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 generate_strategy_metrics(stake_currency: str, max_open_trades: int, + all_results: Dict) -> List[Dict]: + """ + 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: List of Dicts containing the metrics per Strategy + """ + + tabular_data = [] + for strategy, results in all_results.items(): + tabular_data.append(_generate_result_line(results, max_open_trades, strategy)) + return tabular_data + + +def generate_text_table_strategy(strategy_results, stake_currency: str) -> str: """ Generate summary table per strategy :param stake_currency: stake-currency - used to correctly name headers @@ -153,34 +208,21 @@ def generate_text_table_strategy(stake_currency: str, max_open_trades: str, :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) - 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]) - ]) + 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(tabular_data, headers=headers, + return tabulate(output, headers=headers, floatfmt=floatfmt, tablefmt="orgtbl", stralign="right") # type: ignore 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', @@ -206,38 +248,48 @@ def generate_edge_table(results: dict) -> str: def show_backtest_results(config: Dict, btdata: Dict[str, DataFrame], all_results: Dict[str, DataFrame]): - for strategy, results in all_results.items(): + stake_currency = config['stake_currency'] + max_open_trades = config['max_open_trades'] + 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) + # 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 = generate_text_table(pair_results, 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 = generate_text_table_sell_reason(sell_reason_stats=sell_reason_stats, + 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 = generate_text_table(left_open_results, 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: # Print Strategy summary table - table = generate_text_table_strategy(config['stake_currency'], - config['max_open_trades'], - all_results=all_results) + strategy_results = generate_strategy_metrics(stake_currency=stake_currency, + max_open_trades=max_open_trades, + all_results=all_results) + + table = generate_text_table_strategy(strategy_results, stake_currency) print(' STRATEGY SUMMARY '.center(len(table.splitlines()[0]), '=')) print(table) print('=' * len(table.splitlines()[0])) diff --git a/freqtrade/pairlist/IPairList.py b/freqtrade/pairlist/IPairList.py index e089e546c..e49ad1561 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 @@ -13,6 +10,7 @@ from cachetools import TTLCache, cached from freqtrade.exchange import market_is_active + logger = logging.getLogger(__name__) @@ -23,11 +21,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 @@ -78,49 +78,50 @@ 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 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]: """ diff --git a/freqtrade/pairlist/PrecisionFilter.py b/freqtrade/pairlist/PrecisionFilter.py index 2a2ba46b7..0331347be 100644 --- a/freqtrade/pairlist/PrecisionFilter.py +++ b/freqtrade/pairlist/PrecisionFilter.py @@ -1,14 +1,28 @@ +""" +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 + 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) + + 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: """ @@ -24,41 +38,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..b85d68269 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,6 +18,7 @@ class PriceFilter(IPairList): super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) self._low_price_ratio = pairlistconfig.get('low_price_ratio', 0) + self._enabled = self._low_price_ratio != 0 @property def needstickers(self) -> bool: @@ -31,43 +35,21 @@ class PriceFilter(IPairList): """ return f"{self.name} - Filtering pairs priced below {self._low_price_ratio * 100}%." - def _validate_ticker_lowprice(self, ticker) -> bool: + 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: - 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'] + 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 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..ba3792213 --- /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 requries 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..0147c0068 100644 --- a/freqtrade/pairlist/SpreadFilter.py +++ b/freqtrade/pairlist/SpreadFilter.py @@ -1,19 +1,24 @@ +""" +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: @@ -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..07e559168 100644 --- a/freqtrade/pairlist/StaticPairList.py +++ b/freqtrade/pairlist/StaticPairList.py @@ -1,14 +1,14 @@ """ -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 freqtrade.pairlist.IPairList import IPairList + logger = logging.getLogger(__name__) diff --git a/freqtrade/pairlist/VolumePairList.py b/freqtrade/pairlist/VolumePairList.py index 65f43245c..6f39ae6d6 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,13 +37,19 @@ 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: """ @@ -72,42 +82,42 @@ class VolumePairList(IPairList): (self._last_refresh + self.refresh_period < datetime.now().timestamp())): self._last_refresh = int(datetime.now().timestamp()) - pairs = self._gen_pair_whitelist(pairlist, tickers, - self._config['stake_currency'], - self._sort_key, self._min_value) + pairs = self._gen_pair_whitelist(pairlist, tickers) else: pairs = pairlist + self.log_on_refresh(logger.info, f"Searching {self._number_pairs} pairs: {pairs}") + return pairs - def _gen_pair_whitelist(self, pairlist: List[str], tickers: Dict, - base_currency: str, key: str, min_val: int) -> List[str]: + def _gen_pair_whitelist(self, pairlist: List[str], tickers: Dict) -> 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 pairlist: pairlist to filter or sort :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)] + 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)] else: - # If other pairlist is in front, use the incomming pairlist. + # If other pairlist is in front, use the incoming 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)) + if self._min_value > 0: + filtered_tickers = [ + v for v in filtered_tickers if v[self._sort_key] > self._min_value] - 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] return pairs diff --git a/freqtrade/pairlist/pairlistmanager.py b/freqtrade/pairlist/pairlistmanager.py index 5b4c5b602..98878bcb0 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,57 @@ 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) + # 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, ticker_interval) + """ + return [(pair, timeframe or self._config['ticker_interval']) for pair in pairs] diff --git a/freqtrade/persistence.py b/freqtrade/persistence.py index fb314f439..da7137cba 100644 --- a/freqtrade/persistence.py +++ b/freqtrade/persistence.py @@ -86,11 +86,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, 'sell_order_status'): 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') @@ -109,6 +113,7 @@ def check_migrate(engine) -> None: 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') # Schema migration necessary engine.execute(f"alter table trades rename to {table_back_name}") @@ -120,12 +125,14 @@ 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, 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, + max_rate, min_rate, sell_reason, sell_order_status, strategy, ticker_interval, open_trade_price, close_profit_abs ) select id, lower(exchange), @@ -136,7 +143,9 @@ 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, @@ -145,6 +154,7 @@ def check_migrate(engine) -> None: {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, + {sell_order_status} sell_order_status, {strategy} strategy, {ticker_interval} ticker_interval, {open_trade_price} open_trade_price, {close_profit_abs} close_profit_abs from {table_back_name} @@ -185,7 +195,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 @@ -216,6 +230,7 @@ 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) @@ -235,13 +250,19 @@ class Trade(_DECL_BASE): 'pair': self.pair, 'is_open': self.is_open, '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), '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), + 'close_timestamp': int(self.close_date.timestamp() * 1000) 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, @@ -251,6 +272,7 @@ class Trade(_DECL_BASE): 'stake_amount': round(self.stake_amount, 8), 'close_profit': self.close_profit, 'sell_reason': self.sell_reason, + 'sell_order_status': self.sell_order_status, 'stop_loss': self.stop_loss, 'stop_loss_pct': (self.stop_loss_pct * 100) if self.stop_loss_pct else None, 'initial_stop_loss': self.initial_stop_loss, @@ -354,12 +376,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. diff --git a/freqtrade/plot/plotting.py b/freqtrade/plot/plotting.py index 60f838db2..f1d114e2b 100644 --- a/freqtrade/plot/plotting.py +++ b/freqtrade/plot/plotting.py @@ -10,8 +10,9 @@ 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.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 @@ -414,9 +415,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 @@ -504,6 +508,9 @@ 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 diff --git a/freqtrade/rpc/api_server.py b/freqtrade/rpc/api_server.py index 0335bb151..23b6a85b0 100644 --- a/freqtrade/rpc/api_server.py +++ b/freqtrade/rpc/api_server.py @@ -2,11 +2,17 @@ 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__ @@ -38,9 +44,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 @@ -70,8 +76,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,6 +89,15 @@ class ApiServer(RPC): self._config = freqtrade.config self.app = Flask(__name__) + self._cors = CORS(self.app, + resources={r"/api/*": {"supports_credentials": True, }} + ) + + # 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 @@ -148,6 +163,10 @@ 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']) @@ -199,6 +218,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): diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 8645e466e..248b4a421 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -94,6 +94,7 @@ 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'], @@ -103,6 +104,8 @@ class RPC: 'ticker_interval': config['ticker_interval'], 'exchange': config['exchange']['name'], 'strategy': config['strategy'], + 'forcebuy_enabled': config.get('forcebuy_enable', False), + 'state': str(self._freqtrade.state) } return val @@ -128,13 +131,15 @@ class RPC: current_rate = NAN current_profit = trade.calc_profit_ratio(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), open_order='({} {} rem={:.8f})'.format( order['type'], order['side'], order['remaining'] ) if order else None, @@ -183,7 +188,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,28 +208,26 @@ 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 """ @@ -311,7 +314,9 @@ class RPC: 'profit_all_fiat': profit_all_fiat, 'trade_count': len(trades), 'first_trade_date': arrow.get(trades[0].open_date).humanize(), + 'first_trade_timestamp': int(trades[0].open_date.timestamp() * 1000), 'latest_trade_date': arrow.get(trades[-1].open_date).humanize(), + 'latest_trade_timestamp': int(trades[-1].open_date.timestamp() * 1000), 'avg_duration': str(timedelta(seconds=sum(durations) / num)).split('.')[0], 'best_pair': bp_pair, 'best_rate': round(bp_rate * 100, 2), @@ -544,5 +549,5 @@ class RPC: 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/telegram.py b/freqtrade/rpc/telegram.py index a21f7556c..488fa9f37 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -215,22 +215,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 +282,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: @@ -579,7 +589,7 @@ class Telegram(RPC): "*/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" \ + "*/edge:* `Shows validated pairs by Edge if it is enabled` \n" \ "*/help:* `This help message`\n" \ "*/version:* `Show version`" @@ -621,10 +631,12 @@ 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"{sl_info}" f"*Ticker Interval:* `{val['ticker_interval']}`\n" - f"*Strategy:* `{val['strategy']}`" + 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..38784c6a4 100644 --- a/freqtrade/state.py +++ b/freqtrade/state.py @@ -14,6 +14,9 @@ class State(Enum): STOPPED = 2 RELOAD_CONF = 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 6268b8a43..400997baf 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -7,7 +7,7 @@ import warnings from abc import ABC, abstractmethod from datetime import datetime, timezone from enum import Enum -from typing import Dict, List, NamedTuple, Optional, Tuple +from typing import Dict, NamedTuple, Optional, Tuple import arrow from pandas import DataFrame @@ -17,8 +17,10 @@ from freqtrade.exceptions import StrategyError from freqtrade.exchange import timeframe_to_minutes from freqtrade.persistence import Trade from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper +from freqtrade.constants import ListPairsWithTimeframes from freqtrade.wallets import Wallets + logger = logging.getLogger(__name__) @@ -185,7 +187,7 @@ class IStrategy(ABC): """ return False - def informative_pairs(self) -> List[Tuple[str, str]]: + 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 @@ -308,7 +310,6 @@ class IStrategy(ABC): logger.warning('Empty candle (OHLCV) data for pair %s', pair) return False, False - latest_date = dataframe['date'].max() try: df_len, df_close, df_date = self.preserve_df(dataframe) dataframe = strategy_safe_wrapper( @@ -324,17 +325,19 @@ class IStrategy(ABC): logger.warning('Empty dataframe for pair %s', pair) return False, False + latest_date = dataframe['date'].max() latest = dataframe.loc[dataframe['date'] == latest_date].iloc[-1] + # Explicitly convert to arrow object to ensure the below comparison does not fail + latest_date = arrow.get(latest_date) # Check if dataframe is out of date - signal_date = arrow.get(latest['date']) interval_minutes = timeframe_to_minutes(interval) 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=-(interval_minutes * 2 + offset))): logger.warning( 'Outdated history for pair %s. Last tick is %s minutes old', pair, - (arrow.utcnow() - signal_date).seconds // 60 + (arrow.utcnow() - latest_date).seconds // 60 ) return False, False diff --git a/freqtrade/templates/base_config.json.j2 b/freqtrade/templates/base_config.json.j2 index 134719273..6d3174347 100644 --- a/freqtrade/templates/base_config.json.j2 +++ b/freqtrade/templates/base_config.json.j2 @@ -6,6 +6,7 @@ "fiat_display_currency": "{{ fiat_display_currency }}", "ticker_interval": "{{ ticker_interval }}", "dry_run": {{ dry_run | lower }}, + "cancel_open_orders_on_exit": false, "unfilledtimeout": { "buy": 10, "sell": 30 diff --git a/freqtrade/worker.py b/freqtrade/worker.py index 4c28ecaeb..3f5ab734e 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,6 +58,15 @@ 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: @@ -89,17 +96,13 @@ class Worker: 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 +134,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 +157,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 +168,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/requirements-common.txt b/requirements-common.txt index a53fc3999..a9019cba1 100644 --- a/requirements-common.txt +++ b/requirements-common.txt @@ -1,15 +1,15 @@ # requirements without requirements installable via conda # mainly used for Raspberry pi installs -ccxt==1.27.1 -SQLAlchemy==1.3.16 -python-telegram-bot==12.6.1 -arrow==0.15.5 +ccxt==1.28.49 +SQLAlchemy==1.3.17 +python-telegram-bot==12.7 +arrow==0.15.6 cachetools==4.1.0 requests==2.23.0 urllib3==1.25.9 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 jinja2==2.11.2 @@ -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 508716bde..e05231630 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,13 +4,13 @@ -r requirements-hyperopt.txt coveralls==2.0.0 -flake8==3.7.9 +flake8==3.8.2 flake8-type-annotations==0.1.0 flake8-tidy-imports==4.1.0 mypy==0.770 -pytest==5.4.1 -pytest-asyncio==0.11.0 -pytest-cov==2.8.1 +pytest==5.4.2 +pytest-asyncio==0.12.0 +pytest-cov==2.9.0 pytest-mock==3.1.0 pytest-random-order==1.0.4 diff --git a/requirements-hyperopt.txt b/requirements-hyperopt.txt index b0e18867d..e1b3fef4f 100644 --- a/requirements-hyperopt.txt +++ b/requirements-hyperopt.txt @@ -3,8 +3,8 @@ # Required for hyperopt scipy==1.4.1 -scikit-learn==0.22.2.post1 +scikit-learn==0.23.1 scikit-optimize==0.7.4 filelock==3.0.12 -joblib==0.14.1 -progressbar2==3.51.0 +joblib==0.15.1 +progressbar2==3.51.3 diff --git a/requirements-plot.txt b/requirements-plot.txt index 3db48a201..d81239053 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.7.1 diff --git a/requirements.txt b/requirements.txt index 967f8df10..f5d09db4d 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.18.4 +pandas==1.0.4 diff --git a/setup.py b/setup.py index 94c48a6a7..20963a15f 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', diff --git a/tests/commands/test_commands.py b/tests/commands/test_commands.py index 4530cd03d..46350beff 100644 --- a/tests/commands/test_commands.py +++ b/tests/commands/test_commands.py @@ -10,11 +10,13 @@ from freqtrade.commands import (start_convert_data, start_create_userdir, start_list_hyperopts, start_list_markets, start_list_strategies, start_list_timeframes, start_new_hyperopt, start_new_strategy, - start_test_pairlist, start_trading) + 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) @@ -911,8 +912,7 @@ def test_hyperopt_list(mocker, capsys, hyperopt_results): 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 +1041,46 @@ 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 + + +@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 3 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/conftest.py b/tests/conftest.py index d95475b8c..971f7a5fa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -92,7 +92,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'])) @@ -249,6 +249,7 @@ def default_conf(testdatadir): "fiat_display_currency": "USD", "ticker_interval": '5m', "dry_run": True, + "cancel_open_orders_on_exit": False, "minimal_roi": { "40": 0.0, "30": 0.01, @@ -304,7 +305,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 @@ -779,7 +781,7 @@ 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, @@ -795,7 +797,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 +813,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 +829,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 +845,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 +861,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 +875,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 { @@ -1328,6 +1423,15 @@ def trades_for_order(): @pytest.fixture(scope="function") def trades_history(): + return [[1565798399463, '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,7 +1586,7 @@ 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, @@ -1601,7 +1705,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 +1716,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 +1768,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..50cf9db3d 100644 --- a/tests/data/test_btanalysis.py +++ b/tests/data/test_btanalysis.py @@ -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..c2d6e82f1 100644 --- a/tests/data/test_dataprovider.py +++ b/tests/data/test_dataprovider.py @@ -1,8 +1,11 @@ from unittest.mock import MagicMock from pandas import DataFrame +import pytest from freqtrade.data.dataprovider import DataProvider +from freqtrade.pairlist.pairlistmanager import PairListManager +from freqtrade.exceptions import DependencyException, OperationalException from freqtrade.state import RunMode from tests.conftest import get_patched_exchange @@ -64,8 +67,8 @@ def test_get_pair_dataframe(mocker, default_conf, ohlcv_history): assert dp.get_pair_dataframe("NONESENSE/AAA", ticker_interval).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", ticker_interval)\ + .equals(dp.get_pair_dataframe("UNITTEST/BTC")) default_conf["runmode"] = RunMode.LIVE dp = DataProvider(default_conf, exchange) @@ -90,10 +93,7 @@ def test_available_pairs(mocker, default_conf, 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", ticker_interval), ("UNITTEST/BTC", ticker_interval), ] def test_refresh(mocker, default_conf, ohlcv_history): @@ -152,3 +152,45 @@ 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=DependencyException('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() diff --git a/tests/data/test_history.py b/tests/data/test_history.py index 12390538a..6fd4d9569 100644 --- a/tests/data/test_history.py +++ b/tests/data/test_history.py @@ -547,6 +547,17 @@ 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 + # clean files freshly downloaded _clean_test_file(file1) @@ -601,7 +612,7 @@ def test_jsondatahandler_ohlcv_get_pairs(testdatadir): 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 +625,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..163ceff4b 100644 --- a/tests/edge/test_edge.py +++ b/tests/edge/test_edge.py @@ -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() diff --git a/tests/exchange/test_binance.py b/tests/exchange/test_binance.py index e4599dcd7..52faa284b 100644 --- a/tests/exchange/test_binance.py +++ b/tests/exchange/test_binance.py @@ -9,7 +9,12 @@ from freqtrade.exceptions import (DependencyException, InvalidOrderException, from tests.conftest import get_patched_exchange -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 +25,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 +36,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 +46,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 diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 3c92612a0..e40f691a8 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -517,9 +517,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): @@ -1413,13 +1413,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 +1427,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): @@ -1537,18 +1537,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 +1594,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 +1604,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 +1614,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 +1630,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 +1640,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 +1650,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 +1666,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 +1676,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 +1686,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 +1698,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 +1720,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) @@ -2145,3 +2145,58 @@ 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), + # TODO: More tests here! + # 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), +]) +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 diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index 1c4d3b16a..ace82d28b 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 @@ -333,8 +334,9 @@ def test_backtesting_start(default_conf, mocker, testdatadir, caplog) -> None: 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['datadir'] = testdatadir default_conf['export'] = None @@ -362,9 +364,9 @@ 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['datadir'] = testdatadir default_conf['export'] = None @@ -375,6 +377,29 @@ 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['ticker_interval'] = "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_backtest(default_conf, fee, mocker, testdatadir) -> None: default_conf['ask_strategy']['use_sell_signal'] = False mocker.patch('freqtrade.exchange.Exchange.get_fee', fee) @@ -530,7 +555,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 @@ -585,12 +610,12 @@ 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.pairlist.pairlistmanager.PairListManager.whitelist', + PropertyMock(return_value=['UNITTEST/BTC'])) patched_configuration_load_config_file(mocker, default_conf) args = [ @@ -624,17 +649,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) + sell_reason_mock = MagicMock() gen_strattable_mock = MagicMock() - mocker.patch('freqtrade.optimize.optimize_reports.generate_text_table_strategy', - gen_strattable_mock) + gen_strat_summary = MagicMock() + + mocker.patch.multiple('freqtrade.optimize.optimize_reports', + generate_text_table=gen_table_mock, + generate_text_table_strategy=gen_strattable_mock, + generate_pair_metrics=MagicMock(), + generate_sell_reason_stats=sell_reason_mock, + generate_strategy_metrics=gen_strat_summary, + ) patched_configuration_load_config_file(mocker, default_conf) args = [ @@ -656,6 +690,8 @@ def test_backtest_start_multi_strat(default_conf, mocker, caplog, testdatadir): assert backtestmock.call_count == 2 assert gen_table_mock.call_count == 4 assert gen_strattable_mock.call_count == 1 + assert sell_reason_mock.call_count == 2 + assert gen_strat_summary.call_count == 1 # check the logs, that will contain the backtest result exists = [ @@ -676,3 +712,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'), + '--ticker-interval', '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/--ticker-interval detected ... Using ticker_interval: 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_hyperopt.py b/tests/optimize/test_hyperopt.py index b5106be0c..90e047954 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() @@ -477,28 +478,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() @@ -817,7 +820,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..8bef6e2cc 100644 --- a/tests/optimize/test_optimize_reports.py +++ b/tests/optimize/test_optimize_reports.py @@ -1,11 +1,13 @@ 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_pair_metrics, generate_edge_table, generate_sell_reason_stats, + generate_text_table, generate_text_table_sell_reason, generate_strategy_metrics, generate_text_table_strategy, store_backtest_result) from freqtrade.strategy.interface import SellType from tests.conftest import patch_exchange @@ -35,12 +37,39 @@ 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 generate_text_table(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_generate_text_table_sell_reason(default_conf): results = pd.DataFrame( { @@ -65,8 +94,46 @@ 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 generate_text_table_sell_reason(sell_reason_stats=sell_reason_stats, + stake_currency='BTC') == result_str + + +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_generate_text_table_strategy(default_conf, mocker): @@ -106,7 +173,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 generate_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..e9e688b78 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") @@ -36,6 +34,28 @@ 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 static_pl_conf(whitelist_conf): whitelist_conf['pairlists'] = [ @@ -55,7 +75,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 +89,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 +136,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,29 +189,41 @@ 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']), + # 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": "PrecisionFilter"}, + {"method": "PriceFilter", "low_price_ratio": 0.03}, + {"method": "SpreadFilter", "max_spread_ratio": 0.005}, + {"method": "ShuffleFilter"}], + "ETH", []), # Precisionfilter and quote volume ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, {"method": "PrecisionFilter"}], "BTC", ['ETH/BTC', 'TKN/BTC', 'LTC/BTC', 'XRP/BTC']), @@ -176,36 +233,49 @@ def test_VolumePairList_refresh_empty(mocker, markets_empty, whitelist_conf): # 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']), + "USDT", ['ETH/USDT', 'NANO/USDT']), # Hot is removed by precision_filter, Fuel by low_price_filter. ([{"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}], + "BTC", ['ETH/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']), # 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", ['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", ['ETH/USDT', 'ADAHALF/USDT', 'NANO/USDT']), + # ShuffleFilter, other seed + ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, + {"method": "ShuffleFilter", "seed": 42}], + "USDT", ['NANO/USDT', 'ETH/USDT', 'ADAHALF/USDT']), + # ShuffleFilter, no seed + ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}, + {"method": "ShuffleFilter"}], + "USDT", 3), ]) def test_VolumePairList_whitelist_gen(mocker, whitelist_conf, shitcoinmarkets, tickers, 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) @@ -215,32 +285,44 @@ def test_VolumePairList_whitelist_gen(mocker, whitelist_conf, shitcoinmarkets, t markets=PropertyMock(return_value=shitcoinmarkets), ) - freqtrade.config['stake_currency'] = base_currency freqtrade.pairlists.refresh_pairlist() whitelist = freqtrade.pairlists.whitelist - assert whitelist == whitelist_result + 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'] == 'PrecisionFilter': + 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': + 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, because ticker\['last'\] 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_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,7 +365,8 @@ 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 @@ -305,18 +388,18 @@ 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_pairlistmanager_no_pairlist(mocker, markets, whitelist_conf, caplog): @@ -325,5 +408,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..e94097545 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -49,13 +49,19 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: 'base_currency': 'BTC', 'open_date': ANY, 'open_date_hum': ANY, + 'open_timestamp': ANY, 'is_open': ANY, 'fee_open': ANY, + 'fee_open_cost': ANY, + 'fee_open_currency': ANY, 'fee_close': ANY, + '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, @@ -63,13 +69,16 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: '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, 'stake_amount': 0.001, 'close_profit': None, - 'current_profit': -0.41, + 'close_profit_pct': None, + 'current_profit': -0.00408133, + 'current_profit_pct': -0.41, 'stop_loss': 0.0, 'initial_stop_loss': 0.0, 'initial_stop_loss_pct': None, @@ -78,7 +87,7 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: } == results[0] mocker.patch('freqtrade.freqtradebot.FreqtradeBot.get_sell_rate', - MagicMock(side_effect=DependencyException(f"Pair 'ETH/BTC' not available"))) + MagicMock(side_effect=DependencyException("Pair 'ETH/BTC' not available"))) results = rpc._rpc_trade_status() assert isnan(results[0]['current_profit']) assert isnan(results[0]['current_rate']) @@ -88,13 +97,19 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: 'base_currency': 'BTC', 'open_date': ANY, 'open_date_hum': ANY, + 'open_timestamp': ANY, 'is_open': ANY, 'fee_open': ANY, + 'fee_open_cost': ANY, + 'fee_open_currency': ANY, 'fee_close': ANY, + '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, @@ -102,13 +117,16 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: '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, 'stake_amount': 0.001, 'close_profit': None, + 'close_profit_pct': None, 'current_profit': ANY, + 'current_profit_pct': ANY, 'stop_loss': 0.0, 'initial_stop_loss': 0.0, 'initial_stop_loss_pct': None, @@ -157,7 +175,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=DependencyException("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 +213,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*'): @@ -307,7 +327,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=DependencyException("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' diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 6548790cb..cc63bf6e8 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -39,16 +39,21 @@ 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': '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': 'example.com'}) -def assert_response(response, expected_code=200): +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 def test_api_not_found(botclient): @@ -65,12 +70,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 +99,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': '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': '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 +157,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()) @@ -283,6 +323,7 @@ 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['state'] == 'running' assert not rc.json['trailing_stop'] @@ -298,8 +339,10 @@ 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): @@ -377,7 +420,9 @@ 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_percent': 6.2, @@ -454,14 +499,18 @@ def test_api_status(botclient, mocker, ticker, fee, markets): 'base_currency': 'BTC', 'close_date': None, 'close_date_hum': None, + 'close_timestamp': None, 'close_profit': None, + 'close_profit_pct': None, 'close_rate': None, - 'current_profit': -0.41, + 'current_profit': -0.00408133, + 'current_profit_pct': -0.41, 'current_rate': 1.099e-05, 'initial_stop_loss': 0.0, 'initial_stop_loss_pct': None, 'open_date': ANY, 'open_date_hum': 'just now', + 'open_timestamp': ANY, 'open_order': '(limit buy rem=0.00000000)', 'open_rate': 1.098e-05, 'pair': 'ETH/BTC', @@ -472,7 +521,11 @@ def test_api_status(botclient, mocker, ticker, fee, markets): '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, @@ -481,6 +534,7 @@ def test_api_status(botclient, mocker, ticker, fee, markets): 'open_rate_requested': 1.098e-05, 'open_trade_price': 0.0010025, 'sell_reason': None, + 'sell_order_status': None, 'strategy': 'DefaultStrategy', 'ticker_interval': 5}] @@ -561,11 +615,13 @@ def test_api_forcebuy(botclient, mocker, fee): assert rc.json == {'amount': 1, '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, @@ -575,7 +631,11 @@ def test_api_forcebuy(botclient, mocker, fee): 'close_profit': 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, @@ -583,6 +643,7 @@ def test_api_forcebuy(botclient, mocker, fee): 'open_rate_requested': None, 'open_trade_price': 0.2460546025, 'sell_reason': None, + 'sell_order_status': None, 'strategy': None, 'ticker_interval': None } diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index bbc961763..730bb2677 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -166,10 +166,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)' diff --git a/tests/test_configuration.py b/tests/test_configuration.py index f29f2eaf2..edcbe4516 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') @@ -1041,18 +1041,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': { diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 6f2ce9f3c..5e951b585 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -11,18 +11,21 @@ import arrow import pytest import requests -from freqtrade.constants import MATH_CLOSE_PREC, UNLIMITED_STAKE_AMOUNT +from freqtrade.constants import (CANCEL_REASON, MATH_CLOSE_PREC, + UNLIMITED_STAKE_AMOUNT) from freqtrade.exceptions import (DependencyException, InvalidOrderException, - OperationalException, TemporaryError) + 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 +51,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: @@ -1140,7 +1161,8 @@ 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) assert freqtrade.handle_stoploss_on_exchange(trade) is True @@ -1957,6 +1979,10 @@ def test_check_handle_timedout_buy_usercustom(default_conf, ticker, limit_buy_or 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() @@ -2087,6 +2113,9 @@ def test_check_handle_timedout_sell_usercustom(default_conf, ticker, limit_sell_ 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 @@ -2202,6 +2231,7 @@ 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', @@ -2221,7 +2251,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 @@ -2229,9 +2259,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, @@ -2281,8 +2312,8 @@ 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', @@ -2302,28 +2333,54 @@ 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) 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 + limit_buy_order['filled'] = 2 mocker.patch('freqtrade.exchange.Exchange.cancel_order', side_effect=InvalidOrderException) - assert not freqtrade.handle_timedout_limit_buy(trade, limit_buy_order) + assert not freqtrade.handle_cancel_buy(trade, limit_buy_order, reason) + + +@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', [ @@ -2332,8 +2389,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) @@ -2343,42 +2400,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: @@ -3061,10 +3156,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 @@ -3107,8 +3200,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', @@ -3164,9 +3257,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', @@ -3230,7 +3322,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%) @@ -3242,9 +3334,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 @@ -3285,8 +3376,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', @@ -3297,21 +3386,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', @@ -3322,8 +3433,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 @@ -3335,8 +3445,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( @@ -3348,8 +3456,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 @@ -3362,8 +3469,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( @@ -3375,8 +3480,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 @@ -3386,8 +3490,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( @@ -3399,16 +3501,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( @@ -3420,13 +3519,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) @@ -3435,8 +3533,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)) @@ -3449,13 +3545,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) @@ -3463,8 +3558,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( @@ -3476,8 +3569,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 @@ -3487,8 +3579,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( @@ -3500,8 +3590,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"): @@ -3514,8 +3603,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( @@ -3527,8 +3614,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), @@ -3539,8 +3625,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( @@ -3553,15 +3637,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', @@ -3576,19 +3657,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, @@ -3625,7 +3735,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, @@ -3650,7 +3760,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, ) @@ -3665,29 +3775,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: @@ -3697,7 +3804,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' @@ -3711,11 +3818,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 @@ -3749,6 +3856,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', [ @@ -3789,9 +3903,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) @@ -3802,6 +3915,22 @@ 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_startup_state(default_conf, mocker): default_conf['pairlist'] = {'method': 'VolumePairList', 'config': {'number_assets': 20} @@ -3858,3 +3987,20 @@ 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.get_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) == 3 + freqtrade.cancel_all_open_orders() + assert buy_mock.call_count == 1 + assert sell_mock.call_count == 1 diff --git a/tests/test_integration.py b/tests/test_integration.py index c40da7e9d..1396e86f5 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]) @@ -67,7 +69,6 @@ def test_may_execute_sell_stoploss_on_exchange_multi(default_conf, ticker, fee, 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) @@ -97,8 +98,9 @@ def test_may_execute_sell_stoploss_on_exchange_multi(default_conf, ticker, fee, # 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 +146,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..11d0ede3a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -115,6 +115,32 @@ def test_main_operational_exception(mocker, default_conf, caplog) -> None: assert log_has('Oh snap!', caplog) +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_conf(mocker, default_conf, caplog) -> None: patch_exchange(mocker) mocker.patch('freqtrade.freqtradebot.FreqtradeBot.cleanup', MagicMock()) diff --git a/tests/test_persistence.py b/tests/test_persistence.py index ceac24356..60bf073f8 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -465,6 +465,10 @@ 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 trade = Trade.query.filter(Trade.id == 2).first() assert trade.close_rate is not None @@ -473,6 +477,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): @@ -734,20 +739,27 @@ 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, 'stake_amount': 0.001, 'close_profit': None, 'sell_reason': None, + 'sell_order_status': None, 'stop_loss': None, 'stop_loss_pct': None, 'initial_stop_loss': None, @@ -777,8 +789,10 @@ 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, @@ -790,7 +804,11 @@ def test_to_json(default_conf, fee): 'close_profit': 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,6 +816,7 @@ 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} @@ -862,6 +881,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): diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 0258b94d1..5bb113784 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -374,7 +374,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 +384,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