diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 53b2e5440..cc8906af5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,6 +7,8 @@ on: - develop - github_actions_tests tags: + release: + types: [published] pull_request: schedule: - cron: '0 5 * * 4' @@ -18,7 +20,7 @@ jobs: strategy: matrix: os: [ ubuntu-18.04, macos-latest ] - python-version: [3.7] + python-version: [3.7, 3.8] steps: - uses: actions/checkout@v1 @@ -68,7 +70,7 @@ jobs: pytest --random-order --cov=freqtrade --cov-config=.coveragerc - name: Coveralls - if: startsWith(matrix.os, 'ubuntu') + if: (startsWith(matrix.os, 'ubuntu') && matrix.python-version == '3.8') env: # Coveralls token. Not used as secret due to github not providing secrets to forked repositories COVERALLS_REPO_TOKEN: 6D1m0xupS3FgutfuGao8keFf9Hc0FpIXu @@ -191,15 +193,40 @@ jobs: deploy: needs: [ build, build_windows, docs_check ] runs-on: ubuntu-18.04 - if: (github.event_name == 'push' || github.event_name == 'schedule') && github.repository == 'freqtrade/freqtrade' + if: (github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'release') && github.repository == 'freqtrade/freqtrade' steps: - uses: actions/checkout@v1 + - name: Set up Python + uses: actions/setup-python@v1 + with: + python-version: 3.8 + - name: Extract branch name shell: bash run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" id: extract_branch + - name: Build distribution + run: | + pip install -U setuptools wheel + python setup.py sdist bdist_wheel + + - name: Publish to PyPI (Test) + uses: pypa/gh-action-pypi-publish@master + if: (steps.extract_branch.outputs.branch == 'master' || github.event_name == 'release') + with: + user: __token__ + password: ${{ secrets.pypi_test_password }} + repository_url: https://test.pypi.org/legacy/ + + - name: Publish to PyPI + uses: pypa/gh-action-pypi-publish@master + if: (steps.extract_branch.outputs.branch == 'master' || github.event_name == 'release') + with: + user: __token__ + password: ${{ secrets.pypi_password }} + - name: Build and test and push docker image env: IMAGE_NAME: freqtradeorg/freqtrade diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a087103c6..90594866a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,7 +48,7 @@ pytest tests/test_.py::test_ #### Run Flake8 ```bash -flake8 freqtrade +flake8 freqtrade tests scripts ``` We receive a lot of code that fails the `flake8` checks. @@ -109,11 +109,11 @@ Exceptions: Contributors may be given commit privileges. Preference will be given to those with: -1. Past contributions to FreqTrade and other related open-source projects. Contributions to FreqTrade include both code (both accepted and pending) and friendly participation in the issue tracker and Pull request reviews. Quantity and quality are considered. +1. Past contributions to Freqtrade and other related open-source projects. Contributions to Freqtrade include both code (both accepted and pending) and friendly participation in the issue tracker and Pull request reviews. Quantity and quality are considered. 1. A coding style that the other core committers find simple, minimal, and clean. 1. Access to resources for cross-platform development and testing. 1. Time to devote to the project regularly. -Being a Committer does not grant write permission on `develop` or `master` for security reasons (Users trust FreqTrade with their Exchange API keys). +Being a Committer does not grant write permission on `develop` or `master` for security reasons (Users trust Freqtrade with their Exchange API keys). After being Committer for some time, a Committer may be named Core Committer and given full repository access. diff --git a/Dockerfile b/Dockerfile index f631d891d..923285f39 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.7.6-slim-stretch +FROM python:3.8.1-slim-buster RUN apt-get update \ && apt-get -y install curl build-essential libssl-dev \ diff --git a/README.md b/README.md index a1feeab67..59799da84 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Freqtrade -[![Build Status](https://travis-ci.org/freqtrade/freqtrade.svg?branch=develop)](https://travis-ci.org/freqtrade/freqtrade) +[![Freqtrade CI](https://github.com/freqtrade/freqtrade/workflows/Freqtrade%20CI/badge.svg)](https://github.com/freqtrade/freqtrade/actions/) [![Coverage Status](https://coveralls.io/repos/github/freqtrade/freqtrade/badge.svg?branch=develop&service=github)](https://coveralls.io/github/freqtrade/freqtrade?branch=develop) [![Documentation](https://readthedocs.org/projects/freqtrade/badge/)](https://www.freqtrade.io) [![Maintainability](https://api.codeclimate.com/v1/badges/5737e6d668200b7518ff/maintainability)](https://codeclimate.com/github/freqtrade/freqtrade/maintainability) diff --git a/bin/freqtrade b/bin/freqtrade index 25c94fe98..eee7cbef4 100755 --- a/bin/freqtrade +++ b/bin/freqtrade @@ -1,11 +1,11 @@ #!/usr/bin/env python3 import sys -import warnings +import logging -from freqtrade.main import main +logger = logging.getLogger(__name__) -warnings.warn( - "Deprecated - To continue to run the bot like this, please run `pip install -e .` again.", - DeprecationWarning) -main(sys.argv[1:]) + +logger.error("DEPRECATED installation detected, please run `pip install -e .` again.") + +sys.exit(2) diff --git a/build_helpers/publish_docker.sh b/build_helpers/publish_docker.sh index 17d5230c9..013644563 100755 --- a/build_helpers/publish_docker.sh +++ b/build_helpers/publish_docker.sh @@ -23,7 +23,7 @@ if [ $? -ne 0 ]; then fi # Run backtest -docker run --rm -v $(pwd)/config.json.example:/freqtrade/config.json:ro -v $(pwd)/tests:/tests freqtrade:${TAG} backtesting --datadir /tests/testdata --strategy DefaultStrategy +docker run --rm -v $(pwd)/config.json.example:/freqtrade/config.json:ro -v $(pwd)/tests:/tests freqtrade:${TAG} backtesting --datadir /tests/testdata --strategy-path /tests/strategy/strats/ --strategy DefaultStrategy if [ $? -ne 0 ]; then echo "failed running backtest" diff --git a/config.json.example b/config.json.example index 8b85e71eb..46441e72d 100644 --- a/config.json.example +++ b/config.json.example @@ -4,7 +4,7 @@ "stake_amount": 0.05, "tradable_balance_ratio": 0.99, "fiat_display_currency": "USD", - "ticker_interval" : "5m", + "ticker_interval": "5m", "dry_run": false, "trailing_stop": false, "unfilledtimeout": { @@ -44,7 +44,7 @@ "DASH/BTC", "ZEC/BTC", "XLM/BTC", - "NXT/BTC", + "XRP/BTC", "TRX/BTC", "ADA/BTC", "XMR/BTC" diff --git a/config_binance.json.example b/config_binance.json.example index 0521a3a35..e2c9879b0 100644 --- a/config_binance.json.example +++ b/config_binance.json.example @@ -4,7 +4,7 @@ "stake_amount": 0.05, "tradable_balance_ratio": 0.99, "fiat_display_currency": "USD", - "ticker_interval" : "5m", + "ticker_interval": "5m", "dry_run": true, "trailing_stop": false, "unfilledtimeout": { diff --git a/config_full.json.example b/config_full.json.example index 82d8bd04a..cdb7e841e 100644 --- a/config_full.json.example +++ b/config_full.json.example @@ -4,7 +4,7 @@ "stake_amount": 0.05, "tradable_balance_ratio": 0.99, "fiat_display_currency": "USD", - "amount_reserve_percent" : 0.05, + "amount_reserve_percent": 0.05, "amend_last_stake_amount": false, "last_stake_amount_min_ratio": 0.5, "dry_run": false, @@ -62,8 +62,8 @@ "refresh_period": 1800 }, {"method": "PrecisionFilter"}, - {"method": "PriceFilter", "low_price_ratio": 0.01 - } + {"method": "PriceFilter", "low_price_ratio": 0.01}, + {"method": "SpreadFilter", "max_spread_ratio": 0.005} ], "exchange": { "name": "bittrex", @@ -129,5 +129,7 @@ "heartbeat_interval": 60 }, "strategy": "DefaultStrategy", - "strategy_path": "user_data/strategies/" + "strategy_path": "user_data/strategies/", + "dataformat_ohlcv": "json", + "dataformat_trades": "jsongz" } diff --git a/config_kraken.json.example b/config_kraken.json.example index a527b569d..4f74d0b7d 100644 --- a/config_kraken.json.example +++ b/config_kraken.json.example @@ -4,7 +4,7 @@ "stake_amount": 10, "tradable_balance_ratio": 0.99, "fiat_display_currency": "EUR", - "ticker_interval" : "5m", + "ticker_interval": "5m", "dry_run": true, "trailing_stop": false, "unfilledtimeout": { diff --git a/docker-compose.yml b/docker-compose.yml index cae98c3ee..3a4c4c2db 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,18 @@ version: '3' services: freqtrade: image: freqtradeorg/freqtrade:master + # Build step - only needed when additional dependencies are needed + # build: + # context: . + # dockerfile: "./Dockerfile.technical" + restart: unless-stopped + container_name: freqtrade volumes: - "./user_data:/freqtrade/user_data" - - "./config.json:/freqtrade/config.json" + # Default command used when running `docker compose up` + command: > + trade + --logfile /freqtrade/user_data/freqtrade.log + --db-url sqlite:////freqtrade/user_data/tradesv3.sqlite + --config /freqtrade/user_data/config.json + --strategy SampleStrategy diff --git a/docs/advanced-hyperopt.md b/docs/advanced-hyperopt.md index 20af0aaab..25b4bd900 100644 --- a/docs/advanced-hyperopt.md +++ b/docs/advanced-hyperopt.md @@ -4,6 +4,34 @@ This page explains some advanced Hyperopt topics that may require higher coding skills and Python knowledge than creation of an ordinal hyperoptimization class. +## Derived hyperopt classes + +Custom hyperop classes can be derived in the same way [it can be done for strategies](strategy-customization.md#derived-strategies). + +Applying to hyperoptimization, as an example, you may override how dimensions are defined in your optimization hyperspace: + +```python +class MyAwesomeHyperOpt(IHyperOpt): + ... + # Uses default stoploss dimension + +class MyAwesomeHyperOpt2(MyAwesomeHyperOpt): + @staticmethod + def stoploss_space() -> List[Dimension]: + # Override boundaries for stoploss + return [ + Real(-0.33, -0.01, name='stoploss'), + ] +``` + +and then quickly switch between hyperopt classes, running optimization process with hyperopt class you need in each particular case: + +``` +$ freqtrade hyperopt --hyperopt MyAwesomeHyperOpt ... +or +$ freqtrade hyperopt --hyperopt MyAwesomeHyperOpt2 ... +``` + ## Creating and using a custom loss function To use a custom loss function class, make sure that the function `hyperopt_loss_function` is defined in your custom hyperopt loss class. diff --git a/docs/backtesting.md b/docs/backtesting.md index 41428085d..79bfa2350 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -119,40 +119,40 @@ A backtesting result will look like that: ``` ========================================================= BACKTESTING REPORT ======================================================== -| pair | buy count | avg profit % | cum profit % | tot profit BTC | tot profit % | avg duration | profit | loss | -|:---------|------------:|---------------:|---------------:|-----------------:|---------------:|:---------------|---------:|-------:| -| ADA/BTC | 35 | -0.11 | -3.88 | -0.00019428 | -1.94 | 4:35:00 | 14 | 21 | -| ARK/BTC | 11 | -0.41 | -4.52 | -0.00022647 | -2.26 | 2:03:00 | 3 | 8 | -| BTS/BTC | 32 | 0.31 | 9.78 | 0.00048938 | 4.89 | 5:05:00 | 18 | 14 | -| DASH/BTC | 13 | -0.08 | -1.07 | -0.00005343 | -0.53 | 4:39:00 | 6 | 7 | -| ENG/BTC | 18 | 1.36 | 24.54 | 0.00122807 | 12.27 | 2:50:00 | 8 | 10 | -| EOS/BTC | 36 | 0.08 | 3.06 | 0.00015304 | 1.53 | 3:34:00 | 16 | 20 | -| ETC/BTC | 26 | 0.37 | 9.51 | 0.00047576 | 4.75 | 6:14:00 | 11 | 15 | -| ETH/BTC | 33 | 0.30 | 9.96 | 0.00049856 | 4.98 | 7:31:00 | 16 | 17 | -| IOTA/BTC | 32 | 0.03 | 1.09 | 0.00005444 | 0.54 | 3:12:00 | 14 | 18 | -| LSK/BTC | 15 | 1.75 | 26.26 | 0.00131413 | 13.13 | 2:58:00 | 6 | 9 | -| LTC/BTC | 32 | -0.04 | -1.38 | -0.00006886 | -0.69 | 4:49:00 | 11 | 21 | -| NANO/BTC | 17 | 1.26 | 21.39 | 0.00107058 | 10.70 | 1:55:00 | 10 | 7 | -| NEO/BTC | 23 | 0.82 | 18.97 | 0.00094936 | 9.48 | 2:59:00 | 10 | 13 | -| REQ/BTC | 9 | 1.17 | 10.54 | 0.00052734 | 5.27 | 3:47:00 | 4 | 5 | -| XLM/BTC | 16 | 1.22 | 19.54 | 0.00097800 | 9.77 | 3:15:00 | 7 | 9 | -| XMR/BTC | 23 | -0.18 | -4.13 | -0.00020696 | -2.07 | 5:30:00 | 12 | 11 | -| XRP/BTC | 35 | 0.66 | 22.96 | 0.00114897 | 11.48 | 3:49:00 | 12 | 23 | -| ZEC/BTC | 22 | -0.46 | -10.18 | -0.00050971 | -5.09 | 2:22:00 | 7 | 15 | -| TOTAL | 429 | 0.36 | 152.41 | 0.00762792 | 76.20 | 4:12:00 | 186 | 243 | +| Pair | Buys | Avg Profit % | Cum Profit % | Tot Profit BTC | Tot Profit % | Avg Duration | Wins | Draws | Losses | +|:---------|-------:|---------------:|---------------:|-----------------:|---------------:|:---------------|------:|-------:|--------:| +| ADA/BTC | 35 | -0.11 | -3.88 | -0.00019428 | -1.94 | 4:35:00 | 14 | 0 | 21 | +| ARK/BTC | 11 | -0.41 | -4.52 | -0.00022647 | -2.26 | 2:03:00 | 3 | 0 | 8 | +| BTS/BTC | 32 | 0.31 | 9.78 | 0.00048938 | 4.89 | 5:05:00 | 18 | 0 | 14 | +| DASH/BTC | 13 | -0.08 | -1.07 | -0.00005343 | -0.53 | 4:39:00 | 6 | 0 | 7 | +| ENG/BTC | 18 | 1.36 | 24.54 | 0.00122807 | 12.27 | 2:50:00 | 8 | 0 | 10 | +| EOS/BTC | 36 | 0.08 | 3.06 | 0.00015304 | 1.53 | 3:34:00 | 16 | 0 | 20 | +| ETC/BTC | 26 | 0.37 | 9.51 | 0.00047576 | 4.75 | 6:14:00 | 11 | 0 | 15 | +| ETH/BTC | 33 | 0.30 | 9.96 | 0.00049856 | 4.98 | 7:31:00 | 16 | 0 | 17 | +| IOTA/BTC | 32 | 0.03 | 1.09 | 0.00005444 | 0.54 | 3:12:00 | 14 | 0 | 18 | +| LSK/BTC | 15 | 1.75 | 26.26 | 0.00131413 | 13.13 | 2:58:00 | 6 | 0 | 9 | +| LTC/BTC | 32 | -0.04 | -1.38 | -0.00006886 | -0.69 | 4:49:00 | 11 | 0 | 21 | +| NANO/BTC | 17 | 1.26 | 21.39 | 0.00107058 | 10.70 | 1:55:00 | 10 | 0 | 7 | +| NEO/BTC | 23 | 0.82 | 18.97 | 0.00094936 | 9.48 | 2:59:00 | 10 | 0 | 13 | +| REQ/BTC | 9 | 1.17 | 10.54 | 0.00052734 | 5.27 | 3:47:00 | 4 | 0 | 5 | +| XLM/BTC | 16 | 1.22 | 19.54 | 0.00097800 | 9.77 | 3:15:00 | 7 | 0 | 9 | +| XMR/BTC | 23 | -0.18 | -4.13 | -0.00020696 | -2.07 | 5:30:00 | 12 | 0 | 11 | +| XRP/BTC | 35 | 0.66 | 22.96 | 0.00114897 | 11.48 | 3:49:00 | 12 | 0 | 23 | +| ZEC/BTC | 22 | -0.46 | -10.18 | -0.00050971 | -5.09 | 2:22:00 | 7 | 0 | 15 | +| TOTAL | 429 | 0.36 | 152.41 | 0.00762792 | 76.20 | 4:12:00 | 186 | 0 | 243 | ========================================================= SELL REASON STATS ========================================================= -| Sell Reason | Count | Profit | Loss | -|:-------------------|--------:|---------:|-------:| -| trailing_stop_loss | 205 | 150 | 55 | -| stop_loss | 166 | 0 | 166 | -| sell_signal | 56 | 36 | 20 | -| force_sell | 2 | 0 | 2 | +| Sell Reason | Sells | Wins | Draws | Losses | +|:-------------------|--------:|------:|-------:|--------:| +| trailing_stop_loss | 205 | 150 | 0 | 55 | +| stop_loss | 166 | 0 | 0 | 166 | +| sell_signal | 56 | 36 | 0 | 20 | +| force_sell | 2 | 0 | 0 | 2 | ====================================================== LEFT OPEN TRADES REPORT ====================================================== -| pair | buy count | avg profit % | cum profit % | tot profit BTC | tot profit % | avg duration | profit | loss | -|:---------|------------:|---------------:|---------------:|-----------------:|---------------:|:---------------|---------:|-------:| -| ADA/BTC | 1 | 0.89 | 0.89 | 0.00004434 | 0.44 | 6:00:00 | 1 | 0 | -| LTC/BTC | 1 | 0.68 | 0.68 | 0.00003421 | 0.34 | 2:00:00 | 1 | 0 | -| TOTAL | 2 | 0.78 | 1.57 | 0.00007855 | 0.78 | 4:00:00 | 2 | 0 | +| Pair | Buys | Avg Profit % | Cum Profit % | Tot Profit BTC | Tot Profit % | Avg Duration | Wins | Draws | Losses | +|:---------|-------:|---------------:|---------------:|-----------------:|---------------:|:---------------|------:|-------:|--------:| +| ADA/BTC | 1 | 0.89 | 0.89 | 0.00004434 | 0.44 | 6:00:00 | 1 | 0 | 0 | +| LTC/BTC | 1 | 0.68 | 0.68 | 0.00003421 | 0.34 | 2:00:00 | 1 | 0 | 0 | +| TOTAL | 2 | 0.78 | 1.57 | 0.00007855 | 0.78 | 4:00:00 | 2 | 0 | 0 | ``` The 1st table contains all trades the bot made, including "left open trades". @@ -237,11 +237,11 @@ There will be an additional table comparing win/losses of the different strategi Detailed output for all strategies one after the other will be available, so make sure to scroll up to see the details per strategy. ``` -=========================================================== Strategy Summary =========================================================== -| Strategy | buy count | avg profit % | cum profit % | tot profit BTC | tot profit % | avg duration | profit | loss | -|:------------|------------:|---------------:|---------------:|-----------------:|---------------:|:---------------|---------:|-------:| -| Strategy1 | 429 | 0.36 | 152.41 | 0.00762792 | 76.20 | 4:12:00 | 186 | 243 | -| Strategy2 | 1487 | -0.13 | -197.58 | -0.00988917 | -98.79 | 4:43:00 | 662 | 825 | +=========================================================== STRATEGY SUMMARY =========================================================== +| Strategy | Buys | Avg Profit % | Cum Profit % | Tot Profit BTC | Tot Profit % | Avg Duration | Wins | Draws | Losses | +|:------------|-------:|---------------:|---------------:|-----------------:|---------------:|:---------------|------:|-------:|-------:| +| Strategy1 | 429 | 0.36 | 152.41 | 0.00762792 | 76.20 | 4:12:00 | 186 | 0 | 243 | +| Strategy2 | 1487 | -0.13 | -197.58 | -0.00988917 | -98.79 | 4:43:00 | 662 | 0 | 825 | ``` ## Next step diff --git a/docs/bot-usage.md b/docs/bot-usage.md index e856755d2..dbc111d44 100644 --- a/docs/bot-usage.md +++ b/docs/bot-usage.md @@ -58,9 +58,10 @@ Common arguments: details. -V, --version show program's version number and exit -c PATH, --config PATH - Specify configuration file (default: `config.json`). - Multiple --config options may be used. Can be set to - `-` to read config from stdin. + Specify configuration file (default: + `userdir/config.json` or `config.json` whichever + exists). Multiple --config options may be used. Can be + set to `-` to read config from stdin. -d PATH, --datadir PATH Path to directory with historical backtesting data. --userdir PATH, --user-data-dir PATH @@ -71,6 +72,7 @@ Strategy arguments: Specify strategy class name which will be used by the bot. --strategy-path PATH Specify additional strategy lookup path. +. ``` @@ -242,12 +244,15 @@ optional arguments: Common arguments: -v, --verbose Verbose mode (-vv for more, -vvv to get all messages). - --logfile FILE Log to the file specified. + --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: `config.json`). - Multiple --config options may be used. Can be set to - `-` to read config from stdin. + Specify configuration file (default: + `userdir/config.json` or `config.json` whichever + exists). Multiple --config options may be used. Can be + set to `-` to read config from stdin. -d PATH, --datadir PATH Path to directory with historical backtesting data. --userdir PATH, --user-data-dir PATH @@ -280,7 +285,7 @@ usage: freqtrade hyperopt [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] [--stake-amount STAKE_AMOUNT] [--fee FLOAT] [--hyperopt NAME] [--hyperopt-path PATH] [--eps] [-e INT] - [--spaces {all,buy,sell,roi,stoploss} [{all,buy,sell,roi,stoploss} ...]] + [--spaces {all,buy,sell,roi,stoploss,trailing,default} [{all,buy,sell,roi,stoploss,trailing,default} ...]] [--dmmp] [--print-all] [--no-color] [--print-json] [-j JOBS] [--random-state INT] [--min-trades INT] [--continue] [--hyperopt-loss NAME] @@ -308,9 +313,9 @@ optional arguments: Allow buying the same pair multiple times (position stacking). -e INT, --epochs INT Specify number of epochs (default: 100). - --spaces {all,buy,sell,roi,stoploss} [{all,buy,sell,roi,stoploss} ...] + --spaces {all,buy,sell,roi,stoploss,trailing,default} [{all,buy,sell,roi,stoploss,trailing,default} ...] Specify which parameters to hyperopt. Space-separated - list. Default: `all`. + list. --dmmp, --disable-max-market-positions Disable applying `max_open_trades` during backtest (same as setting `max_open_trades` to a very high @@ -337,17 +342,21 @@ optional arguments: generate completely different results, since the target for optimization is different. Built-in Hyperopt-loss-functions are: DefaultHyperOptLoss, - OnlyProfitHyperOptLoss, SharpeHyperOptLoss (default: + OnlyProfitHyperOptLoss, SharpeHyperOptLoss, + SharpeHyperOptLossDaily.(default: `DefaultHyperOptLoss`). Common arguments: -v, --verbose Verbose mode (-vv for more, -vvv to get all messages). - --logfile FILE Log to the file specified. + --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: `config.json`). - Multiple --config options may be used. Can be set to - `-` to read config from stdin. + Specify configuration file (default: + `userdir/config.json` or `config.json` whichever + exists). Multiple --config options may be used. Can be + set to `-` to read config from stdin. -d PATH, --datadir PATH Path to directory with historical backtesting data. --userdir PATH, --user-data-dir PATH @@ -358,6 +367,7 @@ Strategy arguments: Specify strategy class name which will be used by the bot. --strategy-path PATH Specify additional strategy lookup path. + ``` ## Edge commands @@ -394,12 +404,15 @@ optional arguments: Common arguments: -v, --verbose Verbose mode (-vv for more, -vvv to get all messages). - --logfile FILE Log to the file specified. + --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: `config.json`). - Multiple --config options may be used. Can be set to - `-` to read config from stdin. + Specify configuration file (default: + `userdir/config.json` or `config.json` whichever + exists). Multiple --config options may be used. Can be + set to `-` to read config from stdin. -d PATH, --datadir PATH Path to directory with historical backtesting data. --userdir PATH, --user-data-dir PATH @@ -410,6 +423,7 @@ Strategy arguments: Specify strategy class name which will be used by the bot. --strategy-path PATH Specify additional strategy lookup path. + ``` To understand edge and how to read the results, please read the [edge documentation](edge.md). diff --git a/docs/configuration.md b/docs/configuration.md index fe692eacb..e0dc43f5d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -40,75 +40,79 @@ Mandatory parameters are marked as **Required**, which means that they are requi | Parameter | Description | |------------|-------------| -| `max_open_trades` | **Required.** Number of trades open your bot will have. If -1 then it is ignored (i.e. potentially unlimited open trades). [More information below](#configuring-amount-per-trade).
***Datatype:*** *Positive integer or -1.* -| `stake_currency` | **Required.** Crypto-currency used for trading. [Strategy Override](#parameters-in-the-strategy).
***Datatype:*** *String* -| `stake_amount` | **Required.** Amount of crypto-currency your bot will use for each trade. Set it to `"unlimited"` to allow the bot to use all available balance. [More information below](#configuring-amount-per-trade). [Strategy Override](#parameters-in-the-strategy).
***Datatype:*** *Positive float or `"unlimited"`.* -| `tradable_balance_ratio` | Ratio of the total account balance the bot is allowed to trade. [More information below](#configuring-amount-per-trade).
*Defaults to `0.99` 99%).*
***Datatype:*** *Positive float between `0.1` and `1.0`.* -| `amend_last_stake_amount` | Use reduced last stake amount if necessary. [More information below](#configuring-amount-per-trade).
*Defaults to `false`.*
***Datatype:*** *Boolean* -| `last_stake_amount_min_ratio` | Defines minimum stake amount that has to be left and executed. Applies only to the last stake amount when it's amended to a reduced value (i.e. if `amend_last_stake_amount` is set to `true`). [More information below](#configuring-amount-per-trade).
*Defaults to `0.5`.*
***Datatype:*** *Float (as ratio)* -| `amount_reserve_percent` | Reserve some amount in min pair stake amount. The bot will reserve `amount_reserve_percent` + stoploss value when calculating min pair stake amount in order to avoid possible trade refusals.
*Defaults to `0.05` (5%).*
***Datatype:*** *Positive Float as ratio.* -| `ticker_interval` | The ticker interval to use (e.g `1m`, `5m`, `15m`, `30m`, `1h` ...). [Strategy Override](#parameters-in-the-strategy).
***Datatype:*** *String* -| `fiat_display_currency` | Fiat currency used to show your profits. [More information below](#what-values-can-be-used-for-fiat_display_currency).
***Datatype:*** *String* -| `dry_run` | **Required.** Define if the bot must be in Dry Run or production mode.
*Defaults to `true`.*
***Datatype:*** *Boolean* -| `dry_run_wallet` | Define the starting amount in stake currency for the simulated wallet used by the bot running in the Dry Run mode.
*Defaults to `1000`.*
***Datatype:*** *Float* -| `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)* -| `trailing_stop` | Enables trailing stoploss (based on `stoploss` in either configuration or strategy file). More details in the [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-the-strategy).
***Datatype:*** *Boolean* -| `trailing_stop_positive` | Changes stoploss once profit has been reached. More details in the [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-the-strategy).
***Datatype:*** *Float* -| `trailing_stop_positive_offset` | Offset on when to apply `trailing_stop_positive`. Percentage value which should be positive. More details in the [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-the-strategy).
*Defaults to `0.0` (no offset).*
***Datatype:*** *Float* -| `trailing_only_offset_is_reached` | Only apply trailing stoploss when the offset is reached. [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-the-strategy).
*Defaults to `false`.*
***Datatype:*** *Boolean* -| `unfilledtimeout.buy` | **Required.** How long (in minutes) the bot will wait for an unfilled buy order to complete, after which the order will be cancelled. [Strategy Override](#parameters-in-the-strategy).
***Datatype:*** *Integer* -| `unfilledtimeout.sell` | **Required.** How long (in minutes) the bot will wait for an unfilled sell order to complete, after which the order will be cancelled. [Strategy Override](#parameters-in-the-strategy).
***Datatype:*** *Integer* +| `max_open_trades` | **Required.** Number of trades open your bot will have. If -1 then it is ignored (i.e. potentially unlimited open trades). [More information below](#configuring-amount-per-trade).
**Datatype:** Positive integer or -1. +| `stake_currency` | **Required.** Crypto-currency used for trading. [Strategy Override](#parameters-in-the-strategy).
**Datatype:** String +| `stake_amount` | **Required.** Amount of crypto-currency your bot will use for each trade. Set it to `"unlimited"` to allow the bot to use all available balance. [More information below](#configuring-amount-per-trade). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Positive float or `"unlimited"`. +| `tradable_balance_ratio` | Ratio of the total account balance the bot is allowed to trade. [More information below](#configuring-amount-per-trade).
*Defaults to `0.99` 99%).*
**Datatype:** Positive float between `0.1` and `1.0`. +| `amend_last_stake_amount` | Use reduced last stake amount if necessary. [More information below](#configuring-amount-per-trade).
*Defaults to `false`.*
**Datatype:** Boolean +| `last_stake_amount_min_ratio` | Defines minimum stake amount that has to be left and executed. Applies only to the last stake amount when it's amended to a reduced value (i.e. if `amend_last_stake_amount` is set to `true`). [More information below](#configuring-amount-per-trade).
*Defaults to `0.5`.*
**Datatype:** Float (as ratio) +| `amount_reserve_percent` | Reserve some amount in min pair stake amount. The bot will reserve `amount_reserve_percent` + stoploss value when calculating min pair stake amount in order to avoid possible trade refusals.
*Defaults to `0.05` (5%).*
**Datatype:** Positive Float as ratio. +| `ticker_interval` | The ticker interval to use (e.g `1m`, `5m`, `15m`, `30m`, `1h` ...). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** String +| `fiat_display_currency` | Fiat currency used to show your profits. [More information below](#what-values-can-be-used-for-fiat_display_currency).
**Datatype:** String +| `dry_run` | **Required.** Define if the bot must be in Dry Run or production mode.
*Defaults to `true`.*
**Datatype:** Boolean +| `dry_run_wallet` | Define the starting amount in stake currency for the simulated wallet used by the bot running in the Dry Run mode.
*Defaults to `1000`.*
**Datatype:** Float +| `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) +| `trailing_stop` | Enables trailing stoploss (based on `stoploss` in either configuration or strategy file). More details in the [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Boolean +| `trailing_stop_positive` | Changes stoploss once profit has been reached. More details in the [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Float +| `trailing_stop_positive_offset` | Offset on when to apply `trailing_stop_positive`. Percentage value which should be positive. More details in the [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-the-strategy).
*Defaults to `0.0` (no offset).*
**Datatype:** Float +| `trailing_only_offset_is_reached` | Only apply trailing stoploss when the offset is reached. [stoploss documentation](stoploss.md). [Strategy Override](#parameters-in-the-strategy).
*Defaults to `false`.*
**Datatype:** Boolean +| `unfilledtimeout.buy` | **Required.** How long (in minutes) the bot will wait for an unfilled buy order to complete, after which the order will be cancelled. [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Integer +| `unfilledtimeout.sell` | **Required.** How long (in minutes) the bot will wait for an unfilled sell order to complete, after which the order will be cancelled. [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Integer | `bid_strategy.ask_last_balance` | **Required.** Set the bidding price. More information [below](#buy-price-without-orderbook). -| `bid_strategy.use_order_book` | Enable buying using the rates in [Order Book Bids](#buy-price-with-orderbook-enabled).
***Datatype:*** *Boolean* -| `bid_strategy.order_book_top` | Bot will use the top N rate in Order Book Bids to buy. I.e. a value of 2 will allow the bot to pick the 2nd bid rate in [Order Book Bids](#buy-price-with-orderbook-enabled).
*Defaults to `1`.*
***Datatype:*** *Positive Integer* -| `bid_strategy. check_depth_of_market.enabled` | Do not buy if the difference of buy orders and sell orders is met in Order Book. [Check market depth](#check-depth-of-market).
*Defaults to `false`.*
***Datatype:*** *Boolean* -| `bid_strategy. check_depth_of_market.bids_to_ask_delta` | The difference ratio of buy orders and sell orders found in Order Book. A value below 1 means sell order size is greater, while value greater than 1 means buy order size is higher. [Check market depth](#check-depth-of-market)
*Defaults to `0`.*
***Datatype:*** *Float (as ratio)* -| `ask_strategy.use_order_book` | Enable selling of open trades using [Order Book Asks](#sell-price-with-orderbook-enabled).
***Datatype:*** *Boolean* -| `ask_strategy.order_book_min` | Bot will scan from the top min to max Order Book Asks searching for a profitable rate.
*Defaults to `1`.*
***Datatype:*** *Positive Integer* -| `ask_strategy.order_book_max` | Bot will scan from the top min to max Order Book Asks searching for a profitable rate.
*Defaults to `1`.*
***Datatype:*** *Positive Integer* -| `ask_strategy.use_sell_signal` | Use sell signals produced by the strategy in addition to the `minimal_roi`. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `true`.*
***Datatype:*** *Boolean* -| `ask_strategy.sell_profit_only` | Wait until the bot makes a positive profit before taking a sell decision. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `false`.*
***Datatype:*** *Boolean* -| `ask_strategy.ignore_roi_if_buy_signal` | Do not sell if the buy signal is still active. This setting takes preference over `minimal_roi` and `use_sell_signal`. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `false`.*
***Datatype:*** *Boolean* -| `order_types` | Configure order-types depending on the action (`"buy"`, `"sell"`, `"stoploss"`, `"stoploss_on_exchange"`). [More information below](#understand-order_types). [Strategy Override](#parameters-in-the-strategy).
***Datatype:*** *Dict* -| `order_time_in_force` | Configure time in force for buy and sell orders. [More information below](#understand-order_time_in_force). [Strategy Override](#parameters-in-the-strategy).
***Datatype:*** *Dict* -| `exchange.name` | **Required.** Name of the exchange class to use. [List below](#user-content-what-values-for-exchangename).
***Datatype:*** *String* -| `exchange.sandbox` | Use the 'sandbox' version of the exchange, where the exchange provides a sandbox for risk-free integration. See [here](sandbox-testing.md) in more details.
***Datatype:*** *Boolean* -| `exchange.key` | API key to use for the exchange. Only required when you are in production mode.
**Keep it in secret, do not disclose publicly.**
***Datatype:*** *String* -| `exchange.secret` | API secret to use for the exchange. Only required when you are in production mode.
**Keep it in secret, do not disclose publicly.**
***Datatype:*** *String* -| `exchange.password` | API password to use for the exchange. Only required when you are in production mode and for exchanges that use password for API requests.
**Keep it in secret, do not disclose publicly.**
***Datatype:*** *String* -| `exchange.pair_whitelist` | List of pairs to use by the bot for trading and to check for potential trades during backtesting. Not used by VolumePairList (see [below](#dynamic-pairlists)).
***Datatype:*** *List* -| `exchange.pair_blacklist` | List of pairs the bot must absolutely avoid for trading and backtesting (see [below](#dynamic-pairlists)).
***Datatype:*** *List* -| `exchange.ccxt_config` | Additional CCXT parameters passed to the regular ccxt instance. Parameters may differ from exchange to exchange and are documented in the [ccxt documentation](https://ccxt.readthedocs.io/en/latest/manual.html#instantiation)
***Datatype:*** *Dict* -| `exchange.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* +| `bid_strategy.use_order_book` | Enable buying using the rates in [Order Book Bids](#buy-price-with-orderbook-enabled).
**Datatype:** Boolean +| `bid_strategy.order_book_top` | Bot will use the top N rate in Order Book Bids to buy. I.e. a value of 2 will allow the bot to pick the 2nd bid rate in [Order Book Bids](#buy-price-with-orderbook-enabled).
*Defaults to `1`.*
**Datatype:** Positive Integer +| `bid_strategy. check_depth_of_market.enabled` | Do not buy if the difference of buy orders and sell orders is met in Order Book. [Check market depth](#check-depth-of-market).
*Defaults to `false`.*
**Datatype:** Boolean +| `bid_strategy. check_depth_of_market.bids_to_ask_delta` | The difference ratio of buy orders and sell orders found in Order Book. A value below 1 means sell order size is greater, while value greater than 1 means buy order size is higher. [Check market depth](#check-depth-of-market)
*Defaults to `0`.*
**Datatype:** Float (as ratio) +| `ask_strategy.use_order_book` | Enable selling of open trades using [Order Book Asks](#sell-price-with-orderbook-enabled).
**Datatype:** Boolean +| `ask_strategy.order_book_min` | Bot will scan from the top min to max Order Book Asks searching for a profitable rate.
*Defaults to `1`.*
**Datatype:** Positive Integer +| `ask_strategy.order_book_max` | Bot will scan from the top min to max Order Book Asks searching for a profitable rate.
*Defaults to `1`.*
**Datatype:** Positive Integer +| `ask_strategy.use_sell_signal` | Use sell signals produced by the strategy in addition to the `minimal_roi`. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `true`.*
**Datatype:** Boolean +| `ask_strategy.sell_profit_only` | Wait until the bot makes a positive profit before taking a sell decision. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `false`.*
**Datatype:** Boolean +| `ask_strategy.ignore_roi_if_buy_signal` | Do not sell if the buy signal is still active. This setting takes preference over `minimal_roi` and `use_sell_signal`. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `false`.*
**Datatype:** Boolean +| `order_types` | Configure order-types depending on the action (`"buy"`, `"sell"`, `"stoploss"`, `"stoploss_on_exchange"`). [More information below](#understand-order_types). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Dict +| `order_time_in_force` | Configure time in force for buy and sell orders. [More information below](#understand-order_time_in_force). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Dict +| `exchange.name` | **Required.** Name of the exchange class to use. [List below](#user-content-what-values-for-exchangename).
**Datatype:** String +| `exchange.sandbox` | Use the 'sandbox' version of the exchange, where the exchange provides a sandbox for risk-free integration. See [here](sandbox-testing.md) in more details.
**Datatype:** Boolean +| `exchange.key` | API key to use for the exchange. Only required when you are in production mode.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String +| `exchange.secret` | API secret to use for the exchange. Only required when you are in production mode.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String +| `exchange.password` | API password to use for the exchange. Only required when you are in production mode and for exchanges that use password for API requests.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String +| `exchange.pair_whitelist` | List of pairs to use by the bot for trading and to check for potential trades during backtesting. Not used by VolumePairList (see [below](#dynamic-pairlists)).
**Datatype:** List +| `exchange.pair_blacklist` | List of pairs the bot must absolutely avoid for trading and backtesting (see [below](#dynamic-pairlists)).
**Datatype:** List +| `exchange.ccxt_config` | Additional CCXT parameters passed to the regular ccxt instance. Parameters may differ from exchange to exchange and are documented in the [ccxt documentation](https://ccxt.readthedocs.io/en/latest/manual.html#instantiation)
**Datatype:** Dict +| `exchange.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* -| `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* -| `webhook.enabled` | Enable usage of Webhook notifications
***Datatype:*** *Boolean* -| `webhook.url` | URL for the webhook. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details.
***Datatype:*** *String* -| `webhook.webhookbuy` | Payload to send on buy. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details.
***Datatype:*** *String* -| `webhook.webhooksell` | Payload to send on sell. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details.
***Datatype:*** *String* -| `webhook.webhookstatus` | Payload to send on status calls. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details.
***Datatype:*** *String* -| `api_server.enabled` | Enable usage of API Server. See the [API Server documentation](rest-api.md) for more details.
***Datatype:*** *Boolean* -| `api_server.listen_ip_address` | Bind IP address. See the [API Server documentation](rest-api.md) for more details.
***Datatype:*** *IPv4* -| `api_server.listen_port` | Bind Port. See the [API Server documentation](rest-api.md) for more details.
***Datatype:*** *Integer between 1024 and 65535* -| `api_server.username` | Username for API server. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
***Datatype:*** *String* -| `api_server.password` | Password for API server. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
***Datatype:*** *String* -| `db_url` | Declares database URL to use. NOTE: This defaults to `sqlite:///tradesv3.dryrun.sqlite` if `dry_run` is `true`, and to `sqlite:///tradesv3.sqlite` for production instances.
***Datatype:*** *String, SQLAlchemy connect string* -| `initial_state` | Defines the initial application state. More information below.
*Defaults to `stopped`.*
***Datatype:*** *Enum, either `stopped` or `running`* -| `forcebuy_enable` | Enables the RPC Commands to force a buy. More information below.
***Datatype:*** *Boolean* -| `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 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* -| `user_data_dir` | Directory containing user data.
*Defaults to `./user_data/`*.
***Datatype:*** *String* +| `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 +| `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 +| `webhook.enabled` | Enable usage of Webhook notifications
**Datatype:** Boolean +| `webhook.url` | URL for the webhook. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details.
**Datatype:** String +| `webhook.webhookbuy` | Payload to send on buy. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details.
**Datatype:** String +| `webhook.webhookbuycancel` | Payload to send on buy order cancel. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details.
**Datatype:** String +| `webhook.webhooksell` | Payload to send on sell. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details.
**Datatype:** String +| `webhook.webhooksellcancel` | Payload to send on sell order cancel. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details.
**Datatype:** String +| `webhook.webhookstatus` | Payload to send on status calls. Only required if `webhook.enabled` is `true`. See the [webhook documentation](webhook-config.md) for more details.
**Datatype:** String +| `api_server.enabled` | Enable usage of API Server. See the [API Server documentation](rest-api.md) for more details.
**Datatype:** Boolean +| `api_server.listen_ip_address` | Bind IP address. See the [API Server documentation](rest-api.md) for more details.
**Datatype:** IPv4 +| `api_server.listen_port` | Bind Port. See the [API Server documentation](rest-api.md) for more details.
**Datatype:** Integer between 1024 and 65535 +| `api_server.username` | Username for API server. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String +| `api_server.password` | Password for API server. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String +| `db_url` | Declares database URL to use. NOTE: This defaults to `sqlite:///tradesv3.dryrun.sqlite` if `dry_run` is `true`, and to `sqlite:///tradesv3.sqlite` for production instances.
**Datatype:** String, SQLAlchemy connect string +| `initial_state` | Defines the initial application state. More information below.
*Defaults to `stopped`.*
**Datatype:** Enum, either `stopped` or `running` +| `forcebuy_enable` | Enables the RPC Commands to force a buy. More information below.
**Datatype:** Boolean +| `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.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 +| `user_data_dir` | Directory containing user data.
*Defaults to `./user_data/`*.
**Datatype:** String +| `dataformat_ohlcv` | Data format to use to store OHLCV historic data.
*Defaults to `json`*.
**Datatype:** String +| `dataformat_trades` | Data format to use to store trades historic data.
*Defaults to `jsongz`*.
**Datatype:** String ### Parameters in the strategy @@ -278,7 +282,7 @@ If this is configured, the following 4 values (`buy`, `sell`, `stoploss` and The below is the default which is used if this is not configured in either strategy or configuration file. Since `stoploss_on_exchange` uses limit orders, the exchange needs 2 prices, the stoploss_price and the Limit price. -`stoploss` defines the stop-price - and limit should be slightly below this. This defaults to 0.99 / 1%. +`stoploss` defines the stop-price - and limit should be slightly below this. This defaults to 0.99 / 1% (configurable via `stoploss_on_exchange_limit_ratio`). Calculation example: we bought the asset at 100$. Stop-price is 95$, then limit would be `95 * 0.99 = 94.05$` - so the stoploss will happen between 95$ and 94.05$. @@ -503,6 +507,7 @@ Inactive markets and blacklisted pairs are always removed from the resulting `pa * [`VolumePairList`](#volume-pair-list) * [`PrecisionFilter`](#precision-filter) * [`PriceFilter`](#price-pair-filter) +* [`SpreadFilter`](#spread-filter) !!! 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. @@ -551,6 +556,11 @@ Min price precision is 8 decimals. If price is 0.00000011 - one step would be 0. These pairs are dangerous since it may be impossible to place the desired stoploss - and often result in high losses. +#### Spread Filter +Removes pairs that have a difference between asks and bids above the specified ratio (default `0.005`). +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 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%. @@ -602,12 +612,25 @@ Once you will be happy with your bot performance running in the Dry-run mode, yo !!! Note A simulated wallet is available during dry-run mode, and will assume a starting capital of `dry_run_wallet` (defaults to 1000). +### Considerations for dry-run + +* API-keys may or may not be provided. Only Read-Only operations (i.e. operations that do not alter account state) on the exchange are performed in the dry-run mode. +* Wallets (`/balance`) are simulated. +* Orders are simulated, and will not be posted to the exchange. +* In combination with `stoploss_on_exchange`, the stop_loss price is assumed to be filled. +* Open orders (not trades, which are stored in the database) are reset on bot restart. + ## Switch to production mode In production mode, the bot will engage your money. Be careful, since a wrong strategy can lose all your money. Be aware of what you are doing when you run it in production mode. +### Setup your exchange account + +You will need to create API Keys (usually you get `key` and `secret`, some exchanges require an additional `password`) from the Exchange website and you'll need to insert this into the appropriate fields in the configuration or when asked by the `freqtrade new-config` command. +API Keys are usually only required for live trading (trading for real money, bot running in "production mode", executing real orders on the exchange) and are not required for the bot running in dry-run (trade simulation) mode. When you setup the bot in dry-run mode, you may fill these fields with empty values. + ### To switch your bot in production mode **Edit your `config.json` file.** @@ -629,9 +652,6 @@ you run it in production mode. } ``` -!!! Note - If you have an exchange API key yet, [see our tutorial](installation.md#setup-your-exchange-account). - You should also make sure to read the [Exchanges](exchanges.md) section of the documentation to be aware of potential configuration details specific to your exchange. ### Using proxy with Freqtrade @@ -656,7 +676,7 @@ freqtrade ## Embedding Strategies -FreqTrade provides you with with an easy way to embed the strategy into your configuration file. +Freqtrade provides you with with an easy way to embed the strategy into your configuration file. This is done by utilizing BASE64 encoding and providing this string at the strategy configuration field, in your chosen config file. diff --git a/docs/data-download.md b/docs/data-download.md index 1f03b124a..76e22f4ea 100644 --- a/docs/data-download.md +++ b/docs/data-download.md @@ -12,6 +12,152 @@ Otherwise `--exchange` becomes mandatory. If you already have backtesting data available in your data-directory and would like to refresh this data up to today, use `--days xx` with a number slightly higher than the missing number of days. Freqtrade will keep the available data and only download the missing data. Be carefull though: If the number is too small (which would result in a few missing days), the whole dataset will be removed and only xx days will be downloaded. +### Usage + +``` +usage: freqtrade download-data [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] [--userdir PATH] [-p PAIRS [PAIRS ...]] + [--pairs-file FILE] [--days INT] [--dl-trades] [--exchange EXCHANGE] + [-t {1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w} [{1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w} ...]] + [--erase] [--data-format-ohlcv {json,jsongz}] [--data-format-trades {json,jsongz}] + +optional arguments: + -h, --help show this help message and exit + -p PAIRS [PAIRS ...], --pairs PAIRS [PAIRS ...] + Show profits for only these pairs. Pairs are space-separated. + --pairs-file FILE File containing a list of pairs to download. + --days INT Download data for given number of days. + --dl-trades Download trades instead of OHLCV data. The bot will resample trades to the desired timeframe as specified as + --timeframes/-t. + --exchange EXCHANGE Exchange name (default: `bittrex`). Only valid if no config is provided. + -t {1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w} [{1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w} ...], --timeframes {1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w} [{1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w} ...] + Specify which tickers to download. Space-separated list. Default: `1m 5m`. + --erase Clean all existing data for the selected exchange/pairs/timeframes. + --data-format-ohlcv {json,jsongz} + Storage format for downloaded ohlcv data. (default: `json`). + --data-format-trades {json,jsongz} + Storage format for downloaded trades data. (default: `jsongz`). + +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: `config.json`). 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. +``` + +### Data format + +Freqtrade currently supports 2 dataformats, `json` (plain "text" json files) and `jsongz` (a gzipped version of json files). +By default, OHLCV data is stored as `json` data, while trades data is stored as `jsongz` data. + +This can be changed via the `--data-format-ohlcv` and `--data-format-trades` parameters respectivly. + +If the default dataformat has been changed during download, then the keys `dataformat_ohlcv` and `dataformat_trades` in the configuration file need to be adjusted to the selected dataformat as well. + +!!! Note + You can convert between data-formats using the [convert-data](#subcommand-convert-data) and [convert-trade-data](#subcommand-convert-trade-data) methods. + +#### Subcommand convert data + +``` +usage: freqtrade convert-data [-h] [-v] [--logfile FILE] [-V] [-c PATH] + [-d PATH] [--userdir PATH] + [-p PAIRS [PAIRS ...]] --format-from + {json,jsongz} --format-to {json,jsongz} + [--erase] + [-t {1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w} [{1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w} ...]] + +optional arguments: + -h, --help show this help message and exit + -p PAIRS [PAIRS ...], --pairs PAIRS [PAIRS ...] + Show profits for only these pairs. Pairs are space- + separated. + --format-from {json,jsongz} + Source format for data conversion. + --format-to {json,jsongz} + Destination format for data conversion. + --erase Clean all existing data for the selected + exchange/pairs/timeframes. + -t {1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w} [{1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w} ...], --timeframes {1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w} [{1m,3m,5m,15m,30m,1h,2h,4h,6h,8h,12h,1d,3d,1w} ...] + Specify which tickers to download. Space-separated + list. Default: `1m 5m`. + +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: `config.json`). + Multiple --config options may be used. Can be set to + `-` to read config from stdin. + -d PATH, --datadir PATH + Path to directory with historical backtesting data. + --userdir PATH, --user-data-dir PATH + Path to userdata directory. +``` + +##### Example converting data + +The following command will convert all ohlcv (candle) data available in `~/.freqtrade/data/binance` from json to jsongz, saving diskspace in the process. +It'll also remove original json data files (`--erase` parameter). + +``` bash +freqtrade convert-data --format-from json --format-to jsongz --data-dir ~/.freqtrade/data/binance -t 5m 15m --erase +``` + +#### Subcommand convert-trade data + +``` +usage: freqtrade convert-trade-data [-h] [-v] [--logfile FILE] [-V] [-c PATH] + [-d PATH] [--userdir PATH] + [-p PAIRS [PAIRS ...]] --format-from + {json,jsongz} --format-to {json,jsongz} + [--erase] + +optional arguments: + -h, --help show this help message and exit + -p PAIRS [PAIRS ...], --pairs PAIRS [PAIRS ...] + Show profits for only these pairs. Pairs are space- + separated. + --format-from {json,jsongz} + Source format for data conversion. + --format-to {json,jsongz} + Destination format for data conversion. + --erase Clean all existing data for the selected + exchange/pairs/timeframes. + +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: `config.json`). + Multiple --config options may be used. Can be set to + `-` to read config from stdin. + -d PATH, --datadir PATH + Path to directory with historical backtesting data. + --userdir PATH, --user-data-dir PATH + Path to userdata directory. +``` + +##### Example converting trades + +The following command will convert all available trade-data in `~/.freqtrade/data/kraken` from jsongz to json. +It'll also remove original jsongz data files (`--erase` parameter). + +``` bash +freqtrade convert-trade-data --format-from jsongz --format-to json --data-dir ~/.freqtrade/data/kraken --erase +``` + ### Pairs file In alternative to the whitelist from `config.json`, a `pairs.json` file can be used. diff --git a/docs/developer.md b/docs/developer.md index c679b8a49..b128ffd2b 100644 --- a/docs/developer.md +++ b/docs/developer.md @@ -1,6 +1,6 @@ # Development Help -This page is intended for developers of FreqTrade, people who want to contribute to the FreqTrade codebase or documentation, or people who want to understand the source code of the application they're running. +This page is intended for developers of Freqtrade, people who want to contribute to the Freqtrade codebase or documentation, or people who want to understand the source code of the application they're running. All contributions, bug reports, bug fixes, documentation improvements, enhancements and ideas are welcome. We [track issues](https://github.com/freqtrade/freqtrade/issues) on [GitHub](https://github.com) and also have a dev channel in [slack](https://join.slack.com/t/highfrequencybot/shared_invite/enQtNjU5ODcwNjI1MDU3LTU1MTgxMjkzNmYxNWE1MDEzYzQ3YmU4N2MwZjUyNjJjODRkMDVkNjg4YTAyZGYzYzlhOTZiMTE4ZjQ4YzM0OGE) where you can ask questions. @@ -153,7 +153,7 @@ In VolumePairList, this implements different methods of sorting, does early vali ## Implement a new Exchange (WIP) !!! Note - This section is a Work in Progress and is not a complete guide on how to test a new exchange with FreqTrade. + This section is a Work in Progress and is not a complete guide on how to test a new exchange with Freqtrade. Most exchanges supported by CCXT should work out of the box. diff --git a/docs/docker.md b/docs/docker.md index d1684abc5..cd24994bc 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -1,4 +1,4 @@ -# Using FreqTrade with Docker +# Using Freqtrade with Docker ## Install Docker @@ -8,13 +8,141 @@ Start by downloading and installing Docker CE for your platform: * [Windows](https://docs.docker.com/docker-for-windows/install/) * [Linux](https://docs.docker.com/install/) +Optionally, [docker-compose](https://docs.docker.com/compose/install/) should be installed and available to follow the [docker quick start guide](#docker-quick-start). + Once you have Docker installed, simply prepare the config file (e.g. `config.json`) and run the image for `freqtrade` as explained below. -## Download the official FreqTrade docker image +## Freqtrade with docker-compose + +Freqtrade provides an official Docker image on [Dockerhub](https://hub.docker.com/r/freqtradeorg/freqtrade/), as well as a [docker-compose file](https://github.com/freqtrade/freqtrade/blob/develop/docker-compose.yml) ready for usage. + +!!! Note + The following section assumes that docker and docker-compose is installed and available to the logged in user. + +!!! Note + All below comands use relative directories and will have to be executed from the directory containing the `docker-compose.yml` file. + +### 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. + +``` bash +mkdir ft_userdata +cd ft_userdata/ +# Download the docker-compose file from the repository +curl https://raw.githubusercontent.com/freqtrade/freqtrade/develop/docker-compose.yml -o docker-compose.yml + +# Pull the freqtrade image +docker-compose pull + +# Create user directory structure +docker-compose run --rm freqtrade create-userdir --userdir user_data + +# Create configuration - Requires answering interactive questions +docker-compose run --rm freqtrade new-config --config user_data/config.json +``` + +The above snippet creates a new directory called "ft_userdata", downloads the latest compose file and pulls the freqtrade image. +The last 2 steps in the snippet create the directory with user-data, as well as (interactively) the default configuration based on your selections. + +!!! Note + You can edit the configuration at any time, which is available as `user_data/config.json` (within the directory `ft_userdata`) when using the above configuration. + +#### Adding your strategy + +The configuration is now available as `user_data/config.json`. +You should now copy your strategy to `user_data/strategies/` - and add the Strategy class name to the `docker-compose.yml` file, replacing `SampleStrategy`. If you wish to run the bot with the SampleStrategy, just leave it as it is. + +!!! Warning + The `SampleStrategy` is there for your reference and give you ideas for your own strategy. + Please always backtest the strategy and use dry-run for some time before risking real money! + +Once this is done, you're ready to launch the bot in trading mode (Dry-run or Live-trading, depending on your answer to the corresponding question you made above). + +``` bash +docker-compose up -d +``` + +#### Docker-compose logs + +Logs will be written to `user_data/freqtrade.log`. +Alternatively, you can check the latest logs using `docker-compose logs -f`. + +#### Database + +The database will be in the user_data directory as well, and will be called `user_data/tradesv3.sqlite`. + +#### Updating freqtrade with docker-compose + +To update freqtrade when using docker-compose is as simple as running the following 2 commands: + +``` bash +# Download the latest image +docker-compose pull +# Restart the image +docker-compose up -d +``` + +This will first pull the latest image, and will then restart the container with the just pulled version. + +!!! Note + You should always check the changelog for breaking changes / manual interventions required and make sure the bot starts correctly after the update. + +#### Going from here + +Advanced users may edit the docker-compose file further to include all possible options or arguments. + +All possible freqtrade arguments will be available by running `docker-compose run --rm freqtrade `. + +!!! Note "`docker-compose run --rm`" + Including `--rm` will clean up the container after completion, and is highly recommended for all modes except trading mode (running with `freqtrade trade` command). + +##### Example: Download data with docker-compose + +Download backtesting data for 5 days for the pair ETH/BTC and 1h timeframe from Binance. The data will be stored in the directory `user_data/data/` on the host. + +``` bash +docker-compose run --rm freqtrade download-data --pairs ETH/BTC --exchange binance --days 5 -t 1h +``` + +Head over to the [Data Downloading Documentation](data-download.md) for more details on downloading data. + +##### Example: Backtest with docker-compose + +Run backtesting in docker-containers for SampleStrategy and specified timerange of historical data, on 5m timeframe: + +``` bash +docker-compose run --rm freqtrade backtesting --config user_data/config.json --strategy SampleStrategy --timerange 20190801-20191001 -i 5m +``` + +Head over to the [Backtesting Documentation](backtesting.md) to learn more. + +#### Additional dependencies with docker-compose + +If your strategy requires dependencies not included in the default image (like [technical](https://github.com/freqtrade/technical)) - it will be necessary to build the image on your host. +For this, please create a Dockerfile containing installation steps for the additional dependencies (have a look at [Dockerfile.technical](https://github.com/freqtrade/freqtrade/blob/develop/Dockerfile.technical) for an example). + +You'll then also need to modify the `docker-compose.yml` file and uncomment the build step, as well as rename the image to avoid naming collisions. + +``` yaml + image: freqtrade_custom + build: + context: . + dockerfile: "./Dockerfile." +``` + +You can then run `docker-compose build` to build the docker image, and run it using the commands described above. + +## Freqtrade with docker without docker-compose + +!!! Warning + The below documentation is provided for completeness and assumes that you are somewhat familiar with running docker containers. If you're just starting out with docker, we recommend to follow the [Freqtrade with docker-compose](#freqtrade-with-docker-compose) instructions. + +### Download the official Freqtrade docker image Pull the image from docker hub. -Branches / tags available can be checked out on [Dockerhub](https://hub.docker.com/r/freqtradeorg/freqtrade/tags/). +Branches / tags available can be checked out on [Dockerhub tags page](https://hub.docker.com/r/freqtradeorg/freqtrade/tags/). ```bash docker pull freqtradeorg/freqtrade:develop diff --git a/docs/edge.md b/docs/edge.md index dcefe7451..6a301b044 100644 --- a/docs/edge.md +++ b/docs/edge.md @@ -145,19 +145,19 @@ Edge module has following configuration options: | Parameter | Description | |------------|-------------| -| `enabled` | If true, then Edge will run periodically.
*Defaults to `false`.*
***Datatype:*** *Boolean* -| `process_throttle_secs` | How often should Edge run in seconds.
*Defaults to `3600` (once per hour).*
***Datatype:*** *Integer* -| `calculate_since_number_of_days` | Number of days of data against which Edge calculates Win Rate, Risk Reward and Expectancy.
**Note** that it downloads historical data so increasing this number would lead to slowing down the bot.
*Defaults to `7`.*
***Datatype:*** *Integer* -| `capital_available_percentage` | **DEPRECATED - [replaced with `tradable_balance_ratio`](configuration.md#Available balance)** This is the percentage of the total capital on exchange in stake currency.
As an example if you have 10 ETH available in your wallet on the exchange and this value is 0.5 (which is 50%), then the bot will use a maximum amount of 5 ETH for trading and considers it as available capital.
*Defaults to `0.5`.*
***Datatype:*** *Float* -| `allowed_risk` | Ratio of allowed risk per trade.
*Defaults to `0.01` (1%)).*
***Datatype:*** *Float* -| `stoploss_range_min` | Minimum stoploss.
*Defaults to `-0.01`.*
***Datatype:*** *Float* -| `stoploss_range_max` | Maximum stoploss.
*Defaults to `-0.10`.*
***Datatype:*** *Float* -| `stoploss_range_step` | As an example if this is set to -0.01 then Edge will test the strategy for `[-0.01, -0,02, -0,03 ..., -0.09, -0.10]` ranges.
**Note** than having a smaller step means having a bigger range which could lead to slow calculation.
If you set this parameter to -0.001, you then slow down the Edge calculation by a factor of 10.
*Defaults to `-0.001`.*
***Datatype:*** *Float* -| `minimum_winrate` | It filters out pairs which don't have at least minimum_winrate.
This comes handy if you want to be conservative and don't comprise win rate in favour of risk reward ratio.
*Defaults to `0.60`.*
***Datatype:*** *Float* -| `minimum_expectancy` | It filters out pairs which have the expectancy lower than this number.
Having an expectancy of 0.20 means if you put 10$ on a trade you expect a 12$ return.
*Defaults to `0.20`.*
***Datatype:*** *Float* -| `min_trade_number` | When calculating *W*, *R* and *E* (expectancy) against historical data, you always want to have a minimum number of trades. The more this number is the more Edge is reliable.
Having a win rate of 100% on a single trade doesn't mean anything at all. But having a win rate of 70% over past 100 trades means clearly something.
*Defaults to `10` (it is highly recommended not to decrease this number).*
***Datatype:*** *Integer* -| `max_trade_duration_minute` | Edge will filter out trades with long duration. If a trade is profitable after 1 month, it is hard to evaluate the strategy based on it. But if most of trades are profitable and they have maximum duration of 30 minutes, then it is clearly a good sign.
**NOTICE:** While configuring this value, you should take into consideration your ticker interval. As an example filtering out trades having duration less than one day for a strategy which has 4h interval does not make sense. Default value is set assuming your strategy interval is relatively small (1m or 5m, etc.).
*Defaults to `1440` (one day).*
***Datatype:*** *Integer* -| `remove_pumps` | Edge will remove sudden pumps in a given market while going through historical data. However, given that pumps happen very often in crypto markets, we recommend you keep this off.
*Defaults to `false`.*
***Datatype:*** *Boolean* +| `enabled` | If true, then Edge will run periodically.
*Defaults to `false`.*
**Datatype:** Boolean +| `process_throttle_secs` | How often should Edge run in seconds.
*Defaults to `3600` (once per hour).*
**Datatype:** Integer +| `calculate_since_number_of_days` | Number of days of data against which Edge calculates Win Rate, Risk Reward and Expectancy.
**Note** that it downloads historical data so increasing this number would lead to slowing down the bot.
*Defaults to `7`.*
**Datatype:** Integer +| `capital_available_percentage` | **DEPRECATED - [replaced with `tradable_balance_ratio`](configuration.md#Available balance)** This is the percentage of the total capital on exchange in stake currency.
As an example if you have 10 ETH available in your wallet on the exchange and this value is 0.5 (which is 50%), then the bot will use a maximum amount of 5 ETH for trading and considers it as available capital.
*Defaults to `0.5`.*
**Datatype:** Float +| `allowed_risk` | Ratio of allowed risk per trade.
*Defaults to `0.01` (1%)).*
**Datatype:** Float +| `stoploss_range_min` | Minimum stoploss.
*Defaults to `-0.01`.*
**Datatype:** Float +| `stoploss_range_max` | Maximum stoploss.
*Defaults to `-0.10`.*
**Datatype:** Float +| `stoploss_range_step` | As an example if this is set to -0.01 then Edge will test the strategy for `[-0.01, -0,02, -0,03 ..., -0.09, -0.10]` ranges.
**Note** than having a smaller step means having a bigger range which could lead to slow calculation.
If you set this parameter to -0.001, you then slow down the Edge calculation by a factor of 10.
*Defaults to `-0.001`.*
**Datatype:** Float +| `minimum_winrate` | It filters out pairs which don't have at least minimum_winrate.
This comes handy if you want to be conservative and don't comprise win rate in favour of risk reward ratio.
*Defaults to `0.60`.*
**Datatype:** Float +| `minimum_expectancy` | It filters out pairs which have the expectancy lower than this number.
Having an expectancy of 0.20 means if you put 10$ on a trade you expect a 12$ return.
*Defaults to `0.20`.*
**Datatype:** Float +| `min_trade_number` | When calculating *W*, *R* and *E* (expectancy) against historical data, you always want to have a minimum number of trades. The more this number is the more Edge is reliable.
Having a win rate of 100% on a single trade doesn't mean anything at all. But having a win rate of 70% over past 100 trades means clearly something.
*Defaults to `10` (it is highly recommended not to decrease this number).*
**Datatype:** Integer +| `max_trade_duration_minute` | Edge will filter out trades with long duration. If a trade is profitable after 1 month, it is hard to evaluate the strategy based on it. But if most of trades are profitable and they have maximum duration of 30 minutes, then it is clearly a good sign.
**NOTICE:** While configuring this value, you should take into consideration your ticker interval. As an example filtering out trades having duration less than one day for a strategy which has 4h interval does not make sense. Default value is set assuming your strategy interval is relatively small (1m or 5m, etc.).
*Defaults to `1440` (one day).*
**Datatype:** Integer +| `remove_pumps` | Edge will remove sudden pumps in a given market while going through historical data. However, given that pumps happen very often in crypto markets, we recommend you keep this off.
*Defaults to `false`.*
**Datatype:** Boolean ## Running Edge independently diff --git a/docs/exchanges.md b/docs/exchanges.md index 76fa81f4a..f615bc61a 100644 --- a/docs/exchanges.md +++ b/docs/exchanges.md @@ -5,7 +5,7 @@ This page combines common gotchas and informations which are exchange-specific a ## Binance !!! Tip "Stoploss on Exchange" - Binance is currently the only exchange supporting `stoploss_on_exchange`. It provides great advantages, so we recommend to benefit from it. + Binance supports `stoploss_on_exchange` and uses stop-loss-limit orders. It provides great advantages, so we recommend to benefit from it. ### Blacklists @@ -22,6 +22,9 @@ Binance has been split into 3, and users must use the correct ccxt exchange ID f ## Kraken +!!! Tip "Stoploss on Exchange" + Kraken supports `stoploss_on_exchange` and uses stop-loss-market orders. It provides great advantages, so we recommend to benefit from it, however since the resulting order is a stoploss-market order, sell-rates are not guaranteed, which makes this feature less secure than on other exchanges. This limitation is based on kraken's policy [source](https://blog.kraken.com/post/1234/announcement-delisting-pairs-and-temporary-suspension-of-advanced-order-types/) and [source2](https://blog.kraken.com/post/1494/kraken-enables-advanced-orders-and-adds-10-currency-pairs/) - which has stoploss-limit orders disabled. + ### Historic Kraken data The Kraken API does only provide 720 historic candles, which is sufficient for Freqtrade dry-run and live trade modes, but is a problem for backtesting. @@ -29,6 +32,10 @@ To download data for the Kraken exchange, using `--dl-trades` is mandatory, othe ## Bittrex +### Order types + +Bittrex does not support market orders. If you have a message at the bot startup about this, you should change order type values set in your configuration and/or in the strategy from `"market"` to `"limit"`. See some more details on this [here in the FAQ](faq.md#im-getting-the-exchange-bittrex-does-not-support-market-orders-message-and-cannot-run-my-strategy). + ### Restricted markets Bittrex split its exchange into US and International versions. diff --git a/docs/faq.md b/docs/faq.md index 2416beae4..94818964b 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -45,12 +45,28 @@ the tutorial [here|Testing-new-strategies-with-Hyperopt](bot-usage.md#hyperopt-c You can use the `/forcesell all` command from Telegram. -### I get the message "RESTRICTED_MARKET" +### I'm getting the "RESTRICTED_MARKET" message in the log Currently known to happen for US Bittrex users. Read [the Bittrex section about restricted markets](exchanges.md#restricted-markets) for more information. +### I'm getting the "Exchange Bittrex does not support market orders." message and cannot run my strategy + +As the message says, Bittrex does not support market orders and you have one of the [order types](configuration.md/#understand-order_types) set to "market". Probably your strategy was written with other exchanges in mind and sets "market" orders for "stoploss" orders, which is correct and preferable for most of the exchanges supporting market orders (but not for Bittrex). + +To fix it for Bittrex, redefine order types in the strategy to use "limit" instead of "market": + +``` + order_types = { + ... + 'stoploss': 'limit', + ... + } +``` + +Same fix should be done in the configuration file, if order types are defined in your custom config rather than in the strategy. + ### How do I search the bot logs for something? By default, the bot writes its log into stderr stream. This is implemented this way so that you can easily separate the bot's diagnostics messages from Backtesting, Edge and Hyperopt results, output from other various Freqtrade utility subcommands, as well as from the output of your custom `print()`'s you may have inserted into your strategy. So if you need to search the log messages with the grep utility, you need to redirect stderr to stdout and disregard stdout. diff --git a/docs/hyperopt.md b/docs/hyperopt.md index f399fe816..401811a1b 100644 --- a/docs/hyperopt.md +++ b/docs/hyperopt.md @@ -57,12 +57,12 @@ Rarely you may also need to override: !!! Tip "Quickly optimize ROI, stoploss and trailing stoploss" You can quickly optimize the spaces `roi`, `stoploss` and `trailing` without changing anything (i.e. without creation of a "complete" Hyperopt class with dimensions, parameters, triggers and guards, as described in this document) from the default hyperopt template by relying on your strategy to do most of the calculations. - ``` python + ```python # Have a working strategy at hand. freqtrade new-hyperopt --hyperopt EmptyHyperopt freqtrade hyperopt --hyperopt EmptyHyperopt --spaces roi stoploss trailing --strategy MyWorkingStrategy --config config.json -e 100 - ``` + ``` ### 1. Install a Custom Hyperopt File @@ -75,8 +75,8 @@ Copy the file `user_data/hyperopts/sample_hyperopt.py` into `user_data/hyperopts There are two places you need to change in your hyperopt file to add a new buy hyperopt for testing: -- Inside `indicator_space()` - the parameters hyperopt shall be optimizing. -- Inside `populate_buy_trend()` - applying the parameters. +* Inside `indicator_space()` - the parameters hyperopt shall be optimizing. +* Inside `populate_buy_trend()` - applying the parameters. There you have two different types of indicators: 1. `guards` and 2. `triggers`. @@ -141,7 +141,7 @@ one we call `trigger` and use it to decide which buy trigger we want to use. So let's write the buy strategy using these values: -``` python +```python def populate_buy_trend(dataframe: DataFrame) -> DataFrame: conditions = [] # GUARDS AND TRENDS @@ -182,7 +182,7 @@ add it to the `populate_indicators()` method in your custom hyperopt file. Each hyperparameter tuning requires a target. This is usually defined as a loss function (sometimes also called objective function), which should decrease for more desirable results, and increase for bad results. -By default, FreqTrade uses a loss function, which has been with freqtrade since the beginning and optimizes mostly for short trade duration and avoiding losses. +By default, Freqtrade uses a loss function, which has been with freqtrade since the beginning and optimizes mostly for short trade duration and avoiding losses. A different loss function can be specified by using the `--hyperopt-loss ` argument. This class should be in its own file within the `user_data/hyperopts/` directory. @@ -192,6 +192,7 @@ Currently, the following loss functions are builtin: * `DefaultHyperOptLoss` (default legacy Freqtrade hyperoptimization loss function) * `OnlyProfitHyperOptLoss` (which takes only amount of profit into consideration) * `SharpeHyperOptLoss` (optimizes Sharpe Ratio calculated on the trade returns) +* `SharpeHyperOptLossDaily` (optimizes Sharpe Ratio calculated on daily trade returns) Creation of a custom loss function is covered in the [Advanced Hyperopt](advanced-hyperopt.md) part of the documentation. @@ -206,7 +207,7 @@ We strongly recommend to use `screen` or `tmux` to prevent any connection loss. freqtrade hyperopt --config config.json --hyperopt -e 5000 --spaces all ``` -Use `` as the name of the custom hyperopt used. +Use `` as the name of the custom hyperopt used. The `-e` option will set how many evaluations hyperopt will do. We recommend running at least several thousand evaluations. @@ -265,23 +266,23 @@ The default Hyperopt Search Space, used when no `--space` command line option is ### Position stacking and disabling max market positions -In some situations, you may need to run Hyperopt (and Backtesting) with the +In some situations, you may need to run Hyperopt (and Backtesting) with the `--eps`/`--enable-position-staking` and `--dmmp`/`--disable-max-market-positions` arguments. By default, hyperopt emulates the behavior of the Freqtrade Live Run/Dry Run, where only one -open trade is allowed for every traded pair. The total number of trades open for all pairs +open trade is allowed for every traded pair. The total number of trades open for all pairs is also limited by the `max_open_trades` setting. During Hyperopt/Backtesting this may lead to some potential trades to be hidden (or masked) by previosly open trades. The `--eps`/`--enable-position-stacking` argument allows emulation of buying the same pair multiple times, -while `--dmmp`/`--disable-max-market-positions` disables applying `max_open_trades` +while `--dmmp`/`--disable-max-market-positions` disables applying `max_open_trades` during Hyperopt/Backtesting (which is equal to setting `max_open_trades` to a very high number). !!! Note Dry/live runs will **NOT** use position stacking - therefore it does make sense to also validate the strategy without this as it's closer to reality. -You can also enable position stacking in the configuration file by explicitly setting +You can also enable position stacking in the configuration file by explicitly setting `"position_stacking"=true`. ### Reproducible results @@ -323,7 +324,7 @@ method, what those values match to. So for example you had `rsi-value: 29.0` so we would look at `rsi`-block, that translates to the following code block: -``` python +```python (dataframe['rsi'] < 29.0) ``` @@ -372,18 +373,19 @@ In order to use this best ROI table found by Hyperopt in backtesting and for liv 118: 0 } ``` + As stated in the comment, you can also use it as the value of the `minimal_roi` setting in the configuration file. #### Default ROI Search Space If you are optimizing ROI, Freqtrade creates the 'roi' optimization hyperspace for you -- it's the hyperspace of components for the ROI tables. By default, each ROI table generated by the Freqtrade consists of 4 rows (steps). Hyperopt implements adaptive ranges for ROI tables with ranges for values in the ROI steps that depend on the ticker_interval used. By default the values vary in the following ranges (for some of the most used ticker intervals, values are rounded to 5 digits after the decimal point): -| # step | 1m | | 5m | | 1h | | 1d | | -|---|---|---|---|---|---|---|---|---| -| 1 | 0 | 0.01161...0.11992 | 0 | 0.03...0.31 | 0 | 0.06883...0.71124 | 0 | 0.12178...1.25835 | -| 2 | 2...8 | 0.00774...0.04255 | 10...40 | 0.02...0.11 | 120...480 | 0.04589...0.25238 | 2880...11520 | 0.08118...0.44651 | -| 3 | 4...20 | 0.00387...0.01547 | 20...100 | 0.01...0.04 | 240...1200 | 0.02294...0.09177 | 5760...28800 | 0.04059...0.16237 | -| 4 | 6...44 | 0.0 | 30...220 | 0.0 | 360...2640 | 0.0 | 8640...63360 | 0.0 | +| # step | 1m | | 5m | | 1h | | 1d | | +| ------ | ------ | ----------------- | -------- | ----------- | ---------- | ----------------- | ------------ | ----------------- | +| 1 | 0 | 0.01161...0.11992 | 0 | 0.03...0.31 | 0 | 0.06883...0.71124 | 0 | 0.12178...1.25835 | +| 2 | 2...8 | 0.00774...0.04255 | 10...40 | 0.02...0.11 | 120...480 | 0.04589...0.25238 | 2880...11520 | 0.08118...0.44651 | +| 3 | 4...20 | 0.00387...0.01547 | 20...100 | 0.01...0.04 | 240...1200 | 0.02294...0.09177 | 5760...28800 | 0.04059...0.16237 | +| 4 | 6...44 | 0.0 | 30...220 | 0.0 | 360...2640 | 0.0 | 8640...63360 | 0.0 | These ranges should be sufficient in most cases. The minutes in the steps (ROI dict keys) are scaled linearly depending on the ticker interval used. The ROI values in the steps (ROI dict values) are scaled logarithmically depending on the ticker interval used. @@ -416,6 +418,7 @@ In order to use this best stoploss value found by Hyperopt in backtesting and fo # This attribute will be overridden if the config file contains "stoploss" stoploss = -0.27996 ``` + As stated in the comment, you can also use it as the value of the `stoploss` setting in the configuration file. #### Default Stoploss Search Space @@ -452,6 +455,7 @@ In order to use these best trailing stop parameters found by Hyperopt in backtes trailing_stop_positive_offset = 0.06038 trailing_only_offset_is_reached = True ``` + As stated in the comment, you can also use it as the values of the corresponding settings in the configuration file. #### Default Trailing Stop Search Space diff --git a/docs/index.md b/docs/index.md index c88c73619..adc661300 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,5 +1,5 @@ # Freqtrade -[![Build Status](https://travis-ci.org/freqtrade/freqtrade.svg?branch=develop)](https://travis-ci.org/freqtrade/freqtrade) +[![Freqtrade CI](https://github.com/freqtrade/freqtrade/workflows/Freqtrade%20CI/badge.svg)](https://github.com/freqtrade/freqtrade/actions/) [![Coverage Status](https://coveralls.io/repos/github/freqtrade/freqtrade/badge.svg?branch=develop&service=github)](https://coveralls.io/github/freqtrade/freqtrade?branch=develop) [![Maintainability](https://api.codeclimate.com/v1/badges/5737e6d668200b7518ff/maintainability)](https://codeclimate.com/github/freqtrade/freqtrade/maintainability) @@ -51,12 +51,15 @@ To run this bot we recommend you a cloud instance with a minimum of: ### Software requirements +- Docker (Recommended) + +Alternatively + - Python 3.6.x - pip (pip3) - git - TA-Lib - virtualenv (Recommended) -- Docker (Recommended) ## Support @@ -67,4 +70,4 @@ Click [here](https://join.slack.com/t/highfrequencybot/shared_invite/enQtNjU5ODc ## Ready to try? -Begin by reading our installation guide [here](installation). +Begin by reading our installation guide [for docker](docker.md), or for [installation without docker](installation.md). diff --git a/docs/installation.md b/docs/installation.md index 267d91c8d..88e2ef6eb 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -2,6 +2,8 @@ This page explains how to prepare your environment for running the bot. +Please consider using the prebuilt [docker images](docker.md) to get started quickly while trying out freqtrade evaluating how it operates. + ## Prerequisite ### Requirements @@ -14,15 +16,7 @@ Click each one for install guide: * [virtualenv](https://virtualenv.pypa.io/en/stable/installation/) (Recommended) * [TA-Lib](https://mrjbq7.github.io/ta-lib/install.html) (install instructions below) -### API keys - -Before running your bot in production you will need to setup few -external API. In production mode, the bot will require valid Exchange API -credentials. We also recommend a [Telegram bot](telegram-usage.md#setup-your-telegram-bot) (optional but recommended). - -### Setup your exchange account - -You will need to create API Keys (Usually you get `key` and `secret`) from the Exchange website and insert this into the appropriate fields in the configuration or when asked by the installation script. + We also recommend a [Telegram bot](telegram-usage.md#setup-your-telegram-bot), which is optional but recommended. ## Quick start @@ -31,7 +25,7 @@ Freqtrade provides the Linux/MacOS Easy Installation script to install all depen !!! Note Windows installation is explained [here](#windows). -The easiest way to install and run Freqtrade is to clone the bot GitHub repository and then run the Easy Installation script, if it's available for your platform. +The easiest way to install and run Freqtrade is to clone the bot Github repository and then run the Easy Installation script, if it's available for your platform. !!! Note "Version considerations" When cloning the repository the default working branch has the name `develop`. This branch contains all last features (can be considered as relatively stable, thanks to automated tests). The `master` branch contains the code of the last release (done usually once per month on an approximately one week old snapshot of the `develop` branch to prevent packaging bugs, so potentially it's more stable). @@ -42,11 +36,12 @@ The easiest way to install and run Freqtrade is to clone the bot GitHub reposito This can be achieved with the following commands: ```bash -git clone git@github.com:freqtrade/freqtrade.git +git clone https://github.com/freqtrade/freqtrade.git cd freqtrade git checkout master # Optional, see (1) ./setup.sh --install ``` + (1) This command switches the cloned repository to the use of the `master` branch. It's not needed if you wish to stay on the `develop` branch. You may later switch between branches at any time with the `git checkout master`/`git checkout develop` commands. ## Easy Installation Script (Linux/MacOS) @@ -64,11 +59,11 @@ usage: ** --install ** -With this option, the script will install everything you need to run the bot: +With this option, the script will install the bot and most dependencies: +You will need to have git and python3.6+ installed beforehand for this to work. * Mandatory software as: `ta-lib` -* Setup your virtualenv -* Configure your `config.json` file +* Setup your virtualenv under `.env/` This option is a combination of installation tasks, `--reset` and `--config`. @@ -82,7 +77,7 @@ This option will hard reset your branch (only if you are on either `master` or ` ** --config ** -Use this option to configure the `config.json` configuration file. The script will interactively ask you questions to setup your bot and create your `config.json`. +DEPRECATED - use `freqtrade new-config -c config.json` instead. ------ @@ -129,6 +124,17 @@ bash setup.sh -i #### 1. Install TA-Lib +Use the provided ta-lib installation script + +```bash +sudo ./build_helpers/install_ta-lib.sh +``` + +!!! Note + This will use the ta-lib tar.gz included in this repository. + +##### TA-Lib manual installation + Official webpage: https://mrjbq7.github.io/ta-lib/install.html ```bash @@ -184,7 +190,8 @@ python3 -m pip install -e . # Initialize the user_directory freqtrade create-userdir --userdir user_data/ -cp config.json.example config.json +# Create a new configuration file +freqtrade new-config --config config.json ``` > *To edit the config please refer to [Bot Configuration](configuration.md).* diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index 3e53c15e3..48ade026e 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,2 +1,2 @@ -mkdocs-material==4.6.0 +mkdocs-material==4.6.3 mdx_truly_sane_lists==1.2 diff --git a/docs/rest-api.md b/docs/rest-api.md index 187a71c97..b68364f39 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -74,7 +74,7 @@ docker run -d \ ## Consuming the API You can consume the API by using the script `scripts/rest_client.py`. -The client script only requires the `requests` module, so FreqTrade does not need to be installed on the system. +The client script only requires the `requests` module, so Freqtrade does not need to be installed on the system. ``` bash python3 scripts/rest_client.py [optional parameters] diff --git a/docs/stoploss.md b/docs/stoploss.md index 105488296..f6d56fd41 100644 --- a/docs/stoploss.md +++ b/docs/stoploss.md @@ -27,7 +27,7 @@ So this parameter will tell the bot how often it should update the stoploss orde This same logic will reapply a stoploss order on the exchange should you cancel it accidentally. !!! Note - Stoploss on exchange is only supported for Binance as of now. + Stoploss on exchange is only supported for Binance (stop-loss-limit) and Kraken (stop-loss-market) as of now. ## Static Stop Loss diff --git a/docs/strategy-customization.md b/docs/strategy-customization.md index d59b097d7..07833da34 100644 --- a/docs/strategy-customization.md +++ b/docs/strategy-customization.md @@ -346,7 +346,7 @@ if self.dp: ``` python if self.dp: - if self.dp.runmode in ('live', 'dry_run'): + 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] @@ -422,7 +422,7 @@ from freqtrade.persistence import Trade The following example queries for the current pair and trades from today, however other filters can easily be added. ``` python -if self.config['runmode'] in ('live', 'dry_run'): +if self.config['runmode'].value in ('live', 'dry_run'): trades = Trade.get_trades([Trade.pair == metadata['pair'], Trade.open_date > datetime.utcnow() - timedelta(days=1), Trade.is_open == False, @@ -434,7 +434,7 @@ if self.config['runmode'] in ('live', 'dry_run'): Get amount of stake_currency currently invested in Trades: ``` python -if self.config['runmode'] in ('live', 'dry_run'): +if self.config['runmode'].value in ('live', 'dry_run'): total_stakes = Trade.total_open_trades_stakes() ``` @@ -442,7 +442,7 @@ Retrieve performance per pair. Returns a List of dicts per pair. ``` python -if self.config['runmode'] in ('live', 'dry_run'): +if self.config['runmode'].value in ('live', 'dry_run'): performance = Trade.get_overall_performance() ``` @@ -487,7 +487,7 @@ from datetime import timedelta, datetime, timezone # -------- # Within populate indicators (or populate_buy): -if self.config['runmode'] in ('live', 'dry_run'): +if self.config['runmode'].value in ('live', 'dry_run'): # fetch closed trades for the last 2 days trades = Trade.get_trades([Trade.pair == metadata['pair'], Trade.open_date > datetime.utcnow() - timedelta(days=2), @@ -532,6 +532,27 @@ If you want to use a strategy from a different directory you can pass `--strateg freqtrade trade --strategy AwesomeStrategy --strategy-path /some/directory ``` +### Derived strategies + +The strategies can be derived from other strategies. This avoids duplication of your custom strategy code. You can use this technique to override small parts of your main strategy, leaving the rest untouched: + +``` python +class MyAwesomeStrategy(IStrategy): + ... + stoploss = 0.13 + trailing_stop = False + # All other attributes and methods are here as they + # should be in any custom strategy... + ... + +class MyAwesomeStrategy2(MyAwesomeStrategy): + # Override something + stoploss = 0.08 + trailing_stop = True +``` + +Both attributes and methods may be overriden, altering behavior of the original strategy in a way you need. + ### Common mistakes when developing strategies Backtesting analyzes the whole time-range at once for performance reasons. Because of this, strategy authors need to make sure that strategies do not look-ahead into the future. diff --git a/docs/strategy_analysis_example.md b/docs/strategy_analysis_example.md index cc6b9805f..53b35ca09 100644 --- a/docs/strategy_analysis_example.md +++ b/docs/strategy_analysis_example.md @@ -1,24 +1,28 @@ # Strategy analysis example -Debugging a strategy can be time-consuming. FreqTrade offers helper functions to visualize raw data. +Debugging a strategy can be time-consuming. Freqtrade offers helper functions to visualize raw data. +The following assumes you work with SampleStrategy, data for 5m timeframe from Binance and have downloaded them into the data directory in the default location. ## Setup ```python from pathlib import Path +from freqtrade.configuration import Configuration + # Customize these according to your needs. +# Initialize empty configuration object +config = Configuration.from_files([]) +# Optionally, use existing configuration file +# config = Configuration.from_files(["config.json"]) + # Define some constants -timeframe = "5m" +config["ticker_interval"] = "5m" # Name of the strategy class -strategy_name = 'SampleStrategy' -# Path to user data -user_data_dir = Path('user_data') -# Location of the strategy -strategy_location = user_data_dir / 'strategies' +config["strategy"] = "SampleStrategy" # Location of the data -data_location = Path(user_data_dir, 'data', 'binance') +data_location = Path(config['user_data_dir'], 'data', 'binance') # Pair to analyze - Only use one pair here pair = "BTC_USDT" ``` @@ -29,7 +33,7 @@ pair = "BTC_USDT" from freqtrade.data.history import load_pair_history candles = load_pair_history(datadir=data_location, - timeframe=timeframe, + timeframe=config["ticker_interval"], pair=pair) # Confirm success @@ -44,9 +48,7 @@ candles.head() ```python # Load strategy using values set above from freqtrade.resolvers import StrategyResolver -strategy = StrategyResolver.load_strategy({'strategy': strategy_name, - 'user_data_dir': user_data_dir, - 'strategy_path': strategy_location}) +strategy = StrategyResolver.load_strategy(config) # Generate buy/sell signals using strategy df = strategy.analyze_ticker(candles, {'pair': pair}) @@ -86,7 +88,7 @@ Analyze a trades dataframe (also used below for plotting) from freqtrade.data.btanalysis import load_backtest_data # Load backtest results -trades = load_backtest_data(user_data_dir / "backtest_results/backtest-result.json") +trades = load_backtest_data(config["user_data_dir"] / "backtest_results/backtest-result.json") # Show value-counts per pair trades.groupby("pair")["sell_reason"].value_counts() diff --git a/docs/telegram-usage.md b/docs/telegram-usage.md index ed0c21a6e..f683ae8da 100644 --- a/docs/telegram-usage.md +++ b/docs/telegram-usage.md @@ -55,7 +55,7 @@ official commands. You can ask at any moment for help with `/help`. | `/reload_conf` | | Reloads the configuration file | `/show_config` | | Shows part of the current configuration with relevant settings to operation | `/status` | | Lists all open trades -| `/status table` | | List all open trades in a table format +| `/status table` | | List all open trades in a table format. Pending buy orders are marked with an asterisk (*) Pending sell orders are marked with a double asterisk (**) | `/count` | | Displays number of trades used and available | `/profit` | | Display a summary of your profit/loss from close trades and some stats about your performance | `/forcesell ` | | Instantly sells the given trade (Ignoring `minimum_roi`). diff --git a/docs/utils.md b/docs/utils.md index 18deeac54..cdf0c31af 100644 --- a/docs/utils.md +++ b/docs/utils.md @@ -36,6 +36,38 @@ optional arguments: └── sample_strategy.py ``` +## Create new config + +Creates a new configuration file, asking some questions which are important selections for a configuration. + +``` +usage: freqtrade new-config [-h] [-c PATH] + +optional arguments: + -h, --help show this help message and exit + -c PATH, --config PATH + Specify configuration file (default: `config.json`). Multiple --config options may be used. Can be set to `-` + to read config from stdin. +``` + +!!! Warning + Only vital questions are asked. Freqtrade offers a lot more configuration possibilities, which are listed in the [Configuration documentation](configuration.md#configuration-parameters) + +### Create config examples + +``` +$ freqtrade new-config --config config_binance.json + +? Do you want to enable Dry-run (simulated trades)? Yes +? Please insert your stake currency: BTC +? Please insert your stake amount: 0.05 +? Please insert max_open_trades (Integer or 'unlimited'): 5 +? Please insert your ticker interval: 15m +? Please insert your display Currency (for reporting): USD +? Select exchange binance +? Do you want to enable Telegram? No +``` + ## Create new strategy Creates a new strategy from a template similar to SampleStrategy. @@ -108,26 +140,62 @@ With custom user directory freqtrade new-hyperopt --userdir ~/.freqtrade/ --hyperopt AwesomeHyperopt ``` -## List Strategies +## List Strategies and List Hyperopts -Use the `list-strategies` subcommand to see all strategies in one particular directory. +Use the `list-strategies` subcommand to see all strategies in one particular directory and the `list-hyperopts` subcommand to list custom Hyperopts. + +These subcommands are useful for finding problems in your environment with loading strategies or hyperopt classes: modules with strategies or hyperopt classes that contain errors and failed to load are printed in red (LOAD FAILED), while strategies or hyperopt classes with duplicate names are printed in yellow (DUPLICATE NAME). ``` -freqtrade list-strategies --help -usage: freqtrade list-strategies [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] [--userdir PATH] [--strategy-path PATH] [-1] +usage: freqtrade list-strategies [-h] [-v] [--logfile FILE] [-V] [-c PATH] + [-d PATH] [--userdir PATH] + [--strategy-path PATH] [-1] [--no-color] optional arguments: -h, --help show this help message and exit --strategy-path PATH Specify additional strategy lookup path. -1, --one-column Print output in one column. + --no-color Disable colorization of hyperopt results. May be + useful if you are redirecting output to a file. 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. + --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: `config.json`). Multiple --config options may be used. Can be set to `-` - to read config from stdin. + Specify configuration file (default: `config.json`). + 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. +``` +``` +usage: freqtrade list-hyperopts [-h] [-v] [--logfile FILE] [-V] [-c PATH] + [-d PATH] [--userdir PATH] + [--hyperopt-path PATH] [-1] [--no-color] + +optional arguments: + -h, --help show this help message and exit + --hyperopt-path PATH Specify additional lookup path for Hyperopt and + Hyperopt Loss functions. + -1, --one-column Print output in one column. + --no-color Disable colorization of hyperopt results. May be + useful if you are redirecting output to a file. + +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: `config.json`). + 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 @@ -135,20 +203,34 @@ Common arguments: ``` !!! Warning - Using this command will try to load all python files from a directory. This can be a security risk if untrusted files reside in this directory, since all module-level code is executed. + Using these commands will try to load all python files from a directory. This can be a security risk if untrusted files reside in this directory, since all module-level code is executed. -Example: search default strategy directory within userdir +Example: Search default strategies and hyperopts directories (within the default userdir). + +``` bash +freqtrade list-strategies +freqtrade list-hyperopts +``` + +Example: Search strategies and hyperopts directory within the userdir. ``` bash freqtrade list-strategies --userdir ~/.freqtrade/ +freqtrade list-hyperopts --userdir ~/.freqtrade/ ``` -Example: search dedicated strategy path +Example: Search dedicated strategy path. ``` bash freqtrade list-strategies --strategy-path ~/.freqtrade/strategies/ ``` +Example: Search dedicated hyperopt path. + +``` bash +freqtrade list-hyperopt --hyperopt-path ~/.freqtrade/hyperopts/ +``` + ## List Exchanges Use the `list-exchanges` subcommand to see the exchanges available for the bot. @@ -179,20 +261,31 @@ All exchanges supported by the ccxt library: _1btcxe, acx, adara, allcoin, anxpr Use the `list-timeframes` subcommand to see the list of ticker intervals (timeframes) available for the exchange. ``` -usage: freqtrade list-timeframes [-h] [--exchange EXCHANGE] [-1] +usage: freqtrade list-timeframes [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] [--userdir PATH] [--exchange EXCHANGE] [-1] optional arguments: - -h, --help show this help message and exit - --exchange EXCHANGE Exchange name (default: `bittrex`). Only valid if no - config is provided. - -1, --one-column Print output in one column. + -h, --help show this help message and exit + --exchange EXCHANGE Exchange name (default: `bittrex`). Only valid if no config is provided. + -1, --one-column Print output in one column. + +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: `config.json`). Multiple --config options may be used. Can be set to `-` + to read config from stdin. + -d PATH, --datadir PATH + Path to directory with historical backtesting data. + --userdir PATH, --user-data-dir PATH + Path to userdata directory. ``` * Example: see the timeframes for the 'binance' exchange, set in the configuration file: ``` -$ freqtrade -c config_binance.json list-timeframes +$ freqtrade list-timeframes -c config_binance.json ... Timeframes available for the exchange `binance`: 1m, 3m, 5m, 15m, 30m, 1h, 2h, 4h, 6h, 8h, 12h, 1d, 3d, 1w, 1M ``` @@ -216,14 +309,16 @@ You can print info about any pair/market with these subcommands - and you can fi These subcommands have same usage and same set of available options: ``` -usage: freqtrade list-markets [-h] [--exchange EXCHANGE] [--print-list] - [--print-json] [-1] [--print-csv] +usage: freqtrade list-markets [-h] [-v] [--logfile FILE] [-V] [-c PATH] + [-d PATH] [--userdir PATH] [--exchange EXCHANGE] + [--print-list] [--print-json] [-1] [--print-csv] [--base BASE_CURRENCY [BASE_CURRENCY ...]] [--quote QUOTE_CURRENCY [QUOTE_CURRENCY ...]] [-a] -usage: freqtrade list-pairs [-h] [--exchange EXCHANGE] [--print-list] - [--print-json] [-1] [--print-csv] +usage: freqtrade list-pairs [-h] [-v] [--logfile FILE] [-V] [-c PATH] + [-d PATH] [--userdir PATH] [--exchange EXCHANGE] + [--print-list] [--print-json] [-1] [--print-csv] [--base BASE_CURRENCY [BASE_CURRENCY ...]] [--quote QUOTE_CURRENCY [QUOTE_CURRENCY ...]] [-a] @@ -242,6 +337,22 @@ optional arguments: Specify quote currency(-ies). Space-separated list. -a, --all Print all pairs or market symbols. By default only active ones are shown. + +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: `config.json`). + 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. + ``` By default, only active pairs/markets are shown. Active pairs/markets are those that can currently be traded @@ -263,7 +374,7 @@ $ freqtrade list-pairs --quote USD --print-json human-readable list with summary: ``` -$ freqtrade -c config_binance.json list-pairs --all --base BTC ETH --quote USDT USD --print-list +$ freqtrade list-pairs -c config_binance.json --all --base BTC ETH --quote USDT USD --print-list ``` * Print all markets on exchange "Kraken", in the tabular format: @@ -311,17 +422,49 @@ You can list the hyperoptimization epochs the Hyperopt module evaluated previous ``` usage: freqtrade hyperopt-list [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH] [--userdir PATH] [--best] - [--profitable] [--no-color] [--print-json] - [--no-details] + [--profitable] [--min-trades INT] + [--max-trades INT] [--min-avg-time FLOAT] + [--max-avg-time FLOAT] [--min-avg-profit FLOAT] + [--max-avg-profit FLOAT] + [--min-total-profit FLOAT] + [--max-total-profit FLOAT] [--no-color] + [--print-json] [--no-details] optional arguments: -h, --help show this help message and exit --best Select only best epochs. --profitable Select only profitable epochs. + --min-trades INT Select epochs with more than INT trades. + --max-trades INT Select epochs with less than INT trades. + --min-avg-time FLOAT Select epochs on above average time. + --max-avg-time FLOAT Select epochs on under average time. + --min-avg-profit FLOAT + Select epochs on above average profit. + --max-avg-profit FLOAT + Select epochs on below average profit. + --min-total-profit FLOAT + Select epochs on above total profit. + --max-total-profit FLOAT + Select epochs on below total profit. --no-color Disable colorization of hyperopt results. May be useful if you are redirecting output to a file. --print-json Print best result detailization in JSON format. --no-details Do not print best epoch details. + +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: `config.json`). + 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 diff --git a/docs/webhook-config.md b/docs/webhook-config.md index 9e0a34eae..e53aa8af5 100644 --- a/docs/webhook-config.md +++ b/docs/webhook-config.md @@ -15,11 +15,21 @@ Sample configuration (tested using IFTTT). "value2": "limit {limit:8f}", "value3": "{stake_amount:8f} {stake_currency}" }, + "webhookbuycancel": { + "value1": "Cancelling Open Buy Order for {pair}", + "value2": "limit {limit:8f}", + "value3": "{stake_amount:8f} {stake_currency}" + }, "webhooksell": { "value1": "Selling {pair}", "value2": "limit {limit:8f}", "value3": "profit: {profit_amount:8f} {stake_currency}" }, + "webhooksellcancel": { + "value1": "Cancelling Open Sell Order for {pair}", + "value2": "limit {limit:8f}", + "value3": "profit: {profit_amount:8f} {stake_currency}" + }, "webhookstatus": { "value1": "Status: {status}", "value2": "", @@ -40,10 +50,29 @@ Possible parameters are: * `exchange` * `pair` * `limit` +* `amount` +* `open_date` * `stake_amount` * `stake_currency` * `fiat_currency` * `order_type` +* `current_rate` + +### Webhookbuycancel + +The fields in `webhook.webhookbuycancel` are filled when the bot cancels a buy order. Parameters are filled using string.format. +Possible parameters are: + +* `exchange` +* `pair` +* `limit` +* `amount` +* `open_date` +* `stake_amount` +* `stake_currency` +* `fiat_currency` +* `order_type` +* `current_rate` ### Webhooksell @@ -66,6 +95,27 @@ Possible parameters are: * `open_date` * `close_date` +### Webhooksellcancel + +The fields in `webhook.webhooksellcancel` are filled when the bot cancels a sell order. Parameters are filled using string.format. +Possible parameters are: + +* `exchange` +* `pair` +* `gain` +* `limit` +* `amount` +* `open_rate` +* `current_rate` +* `profit_amount` +* `profit_percent` +* `stake_currency` +* `fiat_currency` +* `sell_reason` +* `order_type` +* `open_date` +* `close_date` + ### Webhookstatus The fields in `webhook.webhookstatus` are used for regular status messages (Started / Stopped / ...). Parameters are filled using string.format. diff --git a/freqtrade/__init__.py b/freqtrade/__init__.py index 15b7d66d7..bb1321237 100644 --- a/freqtrade/__init__.py +++ b/freqtrade/__init__.py @@ -1,13 +1,27 @@ -""" FreqTrade bot """ -__version__ = '2020.01' +""" Freqtrade bot """ +__version__ = '2020.02' if __version__ == 'develop': try: import subprocess + __version__ = 'develop-' + subprocess.check_output( ['git', 'log', '--format="%h"', '-n 1'], stderr=subprocess.DEVNULL).decode("utf-8").rstrip().strip('"') + + # from datetime import datetime + # last_release = subprocess.check_output( + # ['git', 'tag'] + # ).decode('utf-8').split()[-1].split(".") + # # Releases are in the format "2020.1" - we increment the latest version for dev. + # prefix = f"{last_release[0]}.{int(last_release[1]) + 1}" + # dev_version = int(datetime.now().timestamp() // 1000) + # __version__ = f"{prefix}.dev{dev_version}" + + # subprocess.check_output( + # ['git', 'log', '--format="%h"', '-n 1'], + # stderr=subprocess.DEVNULL).decode("utf-8").rstrip().strip('"') except Exception: # git not available, ignore pass diff --git a/freqtrade/commands/__init__.py b/freqtrade/commands/__init__.py index 990c1107a..f80c74e05 100644 --- a/freqtrade/commands/__init__.py +++ b/freqtrade/commands/__init__.py @@ -7,13 +7,16 @@ Note: Be careful with file-scoped imports in these subfiles. as they are parsed on startup, nothing containing optional modules should be loaded. """ from freqtrade.commands.arguments import Arguments -from freqtrade.commands.data_commands import start_download_data +from freqtrade.commands.build_config_commands import start_new_config +from freqtrade.commands.data_commands import (start_convert_data, + start_download_data) from freqtrade.commands.deploy_commands import (start_create_userdir, start_new_hyperopt, start_new_strategy) from freqtrade.commands.hyperopt_commands import (start_hyperopt_list, start_hyperopt_show) from freqtrade.commands.list_commands import (start_list_exchanges, + start_list_hyperopts, start_list_markets, start_list_strategies, start_list_timeframes) diff --git a/freqtrade/commands/arguments.py b/freqtrade/commands/arguments.py index 724814554..73e77d69d 100644 --- a/freqtrade/commands/arguments.py +++ b/freqtrade/commands/arguments.py @@ -6,8 +6,8 @@ from functools import partial from pathlib import Path from typing import Any, Dict, List, Optional -from freqtrade import constants from freqtrade.commands.cli_options import AVAILABLE_CLI_OPTIONS +from freqtrade.constants import DEFAULT_CONFIG ARGS_COMMON = ["verbosity", "logfile", "version", "config", "datadir", "user_data_dir"] @@ -30,7 +30,9 @@ ARGS_HYPEROPT = ARGS_COMMON_OPTIMIZE + ["hyperopt", "hyperopt_path", ARGS_EDGE = ARGS_COMMON_OPTIMIZE + ["stoploss_range"] -ARGS_LIST_STRATEGIES = ["strategy_path", "print_one_column"] +ARGS_LIST_STRATEGIES = ["strategy_path", "print_one_column", "print_colorized"] + +ARGS_LIST_HYPEROPTS = ["hyperopt_path", "print_one_column", "print_colorized"] ARGS_LIST_EXCHANGES = ["print_one_column", "list_exchanges_all"] @@ -43,12 +45,17 @@ ARGS_TEST_PAIRLIST = ["config", "quote_currencies", "print_one_column", "list_pa ARGS_CREATE_USERDIR = ["user_data_dir", "reset"] +ARGS_BUILD_CONFIG = ["config"] + ARGS_BUILD_STRATEGY = ["user_data_dir", "strategy", "template"] ARGS_BUILD_HYPEROPT = ["user_data_dir", "hyperopt", "template"] +ARGS_CONVERT_DATA = ["pairs", "format_from", "format_to", "erase"] +ARGS_CONVERT_DATA_OHLCV = ARGS_CONVERT_DATA + ["timeframes"] + ARGS_DOWNLOAD_DATA = ["pairs", "pairs_file", "days", "download_trades", "exchange", - "timeframes", "erase"] + "timeframes", "erase", "dataformat_ohlcv", "dataformat_trades"] ARGS_PLOT_DATAFRAME = ["pairs", "indicators1", "indicators2", "plot_limit", "db_url", "trade_source", "export", "exportfilename", @@ -57,15 +64,20 @@ ARGS_PLOT_DATAFRAME = ["pairs", "indicators1", "indicators2", "plot_limit", ARGS_PLOT_PROFIT = ["pairs", "timerange", "export", "exportfilename", "db_url", "trade_source", "ticker_interval"] -ARGS_HYPEROPT_LIST = ["hyperopt_list_best", "hyperopt_list_profitable", "print_colorized", - "print_json", "hyperopt_list_no_details"] +ARGS_HYPEROPT_LIST = ["hyperopt_list_best", "hyperopt_list_profitable", + "hyperopt_list_min_trades", "hyperopt_list_max_trades", + "hyperopt_list_min_avg_time", "hyperopt_list_max_avg_time", + "hyperopt_list_min_avg_profit", "hyperopt_list_max_avg_profit", + "hyperopt_list_min_total_profit", "hyperopt_list_max_total_profit", + "print_colorized", "print_json", "hyperopt_list_no_details"] ARGS_HYPEROPT_SHOW = ["hyperopt_list_best", "hyperopt_list_profitable", "hyperopt_show_index", "print_json", "hyperopt_show_no_header"] -NO_CONF_REQURIED = ["download-data", "list-timeframes", "list-markets", "list-pairs", - "list-strategies", "hyperopt-list", "hyperopt-show", "plot-dataframe", - "plot-profit"] +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"] NO_CONF_ALLOWED = ["create-userdir", "list-exchanges", "new-hyperopt", "new-strategy"] @@ -99,10 +111,23 @@ class Arguments: # Workaround issue in argparse with action='append' and default value # (see https://bugs.python.org/issue16399) # Allow no-config for certain commands (like downloading / plotting) - if ('config' in parsed_arg and parsed_arg.config is None and - ((Path.cwd() / constants.DEFAULT_CONFIG).is_file() or - not ('command' in parsed_arg and parsed_arg.command in NO_CONF_REQURIED))): - parsed_arg.config = [constants.DEFAULT_CONFIG] + if ('config' in parsed_arg and parsed_arg.config is None): + conf_required = ('command' in parsed_arg and parsed_arg.command in NO_CONF_REQURIED) + + if 'user_data_dir' in parsed_arg and parsed_arg.user_data_dir is not None: + user_dir = parsed_arg.user_data_dir + else: + # Default case + user_dir = 'user_data' + # Try loading from "user_data/config.json" + cfgfile = Path(user_dir) / DEFAULT_CONFIG + if cfgfile.is_file(): + parsed_arg.config = [str(cfgfile)] + else: + # Else use "config.json". + cfgfile = Path.cwd() / DEFAULT_CONFIG + if cfgfile.is_file() or not conf_required: + parsed_arg.config = [DEFAULT_CONFIG] return parsed_arg @@ -130,11 +155,13 @@ class Arguments: self.parser = argparse.ArgumentParser(description='Free, open source crypto trading bot') self._build_args(optionlist=['version'], parser=self.parser) - from freqtrade.commands import (start_create_userdir, start_download_data, + from freqtrade.commands import (start_create_userdir, start_convert_data, + start_download_data, start_hyperopt_list, start_hyperopt_show, - start_list_exchanges, start_list_markets, - start_list_strategies, start_new_hyperopt, - start_new_strategy, start_list_timeframes, + start_list_exchanges, start_list_hyperopts, + start_list_markets, start_list_strategies, + start_list_timeframes, start_new_config, + start_new_hyperopt, start_new_strategy, start_plot_dataframe, start_plot_profit, start_backtesting, start_hyperopt, start_edge, start_test_pairlist, start_trading) @@ -177,6 +204,12 @@ class Arguments: create_userdir_cmd.set_defaults(func=start_create_userdir) self._build_args(optionlist=ARGS_CREATE_USERDIR, parser=create_userdir_cmd) + # add new-config subcommand + build_config_cmd = subparsers.add_parser('new-config', + help="Create new config") + 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") @@ -198,6 +231,15 @@ class Arguments: 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', @@ -251,6 +293,24 @@ class Arguments: download_data_cmd.set_defaults(func=start_download_data) self._build_args(optionlist=ARGS_DOWNLOAD_DATA, parser=download_data_cmd) + # Add convert-data subcommand + convert_data_cmd = subparsers.add_parser( + 'convert-data', + help='Convert OHLCV data from one format to another.', + parents=[_common_parser], + ) + convert_data_cmd.set_defaults(func=partial(start_convert_data, ohlcv=True)) + self._build_args(optionlist=ARGS_CONVERT_DATA_OHLCV, parser=convert_data_cmd) + + # Add convert-trade-data subcommand + convert_trade_data_cmd = subparsers.add_parser( + 'convert-trade-data', + help='Convert trade-data from one format to another.', + parents=[_common_parser], + ) + 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', diff --git a/freqtrade/commands/build_config_commands.py b/freqtrade/commands/build_config_commands.py new file mode 100644 index 000000000..1598fa2ae --- /dev/null +++ b/freqtrade/commands/build_config_commands.py @@ -0,0 +1,193 @@ +import logging +from pathlib import Path +from typing import Any, Dict + +from questionary import Separator, prompt + +from freqtrade.constants import UNLIMITED_STAKE_AMOUNT +from freqtrade.exchange import available_exchanges, MAP_EXCHANGE_CHILDCLASS +from freqtrade.misc import render_template +from freqtrade.exceptions import OperationalException +logger = logging.getLogger(__name__) + + +def validate_is_int(val): + try: + _ = int(val) + return True + except Exception: + return False + + +def validate_is_float(val): + try: + _ = float(val) + return True + except Exception: + return False + + +def ask_user_overwrite(config_path: Path) -> bool: + questions = [ + { + "type": "confirm", + "name": "overwrite", + "message": f"File {config_path} already exists. Overwrite?", + "default": False, + }, + ] + answers = prompt(questions) + return answers['overwrite'] + + +def ask_user_config() -> Dict[str, Any]: + """ + Ask user a few questions to build the configuration. + Interactive questions built using https://github.com/tmbo/questionary + :returns: Dict with keys to put into template + """ + questions = [ + { + "type": "confirm", + "name": "dry_run", + "message": "Do you want to enable Dry-run (simulated trades)?", + "default": True, + }, + { + "type": "text", + "name": "stake_currency", + "message": "Please insert your stake currency:", + "default": 'BTC', + }, + { + "type": "text", + "name": "stake_amount", + "message": "Please insert your stake amount:", + "default": "0.01", + "validate": lambda val: val == UNLIMITED_STAKE_AMOUNT or validate_is_float(val), + }, + { + "type": "text", + "name": "max_open_trades", + "message": f"Please insert max_open_trades (Integer or '{UNLIMITED_STAKE_AMOUNT}'):", + "default": "3", + "validate": lambda val: val == UNLIMITED_STAKE_AMOUNT or validate_is_int(val) + }, + { + "type": "text", + "name": "ticker_interval", + "message": "Please insert your ticker interval:", + "default": "5m", + }, + { + "type": "text", + "name": "fiat_display_currency", + "message": "Please insert your display Currency (for reporting):", + "default": 'USD', + }, + { + "type": "select", + "name": "exchange_name", + "message": "Select exchange", + "choices": [ + "binance", + "binanceje", + "binanceus", + "bittrex", + "kraken", + Separator(), + "other", + ], + }, + { + "type": "autocomplete", + "name": "exchange_name", + "message": "Type your exchange name (Must be supported by ccxt)", + "choices": available_exchanges(), + "when": lambda x: x["exchange_name"] == 'other' + }, + { + "type": "password", + "name": "exchange_key", + "message": "Insert Exchange Key", + "when": lambda x: not x['dry_run'] + }, + { + "type": "password", + "name": "exchange_secret", + "message": "Insert Exchange Secret", + "when": lambda x: not x['dry_run'] + }, + { + "type": "confirm", + "name": "telegram", + "message": "Do you want to enable Telegram?", + "default": False, + }, + { + "type": "password", + "name": "telegram_token", + "message": "Insert Telegram token", + "when": lambda x: x['telegram'] + }, + { + "type": "text", + "name": "telegram_chat_id", + "message": "Insert Telegram chat id", + "when": lambda x: x['telegram'] + }, + ] + answers = prompt(questions) + + if not answers: + # Interrupted questionary sessions return an empty dict. + raise OperationalException("User interrupted interactive questions.") + + return answers + + +def deploy_new_config(config_path: Path, selections: Dict[str, Any]) -> None: + """ + Applies selections to the template and writes the result to config_path + :param config_path: Path object for new config file. Should not exist yet + :param selecions: Dict containing selections taken by the user. + """ + from jinja2.exceptions import TemplateNotFound + try: + exchange_template = MAP_EXCHANGE_CHILDCLASS.get( + selections['exchange_name'], selections['exchange_name']) + + selections['exchange'] = render_template( + templatefile=f"subtemplates/exchange_{exchange_template}.j2", + arguments=selections + ) + except TemplateNotFound: + selections['exchange'] = render_template( + templatefile=f"subtemplates/exchange_generic.j2", + arguments=selections + ) + + config_text = render_template(templatefile='base_config.json.j2', + arguments=selections) + + logger.info(f"Writing config to `{config_path}`.") + config_path.write_text(config_text) + + +def start_new_config(args: Dict[str, Any]) -> None: + """ + Create a new strategy from a template + Asking the user questions to fill out the templateaccordingly. + """ + + config_path = Path(args['config'][0]) + if config_path.exists(): + overwrite = ask_user_overwrite(config_path) + if overwrite: + config_path.unlink() + else: + raise OperationalException( + f"Configuration file `{config_path}` already exists. " + "Please delete it or use a different configuration file name.") + selections = ask_user_config() + deploy_new_config(config_path, selections) diff --git a/freqtrade/commands/cli_options.py b/freqtrade/commands/cli_options.py index 490f26cfa..a8d4bc198 100644 --- a/freqtrade/commands/cli_options.py +++ b/freqtrade/commands/cli_options.py @@ -59,7 +59,8 @@ AVAILABLE_CLI_OPTIONS = { ), "config": Arg( '-c', '--config', - help=f'Specify configuration file (default: `{constants.DEFAULT_CONFIG}`). ' + help=f'Specify configuration file (default: `userdir/{constants.DEFAULT_CONFIG}` ' + f'or `config.json` whichever exists). ' f'Multiple --config options may be used. ' f'Can be set to `-` to read config from stdin.', action='append', @@ -256,7 +257,7 @@ AVAILABLE_CLI_OPTIONS = { help='Specify the class name of the hyperopt loss function class (IHyperOptLoss). ' 'Different functions can generate completely different results, ' 'since the target for optimization is different. Built-in Hyperopt-loss-functions are: ' - 'DefaultHyperOptLoss, OnlyProfitHyperOptLoss, SharpeHyperOptLoss.' + 'DefaultHyperOptLoss, OnlyProfitHyperOptLoss, SharpeHyperOptLoss, SharpeHyperOptLossDaily.' '(default: `%(default)s`).', metavar='NAME', default=constants.DEFAULT_HYPEROPT_LOSS, @@ -332,6 +333,30 @@ AVAILABLE_CLI_OPTIONS = { 'desired timeframe as specified as --timeframes/-t.', action='store_true', ), + "format_from": Arg( + '--format-from', + help='Source format for data conversion.', + choices=constants.AVAILABLE_DATAHANDLERS, + required=True, + ), + "format_to": Arg( + '--format-to', + help='Destination format for data conversion.', + choices=constants.AVAILABLE_DATAHANDLERS, + required=True, + ), + "dataformat_ohlcv": Arg( + '--data-format-ohlcv', + help='Storage format for downloaded ohlcv data. (default: `%(default)s`).', + choices=constants.AVAILABLE_DATAHANDLERS, + default='json' + ), + "dataformat_trades": Arg( + '--data-format-trades', + help='Storage format for downloaded trades data. (default: `%(default)s`).', + choices=constants.AVAILABLE_DATAHANDLERS, + default='jsongz' + ), "exchange": Arg( '--exchange', help=f'Exchange name (default: `{constants.DEFAULT_EXCHANGE}`). ' @@ -398,6 +423,54 @@ AVAILABLE_CLI_OPTIONS = { help='Select only best epochs.', action='store_true', ), + "hyperopt_list_min_trades": Arg( + '--min-trades', + help='Select epochs with more than INT trades.', + type=check_int_positive, + metavar='INT', + ), + "hyperopt_list_max_trades": Arg( + '--max-trades', + help='Select epochs with less than INT trades.', + type=check_int_positive, + metavar='INT', + ), + "hyperopt_list_min_avg_time": Arg( + '--min-avg-time', + help='Select epochs on above average time.', + type=float, + metavar='FLOAT', + ), + "hyperopt_list_max_avg_time": Arg( + '--max-avg-time', + help='Select epochs on under average time.', + type=float, + metavar='FLOAT', + ), + "hyperopt_list_min_avg_profit": Arg( + '--min-avg-profit', + help='Select epochs on above average profit.', + type=float, + metavar='FLOAT', + ), + "hyperopt_list_max_avg_profit": Arg( + '--max-avg-profit', + help='Select epochs on below average profit.', + type=float, + metavar='FLOAT', + ), + "hyperopt_list_min_total_profit": Arg( + '--min-total-profit', + help='Select epochs on above total profit.', + type=float, + metavar='FLOAT', + ), + "hyperopt_list_max_total_profit": Arg( + '--max-total-profit', + help='Select epochs on below total profit.', + type=float, + metavar='FLOAT', + ), "hyperopt_list_no_details": Arg( '--no-details', help='Do not print best epoch details.', diff --git a/freqtrade/commands/data_commands.py b/freqtrade/commands/data_commands.py index c01772023..fc3a49f1d 100644 --- a/freqtrade/commands/data_commands.py +++ b/freqtrade/commands/data_commands.py @@ -5,6 +5,8 @@ from typing import Any, Dict, List import arrow from freqtrade.configuration import TimeRange, setup_utils_configuration +from freqtrade.data.converter import (convert_ohlcv_format, + convert_trades_format) from freqtrade.data.history import (convert_trades_to_ohlcv, refresh_backtest_ohlcv_data, refresh_backtest_trades_data) @@ -37,22 +39,32 @@ def start_download_data(args: Dict[str, Any]) -> None: pairs_not_available: List[str] = [] # Init exchange - exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config) + exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config, validate=False) + # Manual validations of relevant settings + exchange.validate_pairs(config['pairs']) + for timeframe in config['timeframes']: + exchange.validate_timeframes(timeframe) + try: if config.get('download_trades'): pairs_not_available = refresh_backtest_trades_data( exchange, pairs=config["pairs"], datadir=config['datadir'], - timerange=timerange, erase=config.get("erase")) + timerange=timerange, erase=bool(config.get("erase")), + data_format=config['dataformat_trades']) # Convert downloaded trade data to different timeframes convert_trades_to_ohlcv( pairs=config["pairs"], timeframes=config["timeframes"], - datadir=config['datadir'], timerange=timerange, erase=config.get("erase")) + datadir=config['datadir'], timerange=timerange, erase=bool(config.get("erase")), + data_format_ohlcv=config['dataformat_ohlcv'], + data_format_trades=config['dataformat_trades'], + ) else: pairs_not_available = refresh_backtest_ohlcv_data( exchange, pairs=config["pairs"], timeframes=config["timeframes"], - datadir=config['datadir'], timerange=timerange, erase=config.get("erase")) + datadir=config['datadir'], timerange=timerange, erase=bool(config.get("erase")), + data_format=config['dataformat_ohlcv']) except KeyboardInterrupt: sys.exit("SIGINT received, aborting ...") @@ -61,3 +73,18 @@ def start_download_data(args: Dict[str, Any]) -> None: if pairs_not_available: logger.info(f"Pairs [{','.join(pairs_not_available)}] not available " f"on exchange {exchange.name}.") + + +def start_convert_data(args: Dict[str, Any], ohlcv: bool = True) -> None: + """ + Convert data from one format to another + """ + config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE) + if ohlcv: + convert_ohlcv_format(config, + convert_from=args['format_from'], convert_to=args['format_to'], + erase=args['erase']) + else: + convert_trades_format(config, + convert_from=args['format_from'], convert_to=args['format_to'], + erase=args['erase']) diff --git a/freqtrade/commands/deploy_commands.py b/freqtrade/commands/deploy_commands.py index 99ae63244..f5a68f748 100644 --- a/freqtrade/commands/deploy_commands.py +++ b/freqtrade/commands/deploy_commands.py @@ -6,7 +6,7 @@ from typing import Any, Dict from freqtrade.configuration import setup_utils_configuration from freqtrade.configuration.directory_operations import (copy_sample_files, create_userdata_dir) -from freqtrade.constants import USERPATH_HYPEROPTS, USERPATH_STRATEGY +from freqtrade.constants import USERPATH_HYPEROPTS, USERPATH_STRATEGIES from freqtrade.exceptions import OperationalException from freqtrade.misc import render_template from freqtrade.state import RunMode @@ -28,7 +28,7 @@ def start_create_userdir(args: Dict[str, Any]) -> None: sys.exit(1) -def deploy_new_strategy(strategy_name, strategy_path: Path, subtemplate: str): +def deploy_new_strategy(strategy_name: str, strategy_path: Path, subtemplate: str) -> None: """ Deploy new strategy from template to strategy_path """ @@ -57,7 +57,7 @@ def start_new_strategy(args: Dict[str, Any]) -> None: if args["strategy"] == "DefaultStrategy": raise OperationalException("DefaultStrategy is not allowed as name.") - new_path = config['user_data_dir'] / USERPATH_STRATEGY / (args["strategy"] + ".py") + new_path = config['user_data_dir'] / USERPATH_STRATEGIES / (args["strategy"] + ".py") if new_path.exists(): raise OperationalException(f"`{new_path}` already exists. " @@ -69,7 +69,7 @@ def start_new_strategy(args: Dict[str, Any]) -> None: raise OperationalException("`new-strategy` requires --strategy to be set.") -def deploy_new_hyperopt(hyperopt_name, hyperopt_path: Path, subtemplate: str): +def deploy_new_hyperopt(hyperopt_name: str, hyperopt_path: Path, subtemplate: str) -> None: """ Deploys a new hyperopt template to hyperopt_path """ diff --git a/freqtrade/commands/hyperopt_commands.py b/freqtrade/commands/hyperopt_commands.py old mode 100644 new mode 100755 index 5c6f25848..8c1c80d98 --- a/freqtrade/commands/hyperopt_commands.py +++ b/freqtrade/commands/hyperopt_commands.py @@ -19,13 +19,24 @@ def start_hyperopt_list(args: Dict[str, Any]) -> None: config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE) - only_best = config.get('hyperopt_list_best', False) - only_profitable = config.get('hyperopt_list_profitable', False) print_colorized = config.get('print_colorized', False) print_json = config.get('print_json', False) no_details = config.get('hyperopt_list_no_details', False) no_header = False + filteroptions = { + 'only_best': config.get('hyperopt_list_best', False), + 'only_profitable': config.get('hyperopt_list_profitable', False), + 'filter_min_trades': config.get('hyperopt_list_min_trades', 0), + 'filter_max_trades': config.get('hyperopt_list_max_trades', 0), + 'filter_min_avg_time': config.get('hyperopt_list_min_avg_time', None), + 'filter_max_avg_time': config.get('hyperopt_list_max_avg_time', None), + 'filter_min_avg_profit': config.get('hyperopt_list_min_avg_profit', None), + 'filter_max_avg_profit': config.get('hyperopt_list_max_avg_profit', None), + 'filter_min_total_profit': config.get('hyperopt_list_min_total_profit', None), + 'filter_max_total_profit': config.get('hyperopt_list_max_total_profit', None) + } + trials_file = (config['user_data_dir'] / 'hyperopt_results' / 'hyperopt_results.pickle') @@ -33,7 +44,7 @@ def start_hyperopt_list(args: Dict[str, Any]) -> None: trials = Hyperopt.load_previous_results(trials_file) total_epochs = len(trials) - trials = _hyperopt_filter_trials(trials, only_best, only_profitable) + trials = _hyperopt_filter_trials(trials, filteroptions) # TODO: fetch the interval for epochs to print from the cli option epoch_start, epoch_stop = 0, None @@ -44,7 +55,8 @@ def start_hyperopt_list(args: Dict[str, Any]) -> None: try: # Human-friendly indexes used here (starting from 1) for val in trials[epoch_start:epoch_stop]: - Hyperopt.print_results_explanation(val, total_epochs, not only_best, print_colorized) + Hyperopt.print_results_explanation(val, total_epochs, + not filteroptions['only_best'], print_colorized) except KeyboardInterrupt: print('User interrupted..') @@ -63,8 +75,18 @@ def start_hyperopt_show(args: Dict[str, Any]) -> None: config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE) - only_best = config.get('hyperopt_list_best', False) - only_profitable = config.get('hyperopt_list_profitable', False) + filteroptions = { + 'only_best': config.get('hyperopt_list_best', False), + 'only_profitable': config.get('hyperopt_list_profitable', False), + 'filter_min_trades': config.get('hyperopt_list_min_trades', 0), + 'filter_max_trades': config.get('hyperopt_list_max_trades', 0), + 'filter_min_avg_time': config.get('hyperopt_list_min_avg_time', None), + 'filter_max_avg_time': config.get('hyperopt_list_max_avg_time', None), + 'filter_min_avg_profit': config.get('hyperopt_list_min_avg_profit', None), + 'filter_max_avg_profit': config.get('hyperopt_list_max_avg_profit', None), + 'filter_min_total_profit': config.get('hyperopt_list_min_total_profit', None), + 'filter_max_total_profit': config.get('hyperopt_list_max_total_profit', None) + } no_header = config.get('hyperopt_show_no_header', False) trials_file = (config['user_data_dir'] / @@ -74,7 +96,7 @@ def start_hyperopt_show(args: Dict[str, Any]) -> None: trials = Hyperopt.load_previous_results(trials_file) total_epochs = len(trials) - trials = _hyperopt_filter_trials(trials, only_best, only_profitable) + trials = _hyperopt_filter_trials(trials, filteroptions) trials_epochs = len(trials) n = config.get('hyperopt_show_index', -1) @@ -97,18 +119,66 @@ def start_hyperopt_show(args: Dict[str, Any]) -> None: header_str="Epoch details") -def _hyperopt_filter_trials(trials: List, only_best: bool, only_profitable: bool) -> List: +def _hyperopt_filter_trials(trials: List, filteroptions: dict) -> List: """ Filter our items from the list of hyperopt results """ - if only_best: + if filteroptions['only_best']: trials = [x for x in trials if x['is_best']] - if only_profitable: + if filteroptions['only_profitable']: trials = [x for x in trials if x['results_metrics']['profit'] > 0] + if filteroptions['filter_min_trades'] > 0: + trials = [ + x for x in trials + if x['results_metrics']['trade_count'] > filteroptions['filter_min_trades'] + ] + if filteroptions['filter_max_trades'] > 0: + trials = [ + x for x in trials + if x['results_metrics']['trade_count'] < filteroptions['filter_max_trades'] + ] + if filteroptions['filter_min_avg_time'] is not None: + trials = [x for x in trials if x['results_metrics']['trade_count'] > 0] + trials = [ + x for x in trials + if x['results_metrics']['duration'] > filteroptions['filter_min_avg_time'] + ] + if filteroptions['filter_max_avg_time'] is not None: + trials = [x for x in trials if x['results_metrics']['trade_count'] > 0] + trials = [ + x for x in trials + if x['results_metrics']['duration'] < filteroptions['filter_max_avg_time'] + ] + if filteroptions['filter_min_avg_profit'] is not None: + trials = [x for x in trials if x['results_metrics']['trade_count'] > 0] + trials = [ + x for x in trials + if x['results_metrics']['avg_profit'] + > filteroptions['filter_min_avg_profit'] + ] + if filteroptions['filter_max_avg_profit'] is not None: + trials = [x for x in trials if x['results_metrics']['trade_count'] > 0] + trials = [ + x for x in trials + if x['results_metrics']['avg_profit'] + < filteroptions['filter_max_avg_profit'] + ] + if filteroptions['filter_min_total_profit'] is not None: + trials = [x for x in trials if x['results_metrics']['trade_count'] > 0] + trials = [ + x for x in trials + if x['results_metrics']['profit'] > filteroptions['filter_min_total_profit'] + ] + if filteroptions['filter_max_total_profit'] is not None: + trials = [x for x in trials if x['results_metrics']['trade_count'] > 0] + trials = [ + x for x in trials + if x['results_metrics']['profit'] < filteroptions['filter_max_total_profit'] + ] logger.info(f"{len(trials)} " + - ("best " if only_best else "") + - ("profitable " if only_profitable else "") + + ("best " if filteroptions['only_best'] else "") + + ("profitable " if filteroptions['only_profitable'] else "") + "epochs found.") return trials diff --git a/freqtrade/commands/list_commands.py b/freqtrade/commands/list_commands.py index 022822782..49674b81a 100644 --- a/freqtrade/commands/list_commands.py +++ b/freqtrade/commands/list_commands.py @@ -3,13 +3,15 @@ import logging import sys from collections import OrderedDict from pathlib import Path -from typing import Any, Dict +from typing import Any, Dict, List +from colorama import init as colorama_init +from colorama import Fore, Style import rapidjson from tabulate import tabulate from freqtrade.configuration import setup_utils_configuration -from freqtrade.constants import USERPATH_STRATEGY +from freqtrade.constants import USERPATH_HYPEROPTS, USERPATH_STRATEGIES from freqtrade.exceptions import OperationalException from freqtrade.exchange import (available_exchanges, ccxt_exchanges, market_is_active, symbol_is_pair) @@ -36,22 +38,63 @@ def start_list_exchanges(args: Dict[str, Any]) -> None: print(f"Exchanges available for Freqtrade: {', '.join(exchanges)}") +def _print_objs_tabular(objs: List, print_colorized: bool) -> None: + if print_colorized: + colorama_init(autoreset=True) + red = Fore.RED + yellow = Fore.YELLOW + reset = Style.RESET_ALL + else: + red = '' + yellow = '' + reset = '' + + names = [s['name'] for s in objs] + objss_to_print = [{ + 'name': s['name'] if s['name'] else "--", + 'location': s['location'].name, + 'status': (red + "LOAD FAILED" + reset if s['class'] is None + else "OK" if names.count(s['name']) == 1 + else yellow + "DUPLICATE NAME" + reset) + } for s in objs] + + print(tabulate(objss_to_print, headers='keys', tablefmt='pipe')) + + def start_list_strategies(args: Dict[str, Any]) -> None: """ - Print Strategies available in a directory + Print files with Strategy custom classes available in the directory """ config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE) - directory = Path(config.get('strategy_path', config['user_data_dir'] / USERPATH_STRATEGY)) - strategies = StrategyResolver.search_all_objects(directory) + directory = Path(config.get('strategy_path', config['user_data_dir'] / USERPATH_STRATEGIES)) + strategy_objs = StrategyResolver.search_all_objects(directory, not args['print_one_column']) # Sort alphabetically - strategies = sorted(strategies, key=lambda x: x['name']) - strats_to_print = [{'name': s['name'], 'location': s['location'].name} for s in strategies] + strategy_objs = sorted(strategy_objs, key=lambda x: x['name']) if args['print_one_column']: - print('\n'.join([s['name'] for s in strategies])) + print('\n'.join([s['name'] for s in strategy_objs])) else: - print(tabulate(strats_to_print, headers='keys', tablefmt='pipe')) + _print_objs_tabular(strategy_objs, config.get('print_colorized', False)) + + +def start_list_hyperopts(args: Dict[str, Any]) -> None: + """ + Print files with HyperOpt custom classes available in the directory + """ + from freqtrade.resolvers.hyperopt_resolver import HyperOptResolver + + config = setup_utils_configuration(args, RunMode.UTIL_NO_EXCHANGE) + + directory = Path(config.get('hyperopt_path', config['user_data_dir'] / USERPATH_HYPEROPTS)) + hyperopt_objs = HyperOptResolver.search_all_objects(directory, not args['print_one_column']) + # Sort alphabetically + hyperopt_objs = sorted(hyperopt_objs, key=lambda x: x['name']) + + if args['print_one_column']: + print('\n'.join([s['name'] for s in hyperopt_objs])) + else: + _print_objs_tabular(hyperopt_objs, config.get('print_colorized', False)) def start_list_timeframes(args: Dict[str, Any]) -> None: diff --git a/freqtrade/commands/plot_commands.py b/freqtrade/commands/plot_commands.py index 028933ba7..5e547acb0 100644 --- a/freqtrade/commands/plot_commands.py +++ b/freqtrade/commands/plot_commands.py @@ -5,7 +5,7 @@ from freqtrade.exceptions import OperationalException from freqtrade.state import RunMode -def validate_plot_args(args: Dict[str, Any]): +def validate_plot_args(args: Dict[str, Any]) -> None: if not args.get('datadir') and not args.get('config'): raise OperationalException( "You need to specify either `--datadir` or `--config` " diff --git a/freqtrade/configuration/check_exchange.py b/freqtrade/configuration/check_exchange.py index 0076b1c5d..92daaf251 100644 --- a/freqtrade/configuration/check_exchange.py +++ b/freqtrade/configuration/check_exchange.py @@ -10,7 +10,7 @@ from freqtrade.state import RunMode logger = logging.getLogger(__name__) -def remove_credentials(config: Dict[str, Any]): +def remove_credentials(config: Dict[str, Any]) -> None: """ Removes exchange keys from the configuration and specifies dry-run Used for backtesting / hyperopt / edge and utils. diff --git a/freqtrade/configuration/configuration.py b/freqtrade/configuration/configuration.py index a8b7638c8..21b3e3bd3 100644 --- a/freqtrade/configuration/configuration.py +++ b/freqtrade/configuration/configuration.py @@ -310,6 +310,30 @@ class Configuration: self._args_to_config(config, argname='hyperopt_list_profitable', logstring='Parameter --profitable detected: {}') + self._args_to_config(config, argname='hyperopt_list_min_trades', + logstring='Parameter --min-trades detected: {}') + + self._args_to_config(config, argname='hyperopt_list_max_trades', + logstring='Parameter --max-trades detected: {}') + + self._args_to_config(config, argname='hyperopt_list_min_avg_time', + logstring='Parameter --min-avg-time detected: {}') + + self._args_to_config(config, argname='hyperopt_list_max_avg_time', + logstring='Parameter --max-avg-time detected: {}') + + self._args_to_config(config, argname='hyperopt_list_min_avg_profit', + logstring='Parameter --min-avg-profit detected: {}') + + self._args_to_config(config, argname='hyperopt_list_max_avg_profit', + logstring='Parameter --max-avg-profit detected: {}') + + self._args_to_config(config, argname='hyperopt_list_min_total_profit', + logstring='Parameter --min-total-profit detected: {}') + + self._args_to_config(config, argname='hyperopt_list_max_total_profit', + logstring='Parameter --max-total-profit detected: {}') + self._args_to_config(config, argname='hyperopt_list_no_details', logstring='Parameter --no-details detected: {}') @@ -340,9 +364,16 @@ class Configuration: self._args_to_config(config, argname='days', logstring='Detected --days: {}') + self._args_to_config(config, argname='download_trades', logstring='Detected --dl-trades: {}') + self._args_to_config(config, argname='dataformat_ohlcv', + logstring='Using "{}" to store OHLCV data.') + + self._args_to_config(config, argname='dataformat_trades', + logstring='Using "{}" to store trades data.') + def _process_runmode(self, config: Dict[str, Any]) -> None: if not self.runmode: diff --git a/freqtrade/configuration/deprecated_settings.py b/freqtrade/configuration/deprecated_settings.py index 78d8218d4..55497d4f5 100644 --- a/freqtrade/configuration/deprecated_settings.py +++ b/freqtrade/configuration/deprecated_settings.py @@ -13,7 +13,7 @@ logger = logging.getLogger(__name__) def check_conflicting_settings(config: Dict[str, Any], section1: str, name1: str, - section2: str, name2: str): + section2: str, name2: str) -> None: section1_config = config.get(section1, {}) section2_config = config.get(section2, {}) if name1 in section1_config and name2 in section2_config: @@ -28,7 +28,7 @@ def check_conflicting_settings(config: Dict[str, Any], def process_deprecated_setting(config: Dict[str, Any], section1: str, name1: str, - section2: str, name2: str): + section2: str, name2: str) -> None: section2_config = config.get(section2, {}) if name2 in section2_config: diff --git a/freqtrade/configuration/directory_operations.py b/freqtrade/configuration/directory_operations.py index 43a209483..5f8eb76b0 100644 --- a/freqtrade/configuration/directory_operations.py +++ b/freqtrade/configuration/directory_operations.py @@ -23,7 +23,7 @@ def create_datadir(config: Dict[str, Any], datadir: Optional[str] = None) -> Pat return folder -def create_userdata_dir(directory: str, create_dir=False) -> Path: +def create_userdata_dir(directory: str, create_dir: bool = False) -> Path: """ Create userdata directory structure. if create_dir is True, then the parent-directory will be created if it does not exist. diff --git a/freqtrade/configuration/timerange.py b/freqtrade/configuration/timerange.py index a8be873df..3db5f6217 100644 --- a/freqtrade/configuration/timerange.py +++ b/freqtrade/configuration/timerange.py @@ -7,6 +7,7 @@ from typing import Optional import arrow + logger = logging.getLogger(__name__) @@ -30,7 +31,7 @@ class TimeRange: return (self.starttype == other.starttype and self.stoptype == other.stoptype and self.startts == other.startts and self.stopts == other.stopts) - def subtract_start(self, seconds) -> None: + def subtract_start(self, seconds: int) -> None: """ Subtracts from startts if startts is set. :param seconds: Seconds to subtract from starttime @@ -59,7 +60,7 @@ class TimeRange: self.starttype = 'date' @staticmethod - def parse_timerange(text: Optional[str]): + def parse_timerange(text: Optional[str]) -> 'TimeRange': """ Parse the value of the argument --timerange to determine what is the range desired :param text: value from --timerange diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 53bc4af53..105cd6b53 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -17,20 +17,24 @@ REQUIRED_ORDERTIF = ['buy', 'sell'] REQUIRED_ORDERTYPES = ['buy', 'sell', 'stoploss', 'stoploss_on_exchange'] ORDERTYPE_POSSIBILITIES = ['limit', 'market'] ORDERTIF_POSSIBILITIES = ['gtc', 'fok', 'ioc'] -AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList', 'PrecisionFilter', 'PriceFilter'] +AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList', + 'PrecisionFilter', 'PriceFilter', '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'] USERPATH_HYPEROPTS = 'hyperopts' -USERPATH_STRATEGY = 'strategies' +USERPATH_STRATEGIES = 'strategies' +USERPATH_NOTEBOOKS = 'notebooks' # Soure files with destination directories within user-directory USER_DATA_FILES = { - 'sample_strategy.py': USERPATH_STRATEGY, + 'sample_strategy.py': USERPATH_STRATEGIES, 'sample_hyperopt_advanced.py': USERPATH_HYPEROPTS, 'sample_hyperopt_loss.py': USERPATH_HYPEROPTS, 'sample_hyperopt.py': USERPATH_HYPEROPTS, - 'strategy_analysis_example.ipynb': 'notebooks', + 'strategy_analysis_example.ipynb': USERPATH_NOTEBOOKS, } SUPPORTED_FIAT = [ @@ -76,7 +80,7 @@ CONF_SCHEMA = { 'amend_last_stake_amount': {'type': 'boolean', 'default': False}, 'last_stake_amount_min_ratio': { 'type': 'number', 'minimum': 0.0, 'maximum': 1.0, 'default': 0.5 - }, + }, 'fiat_display_currency': {'type': 'string', 'enum': SUPPORTED_FIAT}, 'dry_run': {'type': 'boolean'}, 'dry_run_wallet': {'type': 'number', 'default': DRY_RUN_WALLET}, @@ -189,7 +193,9 @@ CONF_SCHEMA = { 'properties': { 'enabled': {'type': 'boolean'}, 'webhookbuy': {'type': 'object'}, + 'webhookbuycancel': {'type': 'object'}, 'webhooksell': {'type': 'object'}, + 'webhooksellcancel': {'type': 'object'}, 'webhookstatus': {'type': 'object'}, }, }, @@ -213,11 +219,22 @@ CONF_SCHEMA = { 'forcebuy_enable': {'type': 'boolean'}, 'internals': { 'type': 'object', + 'default': {}, 'properties': { 'process_throttle_secs': {'type': 'integer'}, 'interval': {'type': 'integer'}, 'sd_notify': {'type': 'boolean'}, } + }, + 'dataformat_ohlcv': { + 'type': 'string', + 'enum': AVAILABLE_DATAHANDLERS, + 'default': 'json' + }, + 'dataformat_trades': { + 'type': 'string', + 'enum': AVAILABLE_DATAHANDLERS, + 'default': 'jsongz' } }, 'definitions': { @@ -288,9 +305,14 @@ SCHEMA_TRADE_REQUIRED = [ 'unfilledtimeout', 'stoploss', 'minimal_roi', + 'internals', + 'dataformat_ohlcv', + 'dataformat_trades', ] SCHEMA_MINIMAL_REQUIRED = [ 'exchange', 'dry_run', + 'dataformat_ohlcv', + 'dataformat_trades', ] diff --git a/freqtrade/data/btanalysis.py b/freqtrade/data/btanalysis.py index 04b2ca980..c28e462ba 100644 --- a/freqtrade/data/btanalysis.py +++ b/freqtrade/data/btanalysis.py @@ -3,7 +3,7 @@ Helpers when analyzing backtest data """ import logging from pathlib import Path -from typing import Dict +from typing import Dict, Union import numpy as np import pandas as pd @@ -20,7 +20,7 @@ BT_DATA_COLUMNS = ["pair", "profitperc", "open_time", "close_time", "index", "du "open_rate", "close_rate", "open_at_end", "sell_reason"] -def load_backtest_data(filename) -> pd.DataFrame: +def load_backtest_data(filename: Union[Path, str]) -> pd.DataFrame: """ Load backtest data file. :param filename: pathlib.Path object, or string pointing to the file. @@ -151,7 +151,8 @@ def extract_trades_of_period(dataframe: pd.DataFrame, trades: pd.DataFrame) -> p return trades -def combine_tickers_with_mean(tickers: Dict[str, pd.DataFrame], column: str = "close"): +def combine_tickers_with_mean(tickers: Dict[str, pd.DataFrame], + column: str = "close") -> pd.DataFrame: """ Combine multiple dataframes "column" :param tickers: Dict of Dataframes, dict key should be pair. diff --git a/freqtrade/data/converter.py b/freqtrade/data/converter.py index e45dd451e..49a2a25bc 100644 --- a/freqtrade/data/converter.py +++ b/freqtrade/data/converter.py @@ -2,10 +2,13 @@ Functions to convert data from one format to another """ import logging +from datetime import datetime, timezone +from typing import Any, Dict import pandas as pd from pandas import DataFrame, to_datetime +from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS logger = logging.getLogger(__name__) @@ -24,7 +27,7 @@ def parse_ticker_dataframe(ticker: list, timeframe: str, pair: str, *, :return: DataFrame """ logger.debug("Parsing tickerlist to dataframe") - cols = ['date', 'open', 'high', 'low', 'close', 'volume'] + cols = DEFAULT_DATAFRAME_COLUMNS frame = DataFrame(ticker, columns=cols) frame['date'] = to_datetime(frame['date'], @@ -37,9 +40,29 @@ def parse_ticker_dataframe(ticker: list, timeframe: str, pair: str, *, # and fail with exception... frame = frame.astype(dtype={'open': 'float', 'high': 'float', 'low': 'float', 'close': 'float', 'volume': 'float'}) + return clean_ohlcv_dataframe(frame, timeframe, pair, + fill_missing=fill_missing, + drop_incomplete=drop_incomplete) + +def clean_ohlcv_dataframe(data: DataFrame, timeframe: str, pair: str, *, + fill_missing: bool = True, + drop_incomplete: bool = True) -> DataFrame: + """ + Clense a ohlcv dataframe by + * Grouping it by date (removes duplicate tics) + * dropping last candles if requested + * Filling up missing data (if requested) + :param data: DataFrame containing ohlcv data. + :param timeframe: timeframe (e.g. 5m). Used to fill up eventual missing data + :param pair: Pair this data is for (used to warn if fillup was necessary) + :param fill_missing: fill up missing candles with 0 candles + (see ohlcv_fill_up_missing_data for details) + :param drop_incomplete: Drop the last candle of the dataframe, assuming it's incomplete + :return: DataFrame + """ # group by index and aggregate results to eliminate duplicate ticks - frame = frame.groupby(by='date', as_index=False, sort=True).agg({ + data = data.groupby(by='date', as_index=False, sort=True).agg({ 'open': 'first', 'high': 'max', 'low': 'min', @@ -48,13 +71,13 @@ def parse_ticker_dataframe(ticker: list, timeframe: str, pair: str, *, }) # eliminate partial candle if drop_incomplete: - frame.drop(frame.tail(1).index, inplace=True) + data.drop(data.tail(1).index, inplace=True) logger.debug('Dropping last candle') if fill_missing: - return ohlcv_fill_up_missing_data(frame, timeframe, pair) + return ohlcv_fill_up_missing_data(data, timeframe, pair) else: - return frame + return data def ohlcv_fill_up_missing_data(dataframe: DataFrame, timeframe: str, pair: str) -> DataFrame: @@ -92,8 +115,26 @@ def ohlcv_fill_up_missing_data(dataframe: DataFrame, timeframe: str, pair: str) return df +def trim_dataframe(df: DataFrame, timerange, df_date_col: str = 'date') -> DataFrame: + """ + Trim dataframe based on given timerange + :param df: Dataframe to trim + :param timerange: timerange (use start and end date if available) + :param: df_date_col: Column in the dataframe to use as Date column + :return: trimmed dataframe + """ + if timerange.starttype == 'date': + start = datetime.fromtimestamp(timerange.startts, tz=timezone.utc) + df = df.loc[df[df_date_col] >= start, :] + if timerange.stoptype == 'date': + stop = datetime.fromtimestamp(timerange.stopts, tz=timezone.utc) + df = df.loc[df[df_date_col] <= stop, :] + return df + + def order_book_to_dataframe(bids: list, asks: list) -> DataFrame: """ + TODO: This should get a dedicated test Gets order book list, returns dataframe with below format per suggested by creslin ------------------------------------------------------------------- b_sum b_size bids asks a_size a_sum @@ -116,12 +157,13 @@ def order_book_to_dataframe(bids: list, asks: list) -> DataFrame: return frame -def trades_to_ohlcv(trades: list, timeframe: str) -> list: +def trades_to_ohlcv(trades: list, timeframe: str) -> DataFrame: """ Converts trades list to ohlcv list + TODO: This should get a dedicated test :param trades: List of trades, as returned by ccxt.fetch_trades. :param timeframe: Ticker timeframe to resample data to - :return: ohlcv timeframe as list (as returned by ccxt.fetch_ohlcv) + :return: ohlcv Dataframe. """ from freqtrade.exchange import timeframe_to_minutes ticker_minutes = timeframe_to_minutes(timeframe) @@ -131,8 +173,68 @@ def trades_to_ohlcv(trades: list, timeframe: str) -> list: df_new = df['price'].resample(f'{ticker_minutes}min').ohlc() df_new['volume'] = df['amount'].resample(f'{ticker_minutes}min').sum() - df_new['date'] = df_new.index.astype("int64") // 10 ** 6 + df_new['date'] = df_new.index # Drop 0 volume rows df_new = df_new.dropna() - columns = ["date", "open", "high", "low", "close", "volume"] - return list(zip(*[df_new[x].values.tolist() for x in columns])) + return df_new[DEFAULT_DATAFRAME_COLUMNS] + + +def convert_trades_format(config: Dict[str, Any], convert_from: str, convert_to: str, erase: bool): + """ + Convert trades from one format to another format. + :param config: Config dictionary + :param convert_from: Source format + :param convert_to: Target format + :param erase: Erase souce data (does not apply if source and target format are identical) + """ + from freqtrade.data.history.idatahandler import get_datahandler + src = get_datahandler(config['datadir'], convert_from) + trg = get_datahandler(config['datadir'], convert_to) + + if 'pairs' not in config: + config['pairs'] = src.trades_get_pairs(config['datadir']) + logger.info(f"Converting trades for {config['pairs']}") + + for pair in config['pairs']: + data = src.trades_load(pair=pair) + logger.info(f"Converting {len(data)} trades for {pair}") + trg.trades_store(pair, data) + if erase and convert_from != convert_to: + logger.info(f"Deleting source Trade data for {pair}.") + src.trades_purge(pair=pair) + + +def convert_ohlcv_format(config: Dict[str, Any], convert_from: str, convert_to: str, erase: bool): + """ + Convert ohlcv from one format to another format. + :param config: Config dictionary + :param convert_from: Source format + :param convert_to: Target format + :param erase: Erase souce data (does not apply if source and target format are identical) + """ + from freqtrade.data.history.idatahandler import get_datahandler + src = get_datahandler(config['datadir'], convert_from) + trg = get_datahandler(config['datadir'], convert_to) + timeframes = config.get('timeframes', [config.get('ticker_interval')]) + logger.info(f"Converting OHLCV for timeframe {timeframes}") + + if 'pairs' not in config: + config['pairs'] = [] + # Check timeframes or fall back to ticker_interval. + for timeframe in timeframes: + config['pairs'].extend(src.ohlcv_get_pairs(config['datadir'], + timeframe)) + logger.info(f"Converting OHLCV for {config['pairs']}") + + for timeframe in timeframes: + for pair in config['pairs']: + data = src.ohlcv_load(pair=pair, timeframe=timeframe, + timerange=None, + fill_missing=False, + drop_incomplete=False, + startup_candles=0) + logger.info(f"Converting {len(data)} candles for {pair}") + trg.ohlcv_store(pair=pair, timeframe=timeframe, data=data) + if erase and convert_from != convert_to: + logger.info(f"Deleting source data for {pair} / {timeframe}") + src.ohlcv_purge(pair=pair, timeframe=timeframe) diff --git a/freqtrade/data/history/__init__.py b/freqtrade/data/history/__init__.py new file mode 100644 index 000000000..23f635a98 --- /dev/null +++ b/freqtrade/data/history/__init__.py @@ -0,0 +1,14 @@ +""" +Handle historic data (ohlcv). + +Includes: +* load data for a pair (or a list of pairs) from disk +* download data from exchange and store to disk +""" + +from .history_utils import (convert_trades_to_ohlcv, # noqa: F401 + get_timerange, load_data, load_pair_history, + refresh_backtest_ohlcv_data, + refresh_backtest_trades_data, refresh_data, + validate_backtest_data) +from .idatahandler import get_datahandler # noqa: F401 diff --git a/freqtrade/data/history.py b/freqtrade/data/history/history_utils.py similarity index 55% rename from freqtrade/data/history.py rename to freqtrade/data/history/history_utils.py index 30d168f78..5f9a7da20 100644 --- a/freqtrade/data/history.py +++ b/freqtrade/data/history/history_utils.py @@ -1,138 +1,31 @@ -""" -Handle historic data (ohlcv). - -Includes: -* load data for a pair (or a list of pairs) from disk -* download data from exchange and store to disk -""" - import logging import operator -from copy import deepcopy from datetime import datetime, timezone from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Tuple import arrow from pandas import DataFrame -from freqtrade import misc from freqtrade.configuration import TimeRange +from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS from freqtrade.data.converter import parse_ticker_dataframe, trades_to_ohlcv +from freqtrade.data.history.idatahandler import IDataHandler, get_datahandler from freqtrade.exceptions import OperationalException -from freqtrade.exchange import (Exchange, timeframe_to_minutes, - timeframe_to_seconds) +from freqtrade.exchange import Exchange logger = logging.getLogger(__name__) -def trim_tickerlist(tickerlist: List[Dict], timerange: TimeRange) -> List[Dict]: - """ - Trim tickerlist based on given timerange - """ - if not tickerlist: - return tickerlist - - start_index = 0 - stop_index = len(tickerlist) - - if timerange.starttype == 'date': - while (start_index < len(tickerlist) and - tickerlist[start_index][0] < timerange.startts * 1000): - start_index += 1 - - if timerange.stoptype == 'date': - while (stop_index > 0 and - tickerlist[stop_index-1][0] > timerange.stopts * 1000): - stop_index -= 1 - - if start_index > stop_index: - raise ValueError(f'The timerange [{timerange.startts},{timerange.stopts}] is incorrect') - - return tickerlist[start_index:stop_index] - - -def trim_dataframe(df: DataFrame, timerange: TimeRange, df_date_col: str = 'date') -> DataFrame: - """ - Trim dataframe based on given timerange - :param df: Dataframe to trim - :param timerange: timerange (use start and end date if available) - :param: df_date_col: Column in the dataframe to use as Date column - :return: trimmed dataframe - """ - if timerange.starttype == 'date': - start = datetime.fromtimestamp(timerange.startts, tz=timezone.utc) - df = df.loc[df[df_date_col] >= start, :] - if timerange.stoptype == 'date': - stop = datetime.fromtimestamp(timerange.stopts, tz=timezone.utc) - df = df.loc[df[df_date_col] <= stop, :] - return df - - -def load_tickerdata_file(datadir: Path, pair: str, timeframe: str, - timerange: Optional[TimeRange] = None) -> List[Dict]: - """ - Load a pair from file, either .json.gz or .json - :return: tickerlist or None if unsuccessful - """ - filename = pair_data_filename(datadir, pair, timeframe) - pairdata = misc.file_load_json(filename) - if not pairdata: - return [] - - if timerange: - pairdata = trim_tickerlist(pairdata, timerange) - return pairdata - - -def store_tickerdata_file(datadir: Path, pair: str, - timeframe: str, data: list, is_zip: bool = False): - """ - Stores tickerdata to file - """ - filename = pair_data_filename(datadir, pair, timeframe) - misc.file_dump_json(filename, data, is_zip=is_zip) - - -def load_trades_file(datadir: Path, pair: str, - timerange: Optional[TimeRange] = None) -> List[Dict]: - """ - Load a pair from file, either .json.gz or .json - :return: tradelist or empty list if unsuccesful - """ - filename = pair_trades_filename(datadir, pair) - tradesdata = misc.file_load_json(filename) - if not tradesdata: - return [] - - return tradesdata - - -def store_trades_file(datadir: Path, pair: str, - data: list, is_zip: bool = True): - """ - Stores tickerdata to file - """ - filename = pair_trades_filename(datadir, pair) - misc.file_dump_json(filename, data, is_zip=is_zip) - - -def _validate_pairdata(pair, pairdata, timerange: TimeRange): - if timerange.starttype == 'date' and pairdata[0][0] > timerange.startts * 1000: - logger.warning('Missing data at start for pair %s, data starts at %s', - pair, arrow.get(pairdata[0][0] // 1000).strftime('%Y-%m-%d %H:%M:%S')) - if timerange.stoptype == 'date' and pairdata[-1][0] < timerange.stopts * 1000: - logger.warning('Missing data at end for pair %s, data ends at %s', - pair, arrow.get(pairdata[-1][0] // 1000).strftime('%Y-%m-%d %H:%M:%S')) - - def load_pair_history(pair: str, timeframe: str, - datadir: Path, + datadir: Path, *, timerange: Optional[TimeRange] = None, fill_up_missing: bool = True, drop_incomplete: bool = True, startup_candles: int = 0, + data_format: str = None, + data_handler: IDataHandler = None, ) -> DataFrame: """ Load cached ticker history for the given pair. @@ -140,39 +33,34 @@ def load_pair_history(pair: str, :param pair: Pair to load data for :param timeframe: Ticker timeframe (e.g. "5m") :param datadir: Path to the data storage location. + :param data_format: Format of the data. Ignored if data_handler is set. :param timerange: Limit data to be loaded to this timerange :param fill_up_missing: Fill missing values with "No action"-candles :param drop_incomplete: Drop last candle assuming it may be incomplete. :param startup_candles: Additional candles to load at the start of the period + :param data_handler: Initialized data-handler to use. + Will be initialized from data_format if not set :return: DataFrame with ohlcv data, or empty DataFrame """ - timerange_startup = deepcopy(timerange) - if startup_candles > 0 and timerange_startup: - timerange_startup.subtract_start(timeframe_to_seconds(timeframe) * startup_candles) + data_handler = get_datahandler(datadir, data_format, data_handler) - pairdata = load_tickerdata_file(datadir, pair, timeframe, timerange=timerange_startup) - - if pairdata: - if timerange_startup: - _validate_pairdata(pair, pairdata, timerange_startup) - return parse_ticker_dataframe(pairdata, timeframe, pair=pair, - fill_missing=fill_up_missing, - drop_incomplete=drop_incomplete) - else: - logger.warning( - f'No history data for pair: "{pair}", timeframe: {timeframe}. ' - 'Use `freqtrade download-data` to download the data' - ) - return DataFrame() + return data_handler.ohlcv_load(pair=pair, + timeframe=timeframe, + timerange=timerange, + fill_missing=fill_up_missing, + drop_incomplete=drop_incomplete, + startup_candles=startup_candles, + ) def load_data(datadir: Path, timeframe: str, - pairs: List[str], + pairs: List[str], *, timerange: Optional[TimeRange] = None, fill_up_missing: bool = True, startup_candles: int = 0, - fail_without_data: bool = False + fail_without_data: bool = False, + data_format: str = 'json', ) -> Dict[str, DataFrame]: """ Load ticker history data for a list of pairs. @@ -184,17 +72,22 @@ def load_data(datadir: Path, :param fill_up_missing: Fill missing values with "No action"-candles :param startup_candles: Additional candles to load at the start of the period :param fail_without_data: Raise OperationalException if no data is found. + :param data_format: Data format which should be used. Defaults to json :return: dict(:) """ result: Dict[str, DataFrame] = {} if startup_candles > 0 and timerange: logger.info(f'Using indicator startup period: {startup_candles} ...') + data_handler = get_datahandler(datadir, data_format) + for pair in pairs: hist = load_pair_history(pair=pair, timeframe=timeframe, datadir=datadir, timerange=timerange, fill_up_missing=fill_up_missing, - startup_candles=startup_candles) + startup_candles=startup_candles, + data_handler=data_handler + ) if not hist.empty: result[pair] = hist @@ -207,6 +100,7 @@ def refresh_data(datadir: Path, timeframe: str, pairs: List[str], exchange: Exchange, + data_format: str = None, timerange: Optional[TimeRange] = None, ) -> None: """ @@ -218,70 +112,50 @@ def refresh_data(datadir: Path, :param exchange: Exchange object :param timerange: Limit data to be loaded to this timerange """ + data_handler = get_datahandler(datadir, data_format) for pair in pairs: _download_pair_history(pair=pair, timeframe=timeframe, datadir=datadir, timerange=timerange, - exchange=exchange) + exchange=exchange, data_handler=data_handler) -def pair_data_filename(datadir: Path, pair: str, timeframe: str) -> Path: - pair_s = pair.replace("/", "_") - filename = datadir.joinpath(f'{pair_s}-{timeframe}.json') - return filename - - -def pair_trades_filename(datadir: Path, pair: str) -> Path: - pair_s = pair.replace("/", "_") - filename = datadir.joinpath(f'{pair_s}-trades.json.gz') - return filename - - -def _load_cached_data_for_updating(datadir: Path, pair: str, timeframe: str, - timerange: Optional[TimeRange]) -> Tuple[List[Any], - Optional[int]]: +def _load_cached_data_for_updating(pair: str, timeframe: str, timerange: Optional[TimeRange], + data_handler: IDataHandler) -> Tuple[DataFrame, Optional[int]]: """ Load cached data to download more data. If timerange is passed in, checks whether data from an before the stored data will be downloaded. If that's the case then what's available should be completely overwritten. - Only used by download_pair_history(). + Otherwise downloads always start at the end of the available data to avoid data gaps. + Note: Only used by download_pair_history(). """ - - since_ms = None - - # user sets timerange, so find the start time + start = None if timerange: if timerange.starttype == 'date': - since_ms = timerange.startts * 1000 - elif timerange.stoptype == 'line': - num_minutes = timerange.stopts * timeframe_to_minutes(timeframe) - since_ms = arrow.utcnow().shift(minutes=num_minutes).timestamp * 1000 + # TODO: convert to date for conversion + start = datetime.fromtimestamp(timerange.startts, tz=timezone.utc) - # read the cached file # Intentionally don't pass timerange in - since we need to load the full dataset. - data = load_tickerdata_file(datadir, pair, timeframe) - # remove the last item, could be incomplete candle - if data: - data.pop() - else: - data = [] - - if data: - if since_ms and since_ms < data[0][0]: + data = data_handler.ohlcv_load(pair, timeframe=timeframe, + timerange=None, fill_missing=False, + drop_incomplete=True, warn_no_data=False) + if not data.empty: + if start and start < data.iloc[0]['date']: # Earlier data than existing data requested, redownload all - data = [] + data = DataFrame(columns=DEFAULT_DATAFRAME_COLUMNS) else: - # a part of the data was already downloaded, so download unexist data only - since_ms = data[-1][0] + 1 + start = data.iloc[-1]['date'] - return (data, since_ms) + start_ms = int(start.timestamp() * 1000) if start else None + return data, start_ms def _download_pair_history(datadir: Path, exchange: Exchange, - pair: str, + pair: str, *, timeframe: str = '5m', - timerange: Optional[TimeRange] = None) -> bool: + timerange: Optional[TimeRange] = None, + data_handler: IDataHandler = None) -> bool: """ Download latest candles from the exchange for the pair and timeframe passed in parameters The data is downloaded starting from the last correct data that @@ -295,16 +169,22 @@ def _download_pair_history(datadir: Path, :param timerange: range of time to download :return: bool with success state """ + data_handler = get_datahandler(datadir, data_handler=data_handler) + try: logger.info( f'Download history data for pair: "{pair}", timeframe: {timeframe} ' f'and store in {datadir}.' ) - data, since_ms = _load_cached_data_for_updating(datadir, pair, timeframe, timerange) + # data, since_ms = _load_cached_data_for_updating_old(datadir, pair, timeframe, timerange) + data, since_ms = _load_cached_data_for_updating(pair, timeframe, timerange, + data_handler=data_handler) - logger.debug("Current Start: %s", misc.format_ms_time(data[1][0]) if data else 'None') - logger.debug("Current End: %s", misc.format_ms_time(data[-1][0]) if data else 'None') + logger.debug("Current Start: %s", + f"{data.iloc[0]['date']:%Y-%m-%d %H:%M:%S}" if not data.empty else 'None') + logger.debug("Current End: %s", + f"{data.iloc[-1]['date']:%Y-%m-%d %H:%M:%S}" if not data.empty else 'None') # Default since_ms to 30 days if nothing is given new_data = exchange.get_historic_ohlcv(pair=pair, @@ -313,12 +193,20 @@ def _download_pair_history(datadir: Path, int(arrow.utcnow().shift( days=-30).float_timestamp) * 1000 ) - data.extend(new_data) + # TODO: Maybe move parsing to exchange class (?) + new_dataframe = parse_ticker_dataframe(new_data, timeframe, pair, + fill_missing=False, drop_incomplete=True) + if data.empty: + data = new_dataframe + else: + data = data.append(new_dataframe) - logger.debug("New Start: %s", misc.format_ms_time(data[0][0])) - logger.debug("New End: %s", misc.format_ms_time(data[-1][0])) + logger.debug("New Start: %s", + f"{data.iloc[0]['date']:%Y-%m-%d %H:%M:%S}" if not data.empty else 'None') + logger.debug("New End: %s", + f"{data.iloc[-1]['date']:%Y-%m-%d %H:%M:%S}" if not data.empty else 'None') - store_tickerdata_file(datadir, pair, timeframe, data=data) + data_handler.ohlcv_store(pair, timeframe, data=data) return True except Exception as e: @@ -331,13 +219,14 @@ def _download_pair_history(datadir: Path, def refresh_backtest_ohlcv_data(exchange: Exchange, pairs: List[str], timeframes: List[str], datadir: Path, timerange: Optional[TimeRange] = None, - erase=False) -> List[str]: + erase: bool = False, data_format: str = None) -> List[str]: """ Refresh stored ohlcv data for backtesting and hyperopt operations. Used by freqtrade download-data subcommand. :return: List of pairs that are not available. """ pairs_not_available = [] + data_handler = get_datahandler(datadir, data_format) for pair in pairs: if pair not in exchange.markets: pairs_not_available.append(pair) @@ -345,23 +234,23 @@ def refresh_backtest_ohlcv_data(exchange: Exchange, pairs: List[str], timeframes continue for timeframe in timeframes: - dl_file = pair_data_filename(datadir, pair, timeframe) - if erase and dl_file.exists(): - logger.info( - f'Deleting existing data for pair {pair}, interval {timeframe}.') - dl_file.unlink() + if erase: + if data_handler.ohlcv_purge(pair, timeframe): + logger.info( + f'Deleting existing data for pair {pair}, interval {timeframe}.') logger.info(f'Downloading pair {pair}, interval {timeframe}.') _download_pair_history(datadir=datadir, exchange=exchange, pair=pair, timeframe=str(timeframe), - timerange=timerange) + timerange=timerange, data_handler=data_handler) return pairs_not_available -def _download_trades_history(datadir: Path, - exchange: Exchange, - pair: str, - timerange: Optional[TimeRange] = None) -> bool: +def _download_trades_history(exchange: Exchange, + pair: str, *, + timerange: Optional[TimeRange] = None, + data_handler: IDataHandler + ) -> bool: """ Download trade history from the exchange. Appends to previously downloaded trades data. @@ -370,7 +259,7 @@ def _download_trades_history(datadir: Path, since = timerange.startts * 1000 if timerange and timerange.starttype == 'date' else None - trades = load_trades_file(datadir, pair) + trades = data_handler.trades_load(pair) from_id = trades[-1]['id'] if trades else None @@ -385,7 +274,7 @@ def _download_trades_history(datadir: Path, from_id=from_id, ) trades.extend(new_trades[1]) - store_trades_file(datadir, pair, trades) + data_handler.trades_store(pair, data=trades) logger.debug("New Start: %s", trades[0]['datetime']) logger.debug("New End: %s", trades[-1]['datetime']) @@ -401,47 +290,52 @@ def _download_trades_history(datadir: Path, def refresh_backtest_trades_data(exchange: Exchange, pairs: List[str], datadir: Path, - timerange: TimeRange, erase=False) -> List[str]: + timerange: TimeRange, erase: bool = False, + data_format: str = 'jsongz') -> List[str]: """ Refresh stored trades data for backtesting and hyperopt operations. Used by freqtrade download-data subcommand. :return: List of pairs that are not available. """ pairs_not_available = [] + data_handler = get_datahandler(datadir, data_format=data_format) for pair in pairs: if pair not in exchange.markets: pairs_not_available.append(pair) logger.info(f"Skipping pair {pair}...") continue - dl_file = pair_trades_filename(datadir, pair) - if erase and dl_file.exists(): - logger.info( - f'Deleting existing data for pair {pair}.') - dl_file.unlink() + if erase: + if data_handler.trades_purge(pair): + logger.info(f'Deleting existing data for pair {pair}.') logger.info(f'Downloading trades for pair {pair}.') - _download_trades_history(datadir=datadir, exchange=exchange, + _download_trades_history(exchange=exchange, pair=pair, - timerange=timerange) + timerange=timerange, + data_handler=data_handler) return pairs_not_available def convert_trades_to_ohlcv(pairs: List[str], timeframes: List[str], - datadir: Path, timerange: TimeRange, erase=False) -> None: + datadir: Path, timerange: TimeRange, erase: bool = False, + data_format_ohlcv: str = 'json', + data_format_trades: str = 'jsongz') -> None: """ Convert stored trades data to ohlcv data """ + data_handler_trades = get_datahandler(datadir, data_format=data_format_trades) + data_handler_ohlcv = get_datahandler(datadir, data_format=data_format_ohlcv) + for pair in pairs: - trades = load_trades_file(datadir, pair) + trades = data_handler_trades.trades_load(pair) for timeframe in timeframes: - ohlcv_file = pair_data_filename(datadir, pair, timeframe) - if erase and ohlcv_file.exists(): - logger.info(f'Deleting existing data for pair {pair}, interval {timeframe}.') - ohlcv_file.unlink() + if erase: + if data_handler_ohlcv.ohlcv_purge(pair, timeframe): + logger.info(f'Deleting existing data for pair {pair}, interval {timeframe}.') ohlcv = trades_to_ohlcv(trades, timeframe) # Store ohlcv - store_tickerdata_file(datadir, pair, timeframe, data=ohlcv) + data_handler_ohlcv.ohlcv_store(pair, timeframe, data=ohlcv) def get_timerange(data: Dict[str, DataFrame]) -> Tuple[arrow.Arrow, arrow.Arrow]: diff --git a/freqtrade/data/history/idatahandler.py b/freqtrade/data/history/idatahandler.py new file mode 100644 index 000000000..df03e7713 --- /dev/null +++ b/freqtrade/data/history/idatahandler.py @@ -0,0 +1,220 @@ +""" +Abstract datahandler interface. +It's subclasses handle and storing data from disk. + +""" +import logging +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 pandas import DataFrame + +from freqtrade.configuration import TimeRange +from freqtrade.data.converter import clean_ohlcv_dataframe, trim_dataframe +from freqtrade.exchange import timeframe_to_seconds + +logger = logging.getLogger(__name__) + + +class IDataHandler(ABC): + + def __init__(self, datadir: Path) -> None: + self._datadir = datadir + + @abstractclassmethod + def ohlcv_get_pairs(cls, datadir: Path, timeframe: str) -> List[str]: + """ + Returns a list of all pairs with ohlcv data available in this datadir + for the specified timeframe + :param datadir: Directory to search for ohlcv files + :param timeframe: Timeframe to search pairs for + :return: List of Pairs + """ + + @abstractmethod + def ohlcv_store(self, pair: str, timeframe: str, data: DataFrame) -> None: + """ + Store data in json format "values". + format looks as follows: + [[,,,,]] + :param pair: Pair - used to generate filename + :timeframe: Timeframe - used to generate filename + :data: Dataframe containing OHLCV data + :return: None + """ + + @abstractmethod + def _ohlcv_load(self, pair: str, timeframe: str, + timerange: Optional[TimeRange] = None, + ) -> DataFrame: + """ + Internal method used to load data for one pair from disk. + Implements the loading and conversion to a Pandas dataframe. + Timerange trimming and dataframe validation happens outside of this method. + :param pair: Pair to load data + :param timeframe: Ticker timeframe (e.g. "5m") + :param timerange: Limit data to be loaded to this timerange. + Optionally implemented by subclasses to avoid loading + all data where possible. + :return: DataFrame with ohlcv data, or empty DataFrame + """ + + @abstractmethod + def ohlcv_purge(self, pair: str, timeframe: str) -> bool: + """ + Remove data for this pair + :param pair: Delete data for this pair. + :param timeframe: Ticker timeframe (e.g. "5m") + :return: True when deleted, false if file did not exist. + """ + + @abstractmethod + def ohlcv_append(self, pair: str, timeframe: str, data: DataFrame) -> None: + """ + Append data to existing data structures + :param pair: Pair + :param timeframe: Timeframe this ohlcv data is for + :param data: Data to append. + """ + + @abstractclassmethod + def trades_get_pairs(cls, datadir: Path) -> List[str]: + """ + Returns a list of all pairs for which trade data is available in this + :param datadir: Directory to search for ohlcv files + :return: List of Pairs + """ + + @abstractmethod + def trades_store(self, pair: str, data: List[Dict]) -> None: + """ + Store trades data (list of Dicts) to file + :param pair: Pair - used for filename + :param data: List of Dicts containing trade data + """ + + @abstractmethod + def trades_append(self, pair: str, data: List[Dict]): + """ + Append data to existing files + :param pair: Pair - used for filename + :param data: List of Dicts containing trade data + """ + + @abstractmethod + def trades_load(self, pair: str, timerange: Optional[TimeRange] = None) -> List[Dict]: + """ + Load a pair from file, either .json.gz or .json + :param pair: Load trades for this pair + :param timerange: Timerange to load trades for - currently not implemented + :return: List of trades + """ + + @abstractmethod + def trades_purge(self, pair: str) -> bool: + """ + Remove data for this pair + :param pair: Delete data for this pair. + :return: True when deleted, false if file did not exist. + """ + + def ohlcv_load(self, pair, timeframe: str, + timerange: Optional[TimeRange] = None, + fill_missing: bool = True, + drop_incomplete: bool = True, + startup_candles: int = 0, + warn_no_data: bool = True + ) -> DataFrame: + """ + Load cached ticker history for the given pair. + + :param pair: Pair to load data for + :param timeframe: Ticker timeframe (e.g. "5m") + :param timerange: Limit data to be loaded to this timerange + :param fill_missing: Fill missing values with "No action"-candles + :param drop_incomplete: Drop last candle assuming it may be incomplete. + :param startup_candles: Additional candles to load at the start of the period + :param warn_no_data: Log a warning message when no data is found + :return: DataFrame with ohlcv data, or empty DataFrame + """ + # Fix startup period + timerange_startup = deepcopy(timerange) + if startup_candles > 0 and timerange_startup: + timerange_startup.subtract_start(timeframe_to_seconds(timeframe) * startup_candles) + + pairdf = self._ohlcv_load(pair, timeframe, + timerange=timerange_startup) + if pairdf.empty: + if warn_no_data: + logger.warning( + f'No history data for pair: "{pair}", timeframe: {timeframe}. ' + 'Use `freqtrade download-data` to download the data' + ) + return pairdf + else: + enddate = pairdf.iloc[-1]['date'] + + if timerange_startup: + self._validate_pairdata(pair, pairdf, timerange_startup) + pairdf = trim_dataframe(pairdf, timerange_startup) + + # incomplete candles should only be dropped if we didn't trim the end beforehand. + return clean_ohlcv_dataframe(pairdf, timeframe, + pair=pair, + fill_missing=fill_missing, + drop_incomplete=(drop_incomplete and + enddate == pairdf.iloc[-1]['date'])) + + def _validate_pairdata(self, pair, pairdata: DataFrame, timerange: TimeRange): + """ + Validates pairdata for missing data at start end end and logs warnings. + :param pairdata: Dataframe to validate + :param timerange: Timerange specified for start and end dates + """ + + if timerange.starttype == 'date': + start = datetime.fromtimestamp(timerange.startts, tz=timezone.utc) + if pairdata.iloc[0]['date'] > start: + logger.warning(f"Missing data at start for pair {pair}, " + f"data starts at {pairdata.iloc[0]['date']:%Y-%m-%d %H:%M:%S}") + if timerange.stoptype == 'date': + stop = datetime.fromtimestamp(timerange.stopts, tz=timezone.utc) + if pairdata.iloc[-1]['date'] < stop: + logger.warning(f"Missing data at end for pair {pair}, " + f"data ends at {pairdata.iloc[-1]['date']:%Y-%m-%d %H:%M:%S}") + + +def get_datahandlerclass(datatype: str) -> Type[IDataHandler]: + """ + Get datahandler class. + Could be done using Resolvers, but since this may be called often and resolvers + are rather expensive, doing this directly should improve performance. + :param datatype: datatype to use. + :return: Datahandler class + """ + + if datatype == 'json': + from .jsondatahandler import JsonDataHandler + return JsonDataHandler + elif datatype == 'jsongz': + from .jsondatahandler import JsonGzDataHandler + return JsonGzDataHandler + else: + raise ValueError(f"No datahandler for datatype {datatype} available.") + + +def get_datahandler(datadir: Path, data_format: str = None, + data_handler: IDataHandler = None) -> IDataHandler: + """ + :param datadir: Folder to save data + :data_format: dataformat to use + :data_handler: returns this datahandler if it exists or initializes a new one + """ + + if not data_handler: + HandlerClass = get_datahandlerclass(data_format or 'json') + data_handler = HandlerClass(datadir) + return data_handler diff --git a/freqtrade/data/history/jsondatahandler.py b/freqtrade/data/history/jsondatahandler.py new file mode 100644 index 000000000..2b738a94a --- /dev/null +++ b/freqtrade/data/history/jsondatahandler.py @@ -0,0 +1,179 @@ +import re +from pathlib import Path +from typing import Dict, List, Optional + +import numpy as np +from pandas import DataFrame, read_json, to_datetime + +from freqtrade import misc +from freqtrade.configuration import TimeRange +from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS + +from .idatahandler import IDataHandler + + +class JsonDataHandler(IDataHandler): + + _use_zip = False + _columns = DEFAULT_DATAFRAME_COLUMNS + + @classmethod + def ohlcv_get_pairs(cls, datadir: Path, timeframe: str) -> List[str]: + """ + Returns a list of all pairs with ohlcv data available in this datadir + for the specified timeframe + :param datadir: Directory to search for ohlcv files + :param timeframe: Timeframe to search pairs for + :return: List of Pairs + """ + + _tmp = [re.search(r'^(\S+)(?=\-' + timeframe + '.json)', p.name) + for p in datadir.glob(f"*{timeframe}.{cls._get_file_extension()}")] + # Check if regex found something and only return these results + return [match[0].replace('_', '/') for match in _tmp if match] + + def ohlcv_store(self, pair: str, timeframe: str, data: DataFrame) -> None: + """ + Store data in json format "values". + format looks as follows: + [[,,,,]] + :param pair: Pair - used to generate filename + :timeframe: Timeframe - used to generate filename + :data: Dataframe containing OHLCV data + :return: None + """ + filename = self._pair_data_filename(self._datadir, pair, timeframe) + _data = data.copy() + # Convert date to int + _data['date'] = _data['date'].astype(np.int64) // 1000 // 1000 + + # Reset index, select only appropriate columns and save as json + _data.reset_index(drop=True).loc[:, self._columns].to_json( + filename, orient="values", + compression='gzip' if self._use_zip else None) + + def _ohlcv_load(self, pair: str, timeframe: str, + timerange: Optional[TimeRange] = None, + ) -> DataFrame: + """ + Internal method used to load data for one pair from disk. + Implements the loading and conversion to a Pandas dataframe. + Timerange trimming and dataframe validation happens outside of this method. + :param pair: Pair to load data + :param timeframe: Ticker timeframe (e.g. "5m") + :param timerange: Limit data to be loaded to this timerange. + Optionally implemented by subclasses to avoid loading + all data where possible. + :return: DataFrame with ohlcv data, or empty DataFrame + """ + filename = self._pair_data_filename(self._datadir, pair, timeframe) + if not filename.exists(): + return DataFrame(columns=self._columns) + pairdata = read_json(filename, orient='values') + pairdata.columns = self._columns + pairdata = pairdata.astype(dtype={'open': 'float', 'high': 'float', + 'low': 'float', 'close': 'float', 'volume': 'float'}) + pairdata['date'] = to_datetime(pairdata['date'], + unit='ms', + utc=True, + infer_datetime_format=True) + return pairdata + + def ohlcv_purge(self, pair: str, timeframe: str) -> bool: + """ + Remove data for this pair + :param pair: Delete data for this pair. + :param timeframe: Ticker timeframe (e.g. "5m") + :return: True when deleted, false if file did not exist. + """ + filename = self._pair_data_filename(self._datadir, pair, timeframe) + if filename.exists(): + filename.unlink() + return True + return False + + def ohlcv_append(self, pair: str, timeframe: str, data: DataFrame) -> None: + """ + Append data to existing data structures + :param pair: Pair + :param timeframe: Timeframe this ohlcv data is for + :param data: Data to append. + """ + raise NotImplementedError() + + @classmethod + def trades_get_pairs(cls, datadir: Path) -> List[str]: + """ + Returns a list of all pairs for which trade data is available in this + :param datadir: Directory to search for ohlcv files + :return: List of Pairs + """ + _tmp = [re.search(r'^(\S+)(?=\-trades.json)', p.name) + for p in datadir.glob(f"*trades.{cls._get_file_extension()}")] + # 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: + """ + Store trades data (list of Dicts) to file + :param pair: Pair - used for filename + :param data: List of Dicts containing trade data + """ + 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]): + """ + Append data to existing files + :param pair: Pair - used for filename + :param data: List of Dicts containing trade data + """ + raise NotImplementedError() + + def trades_load(self, pair: str, timerange: Optional[TimeRange] = None) -> List[Dict]: + """ + Load a pair from file, either .json.gz or .json + # TODO: respect timerange ... + :param pair: Load trades for this pair + :param timerange: Timerange to load trades for - currently not implemented + :return: List of trades + """ + filename = self._pair_trades_filename(self._datadir, pair) + tradesdata = misc.file_load_json(filename) + if not tradesdata: + return [] + + return tradesdata + + def trades_purge(self, pair: str) -> bool: + """ + Remove data for this pair + :param pair: Delete data for this pair. + :return: True when deleted, false if file did not exist. + """ + filename = self._pair_trades_filename(self._datadir, pair) + if filename.exists(): + filename.unlink() + return True + return False + + @classmethod + def _pair_data_filename(cls, datadir: Path, pair: str, timeframe: str) -> Path: + pair_s = misc.pair_to_filename(pair) + filename = datadir.joinpath(f'{pair_s}-{timeframe}.{cls._get_file_extension()}') + return filename + + @classmethod + def _get_file_extension(cls): + return "json.gz" if cls._use_zip else "json" + + @classmethod + def _pair_trades_filename(cls, datadir: Path, pair: str) -> Path: + pair_s = misc.pair_to_filename(pair) + filename = datadir.joinpath(f'{pair_s}-trades.{cls._get_file_extension()}') + return filename + + +class JsonGzDataHandler(JsonDataHandler): + + _use_zip = True diff --git a/freqtrade/edge/edge_positioning.py b/freqtrade/edge/edge_positioning.py index 15883357b..ee5c3e95d 100644 --- a/freqtrade/edge/edge_positioning.py +++ b/freqtrade/edge/edge_positioning.py @@ -1,7 +1,7 @@ # pragma pylint: disable=W0603 """ Edge positioning package """ import logging -from typing import Any, Dict, NamedTuple +from typing import Any, Dict, List, NamedTuple import arrow import numpy as np @@ -110,6 +110,7 @@ class Edge: timeframe=self.strategy.ticker_interval, timerange=self._timerange, startup_candles=self.strategy.startup_candle_count, + data_format=self.config.get('dataformat_ohlcv', 'json'), ) if not data: @@ -181,7 +182,7 @@ class Edge: 'strategy stoploss is returned instead.') return self.strategy.stoploss - def adjust(self, pairs) -> list: + def adjust(self, pairs: List[str]) -> list: """ Filters out and sorts "pairs" according to Edge calculated pairs """ diff --git a/freqtrade/exchange/__init__.py b/freqtrade/exchange/__init__.py index df18bca02..a39f8f5df 100644 --- a/freqtrade/exchange/__init__.py +++ b/freqtrade/exchange/__init__.py @@ -1,18 +1,20 @@ -from freqtrade.exchange.common import MAP_EXCHANGE_CHILDCLASS # noqa: F401 -from freqtrade.exchange.exchange import Exchange # noqa: F401 -from freqtrade.exchange.exchange import (get_exchange_bad_reason, # noqa: F401 +# flake8: noqa: F401 +from freqtrade.exchange.common import MAP_EXCHANGE_CHILDCLASS +from freqtrade.exchange.exchange import Exchange +from freqtrade.exchange.exchange import (get_exchange_bad_reason, is_exchange_bad, is_exchange_known_ccxt, is_exchange_officially_supported, ccxt_exchanges, available_exchanges) -from freqtrade.exchange.exchange import (timeframe_to_seconds, # noqa: F401 +from freqtrade.exchange.exchange import (timeframe_to_seconds, timeframe_to_minutes, timeframe_to_msecs, timeframe_to_next_date, timeframe_to_prev_date) -from freqtrade.exchange.exchange import (market_is_active, # noqa: F401 +from freqtrade.exchange.exchange import (market_is_active, symbol_is_pair) -from freqtrade.exchange.kraken import Kraken # noqa: F401 -from freqtrade.exchange.binance import Binance # noqa: F401 -from freqtrade.exchange.bibox import Bibox # noqa: F401 +from freqtrade.exchange.kraken import Kraken +from freqtrade.exchange.binance import Binance +from freqtrade.exchange.bibox import Bibox +from freqtrade.exchange.ftx import Ftx diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index 12326f083..875628af9 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -32,13 +32,23 @@ class Binance(Exchange): return super().get_order_book(pair, limit) - def stoploss_limit(self, pair: str, amount: float, stop_price: float, rate: float) -> Dict: + def stoploss_adjust(self, stop_loss: float, order: Dict) -> bool: + """ + Verify stop_loss against stoploss-order value (limit or price) + Returns True if adjustment is necessary. + """ + return order['type'] == 'stop_loss_limit' and stop_loss > float(order['info']['stopPrice']) + + def stoploss(self, pair: str, amount: float, stop_price: float, order_types: Dict) -> Dict: """ creates a stoploss limit order. this stoploss-limit is binance-specific. It may work with a limited number of other exchanges, but this has not been tested yet. - """ + # Limit price threshold: As limit price should always be below stop-price + limit_price_pct = order_types.get('stoploss_on_exchange_limit_ratio', 0.99) + rate = stop_price * limit_price_pct + ordertype = "stop_loss_limit" stop_price = self.price_to_precision(pair, stop_price) @@ -61,8 +71,8 @@ class Binance(Exchange): rate = self.price_to_precision(pair, rate) - order = self._api.create_order(pair, ordertype, 'sell', - amount, rate, params) + order = self._api.create_order(symbol=pair, type=ordertype, side='sell', + amount=amount, price=stop_price, 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/exchange.py b/freqtrade/exchange/exchange.py index 87c189457..e45238b07 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -24,6 +24,10 @@ from freqtrade.exceptions import (DependencyException, InvalidOrderException, from freqtrade.exchange.common import BAD_EXCHANGES, retrier, retrier_async from freqtrade.misc import deep_merge_dicts + +CcxtModuleType = Any + + logger = logging.getLogger(__name__) @@ -51,7 +55,7 @@ class Exchange: } _ft_has: Dict = {} - def __init__(self, config: dict, validate: bool = True) -> None: + def __init__(self, config: Dict[str, Any], validate: bool = True) -> None: """ Initializes this module with the given config, it does basic validation whether the specified exchange and pairs are valid. @@ -62,8 +66,6 @@ class Exchange: self._config.update(config) - self._cached_ticker: Dict[str, Any] = {} - # Holds last candle refreshed time of each pair self._pairs_last_refresh_time: Dict[Tuple[str, str], int] = {} # Timestamp of last markets refresh @@ -135,7 +137,7 @@ class Exchange: if self._api_async and inspect.iscoroutinefunction(self._api_async.close): asyncio.get_event_loop().run_until_complete(self._api_async.close()) - def _init_ccxt(self, exchange_config: dict, ccxt_module=ccxt, + def _init_ccxt(self, exchange_config: Dict[str, Any], ccxt_module: CcxtModuleType = ccxt, ccxt_kwargs: dict = None) -> ccxt.Exchange: """ Initialize ccxt with given config and return valid @@ -224,13 +226,13 @@ class Exchange: markets = self.markets return sorted(set([x['quote'] for _, x in markets.items()])) - def klines(self, pair_interval: Tuple[str, str], copy=True) -> DataFrame: + def klines(self, pair_interval: Tuple[str, str], copy: bool = True) -> DataFrame: if pair_interval in self._klines: return self._klines[pair_interval].copy() if copy else self._klines[pair_interval] else: return DataFrame() - def set_sandbox(self, api, exchange_config: dict, name: str): + def set_sandbox(self, api: ccxt.Exchange, exchange_config: dict, name: str) -> None: if exchange_config.get('sandbox'): if api.urls.get('test'): api.urls['api'] = api.urls['test'] @@ -240,7 +242,7 @@ class Exchange: "Please check your config.json") raise OperationalException(f'Exchange {name} does not provide a sandbox api') - def _load_async_markets(self, reload=False) -> None: + def _load_async_markets(self, reload: bool = False) -> None: try: if self._api_async: asyncio.get_event_loop().run_until_complete( @@ -273,7 +275,7 @@ class Exchange: except ccxt.BaseError: logger.exception("Could not reload markets.") - def validate_stakecurrency(self, stake_currency) -> None: + def validate_stakecurrency(self, stake_currency: str) -> None: """ Checks stake-currency against available currencies on the exchange. :param stake_currency: Stake-currency to validate @@ -282,8 +284,8 @@ class Exchange: quote_currencies = self.get_quote_currencies() if stake_currency not in quote_currencies: raise OperationalException( - f"{stake_currency} is not available as stake on {self.name}. " - f"Available currencies are: {', '.join(quote_currencies)}") + f"{stake_currency} is not available as stake on {self.name}. " + f"Available currencies are: {', '.join(quote_currencies)}") def validate_pairs(self, pairs: List[str]) -> None: """ @@ -319,7 +321,7 @@ class Exchange: f"Please check if you are impacted by this restriction " f"on the exchange and eventually remove {pair} from your whitelist.") - def get_valid_pair_combination(self, curr_1, curr_2) -> str: + def get_valid_pair_combination(self, curr_1: str, curr_2: str) -> str: """ Get valid pair combination of curr_1 and curr_2 by trying both combinations. """ @@ -373,7 +375,7 @@ class Exchange: raise OperationalException( f'Time in force policies are not supported for {self.name} yet.') - def validate_required_startup_candles(self, startup_candles) -> None: + def validate_required_startup_candles(self, startup_candles: int) -> None: """ Checks if required startup_candles is more than ohlcv_candle_limit. Requires a grace-period of 5 candles - so a startup-period up to 494 is allowed by default. @@ -392,7 +394,7 @@ class Exchange: """ return endpoint in self._api.has and self._api.has[endpoint] - def amount_to_precision(self, pair, amount: float) -> float: + def amount_to_precision(self, pair: str, amount: float) -> float: ''' Returns the amount to buy or sell to a precision the Exchange accepts Reimplementation of ccxt internal methods - ensuring we can test the result is correct @@ -406,7 +408,7 @@ class Exchange: return amount - def price_to_precision(self, pair, price: float) -> float: + def price_to_precision(self, pair: str, price: float) -> float: ''' Returns the price rounded up to the precision the Exchange accepts. Partial Reimplementation of ccxt internal method decimal_to_precision(), @@ -460,7 +462,7 @@ class Exchange: "status": "closed", "filled": closed_order["amount"], "remaining": 0 - }) + }) if closed_order["type"] in ["stop_loss_limit"]: closed_order["info"].update({"stopPrice": closed_order["price"]}) self._dry_run_open_orders[closed_order["id"]] = closed_order @@ -494,7 +496,7 @@ class Exchange: raise OperationalException(e) from e def buy(self, pair: str, ordertype: str, amount: float, - rate: float, time_in_force) -> Dict: + rate: float, time_in_force: str) -> Dict: if self._config['dry_run']: dry_order = self.dry_run_order(pair, ordertype, "buy", amount, rate) @@ -507,7 +509,7 @@ class Exchange: return self.create_order(pair, ordertype, 'buy', amount, rate, params) def sell(self, pair: str, ordertype: str, amount: float, - rate: float, time_in_force='gtc') -> Dict: + rate: float, time_in_force: str = 'gtc') -> Dict: if self._config['dry_run']: dry_order = self.dry_run_order(pair, ordertype, "sell", amount, rate) @@ -519,9 +521,17 @@ class Exchange: return self.create_order(pair, ordertype, 'sell', amount, rate, params) - def stoploss_limit(self, pair: str, amount: float, stop_price: float, rate: float) -> Dict: + def stoploss_adjust(self, stop_loss: float, order: Dict) -> bool: """ - creates a stoploss limit order. + Verify stop_loss against stoploss-order value (limit or price) + Returns True if adjustment is necessary. + """ + raise OperationalException(f"stoploss is not implemented for {self.name}.") + + def stoploss(self, pair: str, amount: float, stop_price: float, order_types: Dict) -> Dict: + """ + creates a stoploss order. + The precise ordertype is determined by the order_types dict or exchange default. Since ccxt does not unify stoploss-limit orders yet, this needs to be implemented in each exchange's subclass. The exception below should never raise, since we disallow @@ -529,7 +539,7 @@ class Exchange: Note: Changes to this interface need to be applied to all sub-classes too. """ - raise OperationalException(f"stoploss_limit is not implemented for {self.name}.") + raise OperationalException(f"stoploss is not implemented for {self.name}.") @retrier def get_balance(self, currency: str) -> float: @@ -579,28 +589,17 @@ class Exchange: raise OperationalException(e) from e @retrier - def fetch_ticker(self, pair: str, refresh: Optional[bool] = True) -> dict: - if refresh or pair not in self._cached_ticker.keys(): - try: - if pair not in self._api.markets or not self._api.markets[pair].get('active'): - raise DependencyException(f"Pair {pair} not available") - data = self._api.fetch_ticker(pair) - try: - self._cached_ticker[pair] = { - 'bid': float(data['bid']), - 'ask': float(data['ask']), - } - except KeyError: - logger.debug("Could not cache ticker data for %s", pair) - return data - except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError( - f'Could not load ticker due to {e.__class__.__name__}. Message: {e}') from e - except ccxt.BaseError as e: - raise OperationalException(e) from e - else: - logger.info("returning cached ticker-data for %s", pair) - return self._cached_ticker[pair] + def fetch_ticker(self, pair: str) -> dict: + try: + if pair not in self._api.markets or not self._api.markets[pair].get('active'): + raise DependencyException(f"Pair {pair} not available") + data = self._api.fetch_ticker(pair) + return data + except (ccxt.NetworkError, ccxt.ExchangeError) as e: + raise TemporaryError( + f'Could not load ticker due to {e.__class__.__name__}. Message: {e}') from e + except ccxt.BaseError as e: + raise OperationalException(e) from e def get_historic_ohlcv(self, pair: str, timeframe: str, since_ms: int) -> List: @@ -728,10 +727,11 @@ class Exchange: f'Exchange {self._api.name} does not support fetching historical candlestick data.' f'Message: {e}') from e except (ccxt.NetworkError, ccxt.ExchangeError) as e: - raise TemporaryError(f'Could not load ticker history due to {e.__class__.__name__}. ' - f'Message: {e}') from e + raise TemporaryError(f'Could not load ticker history for pair {pair} due to ' + f'{e.__class__.__name__}. Message: {e}') from e except ccxt.BaseError as e: - raise OperationalException(f'Could not fetch ticker data. Msg: {e}') from e + raise OperationalException(f'Could not fetch ticker data for pair {pair}. ' + f'Msg: {e}') from e @retrier_async async def _async_fetch_trades(self, pair: str, @@ -976,8 +976,8 @@ class Exchange: raise OperationalException(e) from e @retrier - def get_fee(self, symbol, type='', side='', amount=1, - price=1, taker_or_maker='maker') -> float: + def get_fee(self, symbol: str, type: str = '', side: str = '', amount: float = 1, + price: float = 1, taker_or_maker: str = 'maker') -> float: try: # validate that markets are loaded before trying to get fee if self._api.markets is None or len(self._api.markets) == 0: @@ -1000,7 +1000,7 @@ def get_exchange_bad_reason(exchange_name: str) -> str: return BAD_EXCHANGES.get(exchange_name, "") -def is_exchange_known_ccxt(exchange_name: str, ccxt_module=None) -> bool: +def is_exchange_known_ccxt(exchange_name: str, ccxt_module: CcxtModuleType = None) -> bool: return exchange_name in ccxt_exchanges(ccxt_module) @@ -1008,14 +1008,14 @@ def is_exchange_officially_supported(exchange_name: str) -> bool: return exchange_name in ['bittrex', 'binance'] -def ccxt_exchanges(ccxt_module=None) -> List[str]: +def ccxt_exchanges(ccxt_module: CcxtModuleType = None) -> List[str]: """ Return the list of all exchanges known to ccxt """ return ccxt_module.exchanges if ccxt_module is not None else ccxt.exchanges -def available_exchanges(ccxt_module=None) -> List[str]: +def available_exchanges(ccxt_module: CcxtModuleType = None) -> List[str]: """ Return exchanges available to the bot, i.e. non-bad exchanges in the ccxt list """ @@ -1075,7 +1075,8 @@ def timeframe_to_next_date(timeframe: str, date: datetime = None) -> datetime: return datetime.fromtimestamp(new_timestamp, tz=timezone.utc) -def symbol_is_pair(market_symbol: str, base_currency: str = None, quote_currency: str = None): +def symbol_is_pair(market_symbol: str, base_currency: str = None, + quote_currency: str = None) -> bool: """ Check if the market symbol is a pair, i.e. that its symbol consists of the base currency and the quote currency separated by '/' character. If base_currency and/or quote_currency is passed, @@ -1088,7 +1089,7 @@ def symbol_is_pair(market_symbol: str, base_currency: str = None, quote_currency (symbol_parts[1] == quote_currency if quote_currency else len(symbol_parts[1]) > 0)) -def market_is_active(market): +def market_is_active(market: Dict) -> bool: """ Return True if the market is active. """ diff --git a/freqtrade/exchange/ftx.py b/freqtrade/exchange/ftx.py new file mode 100644 index 000000000..75915122b --- /dev/null +++ b/freqtrade/exchange/ftx.py @@ -0,0 +1,14 @@ +""" FTX exchange subclass """ +import logging +from typing import Dict + +from freqtrade.exchange import Exchange + +logger = logging.getLogger(__name__) + + +class Ftx(Exchange): + + _ft_has: Dict = { + "ohlcv_candle_limit": 1500, + } diff --git a/freqtrade/exchange/kraken.py b/freqtrade/exchange/kraken.py index 9bcd9cc1f..243f1a6d6 100644 --- a/freqtrade/exchange/kraken.py +++ b/freqtrade/exchange/kraken.py @@ -4,7 +4,8 @@ from typing import Dict import ccxt -from freqtrade.exceptions import OperationalException, TemporaryError +from freqtrade.exceptions import (DependencyException, InvalidOrderException, + OperationalException, TemporaryError) from freqtrade.exchange import Exchange from freqtrade.exchange.exchange import retrier @@ -15,6 +16,7 @@ class Kraken(Exchange): _params: Dict = {"trading_agreement": "agree"} _ft_has: Dict = { + "stoploss_on_exchange": True, "trades_pagination": "id", "trades_pagination_arg": "since", } @@ -48,3 +50,51 @@ class Kraken(Exchange): f'Could not get balance due to {e.__class__.__name__}. Message: {e}') from e except ccxt.BaseError as e: raise OperationalException(e) from e + + def stoploss_adjust(self, stop_loss: float, order: Dict) -> bool: + """ + Verify stop_loss against stoploss-order value (limit or price) + Returns True if adjustment is necessary. + """ + return order['type'] == 'stop-loss' and stop_loss > float(order['price']) + + def stoploss(self, pair: str, amount: float, stop_price: float, order_types: Dict) -> Dict: + """ + Creates a stoploss market order. + Stoploss market orders is the only stoploss type supported by kraken. + """ + + ordertype = "stop-loss" + + stop_price = self.price_to_precision(pair, stop_price) + + if self._config['dry_run']: + dry_order = self.dry_run_order( + pair, ordertype, "sell", amount, stop_price) + return dry_order + + try: + params = self._params.copy() + + amount = self.amount_to_precision(pair, amount) + + order = self._api.create_order(symbol=pair, type=ordertype, side='sell', + amount=amount, price=stop_price, params=params) + logger.info('stoploss order added for %s. ' + 'stop price: %s.', pair, stop_price) + return order + except ccxt.InsufficientFunds as e: + raise DependencyException( + f'Insufficient funds to create {ordertype} sell order on market {pair}.' + f'Tried to create stoploss with amount {amount} at stoploss {stop_price}. ' + f'Message: {e}') from e + except ccxt.InvalidOrder as e: + raise InvalidOrderException( + f'Could not create {ordertype} sell order on market {pair}. ' + f'Tried to create stoploss with amount {amount} at stoploss {stop_price}. ' + f'Message: {e}') from e + except (ccxt.NetworkError, ccxt.ExchangeError) as e: + raise TemporaryError( + f'Could not place sell order due to {e.__class__.__name__}. Message: {e}') from e + except ccxt.BaseError as e: + raise OperationalException(e) from e diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 6c519280d..29e290165 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -6,11 +6,11 @@ import logging import traceback from datetime import datetime from math import isclose -from os import getpid from threading import Lock from typing import Any, Dict, List, Optional, Tuple import arrow +from cachetools import TTLCache from requests.exceptions import RequestException from freqtrade import __version__, constants, persistence @@ -52,9 +52,8 @@ class FreqtradeBot: # Init objects self.config = config - self._heartbeat_msg = 0 - - self.heartbeat_interval = self.config.get('internals', {}).get('heartbeat_interval', 60) + self._sell_rate_cache = TTLCache(maxsize=100, ttl=5) + self._buy_rate_cache = TTLCache(maxsize=100, ttl=5) self.strategy: IStrategy = StrategyResolver.load_strategy(self.config) @@ -159,11 +158,6 @@ class FreqtradeBot: self.check_handle_timedout() Trade.session.flush() - if (self.heartbeat_interval - and (arrow.utcnow().timestamp - self._heartbeat_msg > self.heartbeat_interval)): - logger.info(f"Bot heartbeat. PID={getpid()}") - self._heartbeat_msg = arrow.utcnow().timestamp - def _refresh_whitelist(self, trades: List[Trade] = []) -> List[str]: """ Refresh whitelist from pairlist or edge and extend it with trades. @@ -234,11 +228,20 @@ class FreqtradeBot: return trades_created - def get_buy_rate(self, pair: str, tick: Dict = None) -> float: + def get_buy_rate(self, pair: str, refresh: bool) -> float: """ Calculates bid target between current ask price and last price + :param pair: Pair to get rate for + :param refresh: allow cached data :return: float: Price """ + if not refresh: + rate = self._buy_rate_cache.get(pair) + # Check if cache has been invalidated + if rate: + logger.info(f"Using cached buy rate for {pair}.") + return rate + config_bid_strategy = self.config.get('bid_strategy', {}) if 'use_order_book' in config_bid_strategy and\ config_bid_strategy.get('use_order_book', False): @@ -251,11 +254,8 @@ class FreqtradeBot: logger.info('...top %s order book buy rate %0.8f', order_book_top, order_book_rate) used_rate = order_book_rate else: - if not tick: - logger.info('Using Last Ask / Last Price') - ticker = self.exchange.fetch_ticker(pair) - else: - ticker = tick + logger.info('Using Last Ask / Last Price') + ticker = self.exchange.fetch_ticker(pair) if ticker['ask'] < ticker['last']: ticker_rate = ticker['ask'] else: @@ -263,9 +263,11 @@ class FreqtradeBot: ticker_rate = ticker['ask'] + balance * (ticker['last'] - ticker['ask']) used_rate = ticker_rate + self._buy_rate_cache[pair] = used_rate + return used_rate - def get_trade_stake_amount(self, pair) -> float: + def get_trade_stake_amount(self, pair: str) -> float: """ Calculate stake amount for the trade :return: float: Stake amount @@ -404,7 +406,7 @@ class FreqtradeBot: stake_amount = self.get_trade_stake_amount(pair) if not stake_amount: - logger.debug("Stake amount is 0, ignoring possible trade for {pair}.") + logger.debug(f"Stake amount is 0, ignoring possible trade for {pair}.") return False logger.info(f"Buy signal found: about create a new trade with stake_amount: " @@ -414,10 +416,12 @@ class FreqtradeBot: if ((bid_check_dom.get('enabled', False)) and (bid_check_dom.get('bids_to_ask_delta', 0) > 0)): if self._check_depth_of_market_buy(pair, bid_check_dom): + logger.info(f'Executing Buy for {pair}.') return self.execute_buy(pair, stake_amount) else: return False + logger.info(f'Executing Buy for {pair}') return self.execute_buy(pair, stake_amount) else: return False @@ -427,23 +431,30 @@ class FreqtradeBot: Checks depth of market before executing a buy """ conf_bids_to_ask_delta = conf.get('bids_to_ask_delta', 0) - logger.info('checking depth of market for %s', pair) + logger.info(f"Checking depth of market for {pair} ...") order_book = self.exchange.get_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() bids_ask_delta = order_book_bids / order_book_asks - logger.info('bids: %s, asks: %s, delta: %s', order_book_bids, - order_book_asks, bids_ask_delta) + logger.info( + f"Bids: {order_book_bids}, Asks: {order_book_asks}, Delta: {bids_ask_delta}, " + f"Bid Price: {order_book['bids'][0][0]}, Ask Price: {order_book['asks'][0][0]}, " + f"Immediate Bid Quantity: {order_book['bids'][0][1]}, " + f"Immediate Ask Quantity: {order_book['asks'][0][1]}." + ) if bids_ask_delta >= conf_bids_to_ask_delta: + logger.info(f"Bids to asks delta for {pair} DOES satisfy condition.") return True - return False + else: + logger.info(f"Bids to asks delta for {pair} does not satisfy condition.") + return False def execute_buy(self, pair: str, stake_amount: float, price: Optional[float] = None) -> bool: """ Executes a limit buy for the given pair :param pair: pair for which we want to create a LIMIT_BUY - :return: None + :return: True if a buy order is created, false if it fails. """ time_in_force = self.strategy.order_time_in_force['buy'] @@ -451,7 +462,7 @@ class FreqtradeBot: buy_limit_requested = price else: # Calculate price - buy_limit_requested = self.get_buy_rate(pair) + buy_limit_requested = self.get_buy_rate(pair, True) min_stake_amount = self._get_min_pair_stake_amount(pair, buy_limit_requested) if min_stake_amount is not None and min_stake_amount > stake_amount: @@ -518,8 +529,6 @@ class FreqtradeBot: ticker_interval=timeframe_to_minutes(self.config['ticker_interval']) ) - self._notify_buy(trade, order_type) - # Update fees if order is closed if order_status == 'closed': self.update_trade_state(trade, order) @@ -530,9 +539,11 @@ class FreqtradeBot: # Updating wallets self.wallets.update() + self._notify_buy(trade, order_type) + return True - def _notify_buy(self, trade: Trade, order_type: str): + def _notify_buy(self, trade: Trade, order_type: str) -> None: """ Sends rpc notification when a buy occured. """ @@ -545,6 +556,32 @@ class FreqtradeBot: 'stake_amount': trade.stake_amount, 'stake_currency': self.config['stake_currency'], 'fiat_currency': self.config.get('fiat_display_currency', None), + 'amount': trade.amount, + 'open_date': trade.open_date or datetime.utcnow(), + 'current_rate': trade.open_rate_requested, + } + + # Send the message + self.rpc.send_msg(msg) + + def _notify_buy_cancel(self, trade: Trade, order_type: str) -> None: + """ + Sends rpc notification when a buy cancel occured. + """ + current_rate = self.get_buy_rate(trade.pair, False) + + msg = { + 'type': RPCMessageType.BUY_CANCEL_NOTIFICATION, + 'exchange': self.exchange.name.capitalize(), + 'pair': trade.pair, + 'limit': trade.open_rate, + 'order_type': order_type, + 'stake_amount': trade.stake_amount, + 'stake_currency': self.config['stake_currency'], + 'fiat_currency': self.config.get('fiat_display_currency', None), + 'amount': trade.amount, + 'open_date': trade.open_date, + 'current_rate': current_rate, } # Send the message @@ -586,8 +623,17 @@ class FreqtradeBot: The orderbook portion is only used for rpc messaging, which would otherwise fail for BitMex (has no bid/ask in fetch_ticker) or remain static in any other case since it's not updating. + :param pair: Pair to get rate for + :param refresh: allow cached data :return: Bid rate """ + if not refresh: + rate = self._sell_rate_cache.get(pair) + # Check if cache has been invalidated + if rate: + logger.info(f"Using cached sell rate for {pair}.") + return rate + config_ask_strategy = self.config.get('ask_strategy', {}) if config_ask_strategy.get('use_order_book', False): logger.debug('Using order book to get sell rate') @@ -596,7 +642,8 @@ class FreqtradeBot: rate = order_book['bids'][0][0] else: - rate = self.exchange.fetch_ticker(pair, refresh)['bid'] + rate = self.exchange.fetch_ticker(pair)['bid'] + self._sell_rate_cache[pair] = rate return rate def handle_trade(self, trade: Trade) -> bool: @@ -620,7 +667,7 @@ class FreqtradeBot: self.dataprovider.ohlcv(trade.pair, self.strategy.ticker_interval)) if config_ask_strategy.get('use_order_book', False): - logger.info('Using order book for selling...') + 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) @@ -629,7 +676,7 @@ class FreqtradeBot: for i in range(order_book_min, order_book_max + 1): order_book_rate = order_book['asks'][i - 1][0] - logger.info(' order book asks top %s: %0.8f', i, order_book_rate) + logger.debug(' order book asks top %s: %0.8f', i, order_book_rate) sell_rate = order_book_rate if self._check_and_execute_sell(trade, sell_rate, buy, sell): @@ -651,13 +698,10 @@ class FreqtradeBot: Force-sells the pair (using EmergencySell reason) in case of Problems creating the order. :return: True if the order succeeded, and False in case of problems. """ - # Limit price threshold: As limit price should always be below stop-price - LIMIT_PRICE_PCT = self.strategy.order_types.get('stoploss_on_exchange_limit_ratio', 0.99) - try: - stoploss_order = self.exchange.stoploss_limit(pair=trade.pair, amount=trade.amount, - stop_price=stop_price, - rate=rate * LIMIT_PRICE_PCT) + stoploss_order = self.exchange.stoploss(pair=trade.pair, amount=trade.amount, + stop_price=stop_price, + order_types=self.strategy.order_types) trade.stoploss_order_id = str(stoploss_order['id']) return True except InvalidOrderException as e: @@ -689,8 +733,24 @@ class FreqtradeBot: except InvalidOrderException as exception: logger.warning('Unable to fetch stoploss order: %s', exception) + # We check if stoploss order is fulfilled + if stoploss_order and stoploss_order['status'] == 'closed': + trade.sell_reason = SellType.STOPLOSS_ON_EXCHANGE.value + trade.update(stoploss_order) + # Lock pair for one candle to prevent immediate rebuys + self.strategy.lock_pair(trade.pair, + timeframe_to_next_date(self.config['ticker_interval'])) + self._notify_sell(trade, "stoploss") + return True + + if trade.open_order_id or not trade.is_open: + # Trade has an open Buy or Sell order, Stoploss-handling can't happen in this case + # as the Amount on the exchange is tied up in another trade. + # The trade can be closed already (sell-order fill confirmation came in this iteration) + return False + # If buy order is fulfilled but there is no stoploss, we add a stoploss on exchange - if (not trade.open_order_id and not stoploss_order): + if (not stoploss_order): stoploss = self.edge.stoploss(pair=trade.pair) if self.edge else self.strategy.stoploss @@ -709,16 +769,6 @@ class FreqtradeBot: trade.stoploss_order_id = None logger.warning('Stoploss order was cancelled, but unable to recreate one.') - # 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) - # Lock pair for one candle to prevent immediate rebuys - self.strategy.lock_pair(trade.pair, - timeframe_to_next_date(self.config['ticker_interval'])) - self._notify_sell(trade, "stoploss") - return True - # Finally we check if stoploss on exchange should be moved up because of trailing. if stoploss_order and self.config.get('trailing_stop', False): # if trailing stoploss is enabled we check if stoploss value has changed @@ -728,7 +778,7 @@ class FreqtradeBot: return False - def handle_trailing_stoploss_on_exchange(self, trade: Trade, order): + def handle_trailing_stoploss_on_exchange(self, trade: Trade, order: dict) -> None: """ Check to see if stoploss on exchange should be updated in case of trailing stoploss on exchange @@ -736,13 +786,12 @@ class FreqtradeBot: :param order: Current on exchange stoploss order :return: None """ - - if trade.stop_loss > float(order['info']['stopPrice']): + if self.exchange.stoploss_adjust(trade.stop_loss, order): # we check if the update is neccesary update_beat = self.strategy.order_types.get('stoploss_on_exchange_interval', 60) if (datetime.utcnow() - trade.stoploss_last_update).total_seconds() >= update_beat: # cancelling the current stoploss on exchange first - logger.info('Trailing stoploss: cancelling current stoploss on exchange (id:{%s})' + logger.info('Trailing stoploss: cancelling current stoploss on exchange (id:{%s}) ' 'in order to add another one ...', order['id']) try: self.exchange.cancel_order(order['id'], trade.pair) @@ -751,10 +800,8 @@ class FreqtradeBot: f"for pair {trade.pair}") # Create new stoploss order - if self.create_stoploss_order(trade=trade, stop_price=trade.stop_loss, - rate=trade.stop_loss): - return False - else: + if not self.create_stoploss_order(trade=trade, stop_price=trade.stop_loss, + rate=trade.stop_loss): logger.warning(f"Could not create trailing stoploss order " f"for pair {trade.pair}.") @@ -769,8 +816,8 @@ class FreqtradeBot: ) if should_sell.sell_flag: + logger.info(f'Executing Sell for {trade.pair}. Reason: {should_sell.sell_type}') self.execute_sell(trade, sell_rate, should_sell.sell_type) - logger.info('executed sell, reason: %s', should_sell.sell_type) return True return False @@ -813,41 +860,37 @@ class FreqtradeBot: if ((order['side'] == 'buy' and order['status'] == 'canceled') or (self._check_timed_out('buy', order))): - self.handle_timedout_limit_buy(trade, order) self.wallets.update() + order_type = self.strategy.order_types['buy'] + self._notify_buy_cancel(trade, order_type) elif ((order['side'] == 'sell' and order['status'] == 'canceled') or (self._check_timed_out('sell', order))): self.handle_timedout_limit_sell(trade, order) self.wallets.update() - - def handle_buy_order_full_cancel(self, trade: Trade, reason: str) -> None: - """Close trade in database and send message""" - Trade.session.delete(trade) - Trade.session.flush() - logger.info('Buy order %s for %s.', reason, trade) - self.rpc.send_msg({ - 'type': RPCMessageType.STATUS_NOTIFICATION, - 'status': f'Unfilled buy order for {trade.pair} {reason}' - }) + order_type = self.strategy.order_types['sell'] + self._notify_sell_cancel(trade, order_type) def handle_timedout_limit_buy(self, trade: Trade, order: Dict) -> bool: """ Buy timeout - cancel order :return: True if order was fully cancelled """ - reason = "cancelled due to timeout" if order['status'] != 'canceled': + reason = "cancelled due to timeout" corder = self.exchange.cancel_order(trade.open_order_id, trade.pair) + logger.info('Buy order %s for %s.', reason, trade) else: # Order was cancelled already, so we can reuse the existing dict corder = order - reason = "canceled on Exchange" + reason = "cancelled on exchange" + logger.info('Buy order %s for %s.', reason, trade) if corder.get('remaining', order['remaining']) == order['amount']: # if trade is not partially completed, just delete the trade - self.handle_buy_order_full_cancel(trade, reason) + Trade.session.delete(trade) + Trade.session.flush() return True # if trade is partially complete, edit the stake details for the trade @@ -882,24 +925,22 @@ class FreqtradeBot: Sell timeout - cancel order and update trade :return: True if order was fully cancelled """ + # if trade is not partially completed, just cancel the trade if order['remaining'] == order['amount']: - # if trade is not partially completed, just cancel the trade if order["status"] != "canceled": - reason = "due to timeout" + 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) - logger.info('Sell order timeout for %s.', trade) + logger.info('Sell order %s for %s.', reason, trade) else: - reason = "on exchange" - logger.info('Sell order canceled on exchange for %s.', trade) + reason = "cancelled on exchange" + logger.info('Sell order %s for %s.', reason, trade) + trade.close_rate = None trade.close_profit = None trade.close_date = None trade.is_open = True trade.open_order_id = None - self.rpc.send_msg({ - 'type': RPCMessageType.STATUS_NOTIFICATION, - 'status': f'Unfilled sell order for {trade.pair} cancelled {reason}' - }) return True @@ -931,13 +972,13 @@ class FreqtradeBot: raise DependencyException( f"Not enough amount to sell. Trade-amount: {amount}, Wallet: {wallet_amount}") - def execute_sell(self, trade: Trade, limit: float, sell_reason: SellType) -> None: + def execute_sell(self, trade: Trade, limit: float, sell_reason: SellType) -> bool: """ Executes a limit sell for the given trade and limit :param trade: Trade instance :param limit: limit rate for the sell order :param sellreason: Reason the sell was triggered - :return: None + :return: True if it succeeds (supported) False (not supported) """ sell_type = 'sell' if sell_reason in (SellType.STOP_LOSS, SellType.TRAILING_STOP_LOSS): @@ -958,7 +999,7 @@ class FreqtradeBot: order_type = self.strategy.order_types[sell_type] if sell_reason == SellType.EMERGENCY_SELL: - # Emergencysells (default to market!) + # Emergency sells (default to market!) order_type = self.strategy.order_types.get("emergencysell", "market") amount = self._safe_sell_amount(trade.pair, trade.amount) @@ -983,7 +1024,9 @@ class FreqtradeBot: self._notify_sell(trade, order_type) - def _notify_sell(self, trade: Trade, order_type: str): + return True + + def _notify_sell(self, trade: Trade, order_type: str) -> None: """ Sends rpc notification when a sell occured. """ @@ -999,7 +1042,7 @@ class FreqtradeBot: 'exchange': trade.exchange.capitalize(), 'pair': trade.pair, 'gain': gain, - 'limit': trade.close_rate_requested, + 'limit': profit_rate, 'order_type': order_type, 'amount': trade.amount, 'open_rate': trade.open_rate, @@ -1010,6 +1053,44 @@ class FreqtradeBot: 'open_date': trade.open_date, 'close_date': trade.close_date or datetime.utcnow(), 'stake_currency': self.config['stake_currency'], + 'fiat_currency': self.config.get('fiat_display_currency', None), + } + + if 'fiat_display_currency' in self.config: + msg.update({ + 'fiat_currency': self.config['fiat_display_currency'], + }) + + # Send the message + self.rpc.send_msg(msg) + + def _notify_sell_cancel(self, trade: Trade, order_type: str) -> None: + """ + Sends rpc notification when a sell cancel occured. + """ + 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) + profit_percent = trade.calc_profit_ratio(profit_rate) + gain = "profit" if profit_percent > 0 else "loss" + + msg = { + 'type': RPCMessageType.SELL_CANCEL_NOTIFICATION, + 'exchange': trade.exchange.capitalize(), + 'pair': trade.pair, + 'gain': gain, + 'limit': profit_rate, + 'order_type': order_type, + 'amount': trade.amount, + 'open_rate': trade.open_rate, + 'current_rate': current_rate, + 'profit_amount': profit_trade, + 'profit_percent': profit_percent, + 'sell_reason': trade.sell_reason, + 'open_date': trade.open_date, + 'close_date': trade.close_date, + 'stake_currency': self.config['stake_currency'], + 'fiat_currency': self.config.get('fiat_display_currency', None), } if 'fiat_display_currency' in self.config: @@ -1024,7 +1105,7 @@ class FreqtradeBot: # Common update trade state methods # - def update_trade_state(self, trade, action_order: dict = None): + def update_trade_state(self, trade: Trade, action_order: dict = None) -> None: """ Checks trades with open orders and updates the amount if necessary """ diff --git a/freqtrade/main.py b/freqtrade/main.py index a75eeebed..08bdc5e32 100755 --- a/freqtrade/main.py +++ b/freqtrade/main.py @@ -38,8 +38,8 @@ def main(sysargv: List[str] = None) -> None: # No subcommand was issued. raise OperationalException( "Usage of Freqtrade requires a subcommand to be specified.\n" - "To have the previous behavior (bot executing trades in live/dry-run modes, " - "depending on the value of the `dry_run` setting in the config), run freqtrade " + "To have the bot executing trades in live/dry-run modes, " + "depending on the value of the `dry_run` setting in the config, run Freqtrade " "as `freqtrade trade [options...]`.\n" "To see the full list of options available, please use " "`freqtrade --help` or `freqtrade --help`." diff --git a/freqtrade/misc.py b/freqtrade/misc.py index bcba78cf0..96bac28d8 100644 --- a/freqtrade/misc.py +++ b/freqtrade/misc.py @@ -6,6 +6,7 @@ import logging import re from datetime import datetime from pathlib import Path +from typing import Any from typing.io import IO import numpy as np @@ -40,28 +41,30 @@ def datesarray_to_datetimearray(dates: np.ndarray) -> np.ndarray: return dates.dt.to_pydatetime() -def file_dump_json(filename: Path, data, is_zip=False) -> None: +def file_dump_json(filename: Path, data: Any, is_zip: bool = False) -> None: """ Dump JSON data into a file :param filename: file to create :param data: JSON Data to save :return: """ - logger.info(f'dumping json to "{filename}"') if is_zip: if filename.suffix != '.gz': filename = filename.with_suffix('.gz') + logger.info(f'dumping json to "{filename}"') + with gzip.open(filename, 'w') as fp: rapidjson.dump(data, fp, default=str, number_mode=rapidjson.NM_NATIVE) else: + logger.info(f'dumping json to "{filename}"') with open(filename, 'w') as fp: rapidjson.dump(data, fp, default=str, number_mode=rapidjson.NM_NATIVE) logger.debug(f'done json to "{filename}"') -def json_load(datafile: IO): +def json_load(datafile: IO) -> Any: """ load data with rapidjson Use this to have a consistent experience, @@ -90,6 +93,12 @@ def file_load_json(file): return pairdata +def pair_to_filename(pair: str) -> str: + for ch in ['/', '-', ' ', '.', '@', '$', '+', ':']: + pair = pair.replace(ch, '_') + return pair + + def format_ms_time(date: int) -> str: """ convert MS date to readable format. @@ -125,11 +134,11 @@ def round_dict(d, n): return {k: (round(v, n) if isinstance(v, float) else v) for k, v in d.items()} -def plural(num, singular: str, plural: str = None) -> str: +def plural(num: float, singular: str, plural: str = None) -> str: return singular if (num == 1 or num == -1) else plural or singular + 's' -def render_template(templatefile: str, arguments: dict = {}): +def render_template(templatefile: str, arguments: dict = {}) -> str: from jinja2 import Environment, PackageLoader, select_autoescape @@ -138,5 +147,4 @@ def render_template(templatefile: str, arguments: dict = {}): autoescape=select_autoescape(['html', 'xml']) ) template = env.get_template(templatefile) - return template.render(**arguments) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index cdf74f65f..c18aefc76 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -9,11 +9,13 @@ from datetime import datetime, timedelta from pathlib import Path from typing import Any, Dict, List, NamedTuple, Optional +import arrow from pandas import DataFrame from freqtrade.configuration import (TimeRange, remove_credentials, validate_config_consistency) from freqtrade.data import history +from freqtrade.data.converter import trim_dataframe from freqtrade.data.dataprovider import DataProvider from freqtrade.exceptions import OperationalException from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds @@ -24,7 +26,7 @@ from freqtrade.optimize.optimize_reports import ( from freqtrade.persistence import Trade from freqtrade.resolvers import ExchangeResolver, StrategyResolver from freqtrade.state import RunMode -from freqtrade.strategy.interface import IStrategy, SellType +from freqtrade.strategy.interface import IStrategy, SellCheckTuple, SellType logger = logging.getLogger(__name__) @@ -117,6 +119,7 @@ class Backtesting: timerange=timerange, startup_candles=self.required_startup, fail_without_data=True, + data_format=self.config.get('dataformat_ohlcv', 'json'), ) min_date, max_date = history.get_timerange(data) @@ -148,7 +151,7 @@ class Backtesting: logger.info(f'Dumping backtest results to {recordfilename}') file_dump_json(recordfilename, records) - def _get_ticker_list(self, processed) -> Dict[str, DataFrame]: + def _get_ticker_list(self, processed: Dict) -> Dict[str, DataFrame]: """ Helper function to convert a processed tickerlist into a list for performance reasons. @@ -175,7 +178,8 @@ class Backtesting: ticker[pair] = [x for x in ticker_data.itertuples()] return ticker - def _get_close_rate(self, sell_row, trade: Trade, sell, trade_dur) -> float: + def _get_close_rate(self, sell_row, trade: Trade, sell: SellCheckTuple, + trade_dur: int) -> float: """ Get close rate for backtesting result """ @@ -280,7 +284,7 @@ class Backtesting: return None def backtest(self, processed: Dict, stake_amount: float, - start_date, end_date, + start_date: arrow.Arrow, end_date: arrow.Arrow, max_open_trades: int = 0, position_stacking: bool = False) -> DataFrame: """ Implement backtesting functionality @@ -395,7 +399,7 @@ class Backtesting: # Trim startup period from analyzed dataframe for pair, df in preprocessed.items(): - preprocessed[pair] = history.trim_dataframe(df, timerange) + preprocessed[pair] = trim_dataframe(df, timerange) min_date, max_date = history.get_timerange(preprocessed) logger.info( @@ -404,12 +408,12 @@ class Backtesting: ) # Execute backtest and print results all_results[self.strategy.get_strategy_name()] = self.backtest( - processed=preprocessed, - stake_amount=self.config['stake_amount'], - start_date=min_date, - end_date=max_date, - max_open_trades=max_open_trades, - position_stacking=position_stacking, + processed=preprocessed, + stake_amount=self.config['stake_amount'], + start_date=min_date, + end_date=max_date, + max_open_trades=max_open_trades, + position_stacking=position_stacking, ) for strategy, results in all_results.items(): @@ -426,7 +430,10 @@ class Backtesting: results=results)) print(' SELL REASON STATS '.center(133, '=')) - print(generate_text_table_sell_reason(data, results)) + print(generate_text_table_sell_reason(data, + stake_currency=self.config['stake_currency'], + max_open_trades=self.config['max_open_trades'], + results=results)) print(' LEFT OPEN TRADES REPORT '.center(133, '=')) print(generate_text_table(data, @@ -436,7 +443,7 @@ class Backtesting: print() if len(all_results) > 1: # Print Strategy summary table - print(' Strategy Summary '.center(133, '=')) + print(' STRATEGY SUMMARY '.center(133, '=')) print(generate_text_table_strategy(self.config['stake_currency'], self.config['max_open_trades'], all_results=all_results)) diff --git a/freqtrade/optimize/hyperopt.py b/freqtrade/optimize/hyperopt.py index 525f491f3..0f9076770 100644 --- a/freqtrade/optimize/hyperopt.py +++ b/freqtrade/optimize/hyperopt.py @@ -22,7 +22,8 @@ from joblib import (Parallel, cpu_count, delayed, dump, load, wrap_non_picklable_objects) from pandas import DataFrame -from freqtrade.data.history import get_timerange, trim_dataframe +from freqtrade.data.converter import trim_dataframe +from freqtrade.data.history import get_timerange from freqtrade.exceptions import OperationalException from freqtrade.misc import plural, round_dict from freqtrade.optimize.backtesting import Backtesting @@ -59,6 +60,7 @@ class Hyperopt: hyperopt = Hyperopt(config) hyperopt.start() """ + def __init__(self, config: Dict[str, Any]) -> None: self.config = config @@ -90,13 +92,13 @@ class Hyperopt: # Populate functions here (hasattr is slow so should not be run during "regular" operations) if hasattr(self.custom_hyperopt, 'populate_indicators'): self.backtesting.strategy.advise_indicators = \ - self.custom_hyperopt.populate_indicators # type: ignore + self.custom_hyperopt.populate_indicators # type: ignore if hasattr(self.custom_hyperopt, 'populate_buy_trend'): self.backtesting.strategy.advise_buy = \ - self.custom_hyperopt.populate_buy_trend # type: ignore + self.custom_hyperopt.populate_buy_trend # type: ignore if hasattr(self.custom_hyperopt, 'populate_sell_trend'): self.backtesting.strategy.advise_sell = \ - self.custom_hyperopt.populate_sell_trend # type: ignore + self.custom_hyperopt.populate_sell_trend # type: ignore # Use max_open_trades for hyperopt as well, except --disable-max-market-positions is set if self.config.get('use_max_market_positions', True): @@ -117,11 +119,11 @@ class Hyperopt: self.print_json = self.config.get('print_json', False) @staticmethod - def get_lock_filename(config) -> str: + def get_lock_filename(config: Dict[str, Any]) -> str: return str(config['user_data_dir'] / 'hyperopt.lock') - def clean_hyperopt(self): + def clean_hyperopt(self) -> None: """ Remove hyperopt pickle files to restart hyperopt. """ @@ -158,7 +160,7 @@ class Hyperopt: f"saved to '{self.trials_file}'.") @staticmethod - def _read_trials(trials_file) -> List: + def _read_trials(trials_file: Path) -> List: """ Read hyperopt trials file """ @@ -189,7 +191,7 @@ class Hyperopt: return result @staticmethod - def print_epoch_details(results, total_epochs, print_json: bool, + def print_epoch_details(results, total_epochs: int, print_json: bool, no_header: bool = False, header_str: str = None) -> None: """ Display details of the hyperopt result @@ -218,7 +220,7 @@ class Hyperopt: Hyperopt._params_pretty_print(params, 'trailing', "Trailing stop:") @staticmethod - def _params_update_for_json(result_dict, params, space: str): + def _params_update_for_json(result_dict, params, space: str) -> None: if space in params: space_params = Hyperopt._space_params(params, space) if space in ['buy', 'sell']: @@ -235,7 +237,7 @@ class Hyperopt: result_dict.update(space_params) @staticmethod - def _params_pretty_print(params, space: str, header: str): + def _params_pretty_print(params, space: str, header: str) -> None: if space in params: space_params = Hyperopt._space_params(params, space, 5) if space == 'stoploss': @@ -251,7 +253,7 @@ class Hyperopt: return round_dict(d, r) if r else d @staticmethod - def is_best_loss(results, current_best_loss) -> bool: + def is_best_loss(results, current_best_loss: float) -> bool: return results['loss'] < current_best_loss def print_results(self, results) -> None: @@ -345,15 +347,15 @@ class Hyperopt: if self.has_space('roi'): self.backtesting.strategy.minimal_roi = \ - self.custom_hyperopt.generate_roi_table(params_dict) + self.custom_hyperopt.generate_roi_table(params_dict) if self.has_space('buy'): self.backtesting.strategy.advise_buy = \ - self.custom_hyperopt.buy_strategy_generator(params_dict) + self.custom_hyperopt.buy_strategy_generator(params_dict) if self.has_space('sell'): self.backtesting.strategy.advise_sell = \ - self.custom_hyperopt.sell_strategy_generator(params_dict) + self.custom_hyperopt.sell_strategy_generator(params_dict) if self.has_space('stoploss'): self.backtesting.strategy.stoploss = params_dict['stoploss'] @@ -372,12 +374,12 @@ class Hyperopt: min_date, max_date = get_timerange(processed) backtesting_results = self.backtesting.backtest( - processed=processed, - stake_amount=self.config['stake_amount'], - start_date=min_date, - end_date=max_date, - max_open_trades=self.max_open_trades, - position_stacking=self.position_stacking, + processed=processed, + stake_amount=self.config['stake_amount'], + start_date=min_date, + end_date=max_date, + max_open_trades=self.max_open_trades, + position_stacking=self.position_stacking, ) return self._get_results_dict(backtesting_results, min_date, max_date, params_dict, params_details) @@ -438,7 +440,7 @@ class Hyperopt: random_state=self.random_state, ) - def fix_optimizer_models_list(self): + 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 @@ -460,7 +462,7 @@ class Hyperopt: wrap_non_picklable_objects(self.generate_optimizer))(v, i) for v in asked) @staticmethod - def load_previous_results(trials_file) -> List: + def load_previous_results(trials_file: Path) -> List: """ Load data for epochs from the file if we have one """ @@ -469,8 +471,8 @@ class Hyperopt: trials = Hyperopt._read_trials(trials_file) if trials[0].get('is_best') is None: raise OperationalException( - "The file with Hyperopt results is incompatible with this version " - "of Freqtrade and cannot be loaded.") + "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 diff --git a/freqtrade/optimize/hyperopt_interface.py b/freqtrade/optimize/hyperopt_interface.py index d7d917c19..b3cedef2c 100644 --- a/freqtrade/optimize/hyperopt_interface.py +++ b/freqtrade/optimize/hyperopt_interface.py @@ -207,7 +207,7 @@ class IHyperOpt(ABC): # so this intermediate parameter is used as the value of the difference between # them. The value of the 'trailing_stop_positive_offset' is constructed in the # generate_trailing_params() method. - # # This is similar to the hyperspace dimensions used for constructing the ROI tables. + # This is similar to the hyperspace dimensions used for constructing the ROI tables. Real(0.001, 0.1, name='trailing_stop_positive_offset_p1'), Categorical([True, False], name='trailing_only_offset_is_reached'), diff --git a/freqtrade/optimize/hyperopt_loss_sharpe.py b/freqtrade/optimize/hyperopt_loss_sharpe.py index 5631a75de..a4ec6f90a 100644 --- a/freqtrade/optimize/hyperopt_loss_sharpe.py +++ b/freqtrade/optimize/hyperopt_loss_sharpe.py @@ -28,18 +28,19 @@ class SharpeHyperOptLoss(IHyperOptLoss): Uses Sharpe Ratio calculation. """ - total_profit = results.profit_percent + total_profit = results["profit_percent"] days_period = (max_date - min_date).days # adding slippage of 0.1% per trade total_profit = total_profit - 0.0005 - expected_yearly_return = total_profit.sum() / days_period + expected_returns_mean = total_profit.sum() / days_period + up_stdev = np.std(total_profit) if (np.std(total_profit) != 0.): - sharp_ratio = expected_yearly_return / np.std(total_profit) * np.sqrt(365) + sharp_ratio = expected_returns_mean / up_stdev * np.sqrt(365) else: # Define high (negative) sharpe ratio to be clear that this is NOT optimal. sharp_ratio = -20. - # print(expected_yearly_return, np.std(total_profit), sharp_ratio) + # print(expected_returns_mean, up_stdev, sharp_ratio) return -sharp_ratio diff --git a/freqtrade/optimize/hyperopt_loss_sharpe_daily.py b/freqtrade/optimize/hyperopt_loss_sharpe_daily.py new file mode 100644 index 000000000..5a8ebaa11 --- /dev/null +++ b/freqtrade/optimize/hyperopt_loss_sharpe_daily.py @@ -0,0 +1,62 @@ +""" +SharpeHyperOptLossDaily + +This module defines the alternative HyperOptLoss class which can be used for +Hyperoptimization. +""" +import math +from datetime import datetime + +from pandas import DataFrame, date_range + +from freqtrade.optimize.hyperopt import IHyperOptLoss + + +class SharpeHyperOptLossDaily(IHyperOptLoss): + """ + Defines the loss function for hyperopt. + + This implementation uses the Sharpe Ratio calculation. + """ + + @staticmethod + def hyperopt_loss_function(results: DataFrame, trade_count: int, + min_date: datetime, max_date: datetime, + *args, **kwargs) -> float: + """ + Objective function, returns smaller number for more optimal results. + + Uses Sharpe Ratio calculation. + """ + resample_freq = '1D' + slippage_per_trade_ratio = 0.0005 + days_in_year = 365 + annual_risk_free_rate = 0.0 + risk_free_rate = annual_risk_free_rate / days_in_year + + # apply slippage per trade to profit_percent + results.loc[:, 'profit_percent_after_slippage'] = \ + results['profit_percent'] - slippage_per_trade_ratio + + # create the index within the min_date and end max_date + t_index = date_range(start=min_date, end=max_date, freq=resample_freq, + normalize=True) + + sum_daily = ( + results.resample(resample_freq, on='close_time').agg( + {"profit_percent_after_slippage": sum}).reindex(t_index).fillna(0) + ) + + total_profit = sum_daily["profit_percent_after_slippage"] - risk_free_rate + expected_returns_mean = total_profit.mean() + up_stdev = total_profit.std() + + if (up_stdev != 0.): + sharp_ratio = expected_returns_mean / up_stdev * math.sqrt(days_in_year) + else: + # Define high (negative) sharpe ratio to be clear that this is NOT optimal. + sharp_ratio = -20. + + # print(t_index, sum_daily, total_profit) + # print(risk_free_rate, expected_returns_mean, up_stdev, sharp_ratio) + return -sharp_ratio diff --git a/freqtrade/optimize/optimize_reports.py b/freqtrade/optimize/optimize_reports.py index 67056eaa9..b00adbd48 100644 --- a/freqtrade/optimize/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports.py @@ -19,9 +19,18 @@ def generate_text_table(data: Dict[str, Dict], stake_currency: str, max_open_tra floatfmt = ('s', 'd', '.2f', '.2f', '.8f', '.2f', 'd', '.1f', '.1f') tabular_data = [] - headers = ['pair', 'buy count', 'avg profit %', 'cum profit %', - f'tot profit {stake_currency}', 'tot profit %', 'avg duration', - 'profit', 'loss'] + 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(): @@ -37,6 +46,7 @@ def generate_text_table(data: Dict[str, Dict], stake_currency: str, max_open_tra 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]) ]) @@ -51,6 +61,7 @@ def generate_text_table(data: Dict[str, Dict], stake_currency: str, max_open_tra str(timedelta( minutes=round(results.trade_duration.mean()))) if not results.empty else '0:00', len(results[results.profit_abs > 0]), + len(results[results.profit_abs == 0]), len(results[results.profit_abs < 0]) ]) # Ignore type as floatfmt does allow tuples but mypy does not know that @@ -58,7 +69,9 @@ def generate_text_table(data: Dict[str, Dict], stake_currency: str, max_open_tra floatfmt=floatfmt, tablefmt="pipe") # type: ignore -def generate_text_table_sell_reason(data: Dict[str, Dict], results: DataFrame) -> str: +def generate_text_table_sell_reason( + data: Dict[str, Dict], stake_currency: str, max_open_trades: int, results: DataFrame +) -> str: """ Generate small table outlining Backtest results :param data: Dict of containing data that was used during backtesting. @@ -66,13 +79,39 @@ def generate_text_table_sell_reason(data: Dict[str, Dict], results: DataFrame) - :return: pretty printed table with tabulate as string """ tabular_data = [] - headers = ['Sell Reason', 'Count', 'Profit', 'Loss', 'Profit %'] + 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] - profit = len(result[result['profit_abs'] >= 0]) + 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) - tabular_data.append([reason.value, count, profit, loss, profit_mean]) + profit_sum = round(result["profit_percent"].sum() * 100.0, 2) + profit_tot = result['profit_abs'].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, + ] + ) return tabulate(tabular_data, headers=headers, tablefmt="pipe") @@ -88,9 +127,9 @@ def generate_text_table_strategy(stake_currency: str, max_open_trades: str, floatfmt = ('s', 'd', '.2f', '.2f', '.8f', '.2f', 'd', '.1f', '.1f') tabular_data = [] - headers = ['Strategy', 'buy count', 'avg profit %', 'cum profit %', - f'tot profit {stake_currency}', 'tot profit %', 'avg duration', - 'profit', 'loss'] + 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, @@ -102,6 +141,7 @@ def generate_text_table_strategy(stake_currency: str, max_open_trades: str, str(timedelta( minutes=round(results.trade_duration.mean()))) if not results.empty else '0:00', len(results[results.profit_abs > 0]), + len(results[results.profit_abs == 0]), len(results[results.profit_abs < 0]) ]) # Ignore type as floatfmt does allow tuples but mypy does not know that @@ -113,9 +153,9 @@ def generate_edge_table(results: dict) -> str: floatfmt = ('s', '.10g', '.2f', '.2f', '.2f', '.2f', 'd', '.d') tabular_data = [] - headers = ['pair', 'stoploss', 'win rate', 'risk reward ratio', - 'required risk reward', 'expectancy', 'total number of trades', - 'average duration (min)'] + headers = ['Pair', 'Stoploss', 'Win Rate', 'Risk Reward Ratio', + 'Required Risk Reward', 'Expectancy', 'Total Number of Trades', + 'Average Duration (min)'] for result in results.items(): if result[1].nb_trades > 0: diff --git a/freqtrade/pairlist/IPairList.py b/freqtrade/pairlist/IPairList.py index d722e70f5..1ad4da523 100644 --- a/freqtrade/pairlist/IPairList.py +++ b/freqtrade/pairlist/IPairList.py @@ -7,7 +7,7 @@ Provides lists as configured in config.json import logging from abc import ABC, abstractmethod, abstractproperty from copy import deepcopy -from typing import Dict, List +from typing import Any, Dict, List from freqtrade.exchange import market_is_active @@ -16,7 +16,8 @@ logger = logging.getLogger(__name__) class IPairList(ABC): - 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: """ :param exchange: Exchange instance diff --git a/freqtrade/pairlist/PrecisionFilter.py b/freqtrade/pairlist/PrecisionFilter.py index 5d364795d..f16458ca5 100644 --- a/freqtrade/pairlist/PrecisionFilter.py +++ b/freqtrade/pairlist/PrecisionFilter.py @@ -48,10 +48,10 @@ class PrecisionFilter(IPairList): """ Filters and sorts pairlists and assigns and returns them again. """ - stoploss = None - if self._config.get('stoploss') is not None: + stoploss = self._config.get('stoploss') + if stoploss is not None: # Precalculate sanitized stoploss value to avoid recalculation for every pair - stoploss = 1 - abs(self._config.get('stoploss')) + stoploss = 1 - abs(stoploss) # Copy list since we're modifying this list for p in deepcopy(pairlist): ticker = tickers.get(p) diff --git a/freqtrade/pairlist/PriceFilter.py b/freqtrade/pairlist/PriceFilter.py index b3546ebd9..dc02ae251 100644 --- a/freqtrade/pairlist/PriceFilter.py +++ b/freqtrade/pairlist/PriceFilter.py @@ -1,6 +1,6 @@ import logging from copy import deepcopy -from typing import Dict, List +from typing import Any, Dict, List from freqtrade.pairlist.IPairList import IPairList @@ -9,7 +9,8 @@ logger = logging.getLogger(__name__) class PriceFilter(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) diff --git a/freqtrade/pairlist/SpreadFilter.py b/freqtrade/pairlist/SpreadFilter.py new file mode 100644 index 000000000..9361837cc --- /dev/null +++ b/freqtrade/pairlist/SpreadFilter.py @@ -0,0 +1,59 @@ +import logging +from copy import deepcopy +from typing import Dict, List + +from freqtrade.pairlist.IPairList import IPairList + +logger = logging.getLogger(__name__) + + +class SpreadFilter(IPairList): + + def __init__(self, exchange, pairlistmanager, config, pairlistconfig: dict, + pairlist_pos: int) -> None: + super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) + + self._max_spread_ratio = pairlistconfig.get('max_spread_ratio', 0.005) + + @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 True + + def short_desc(self) -> str: + """ + Short whitelist method description - used for startup-messages + """ + 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]: + + """ + 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 + + 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: + logger.info(f"Removed {ticker['symbol']} from whitelist, " + f"because spread {spread * 100:.3f}% >" + f"{self._max_spread_ratio * 100}%") + pairlist.remove(p) + else: + pairlist.remove(p) + + return pairlist diff --git a/freqtrade/pairlist/VolumePairList.py b/freqtrade/pairlist/VolumePairList.py index 4ac9935ba..e50dafb63 100644 --- a/freqtrade/pairlist/VolumePairList.py +++ b/freqtrade/pairlist/VolumePairList.py @@ -6,7 +6,7 @@ Provides lists as configured in config.json """ import logging from datetime import datetime -from typing import Dict, List +from typing import Any, Dict, List from freqtrade.exceptions import OperationalException from freqtrade.pairlist.IPairList import IPairList @@ -18,7 +18,7 @@ SORT_VALUES = ['askVolume', 'bidVolume', 'quoteVolume'] class VolumePairList(IPairList): - def __init__(self, exchange, pairlistmanager, config, pairlistconfig: dict, + def __init__(self, exchange, pairlistmanager, config: Dict[str, Any], pairlistconfig: dict, pairlist_pos: int) -> None: super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) @@ -28,6 +28,7 @@ class VolumePairList(IPairList): 'for "pairlist.config.number_assets"') 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) self.refresh_period = self._pairlistconfig.get('refresh_period', 1800) if not self._exchange.exchange_has('fetchTickers'): @@ -73,11 +74,13 @@ class VolumePairList(IPairList): tickers, self._config['stake_currency'], self._sort_key, + self._min_value ) else: return pairlist - def _gen_pair_whitelist(self, pairlist, tickers, base_currency: str, key: str) -> List[str]: + def _gen_pair_whitelist(self, pairlist: List[str], tickers: Dict, + base_currency: str, key: str, min_val: int) -> List[str]: """ Updates the whitelist with with a dynamically generated list :param base_currency: base currency as str @@ -96,6 +99,9 @@ class VolumePairList(IPairList): # If other pairlist is in front, use the incomming pairlist. filtered_tickers = [v for k, v in tickers.items() if k in pairlist] + if min_val > 0: + filtered_tickers = list(filter(lambda t: t[key] > min_val, filtered_tickers)) + sorted_tickers = sorted(filtered_tickers, reverse=True, key=lambda t: t[key]) # Validate whitelist to only have active market pairs diff --git a/freqtrade/persistence.py b/freqtrade/persistence.py index 75116f1e3..fa041abc3 100644 --- a/freqtrade/persistence.py +++ b/freqtrade/persistence.py @@ -64,11 +64,11 @@ def init(db_url: str, clean_open_orders: bool = False) -> None: clean_dry_run_db() -def has_column(columns, searchname: str) -> bool: +def has_column(columns: List, searchname: str) -> bool: return len(list(filter(lambda x: x["name"] == searchname, columns))) == 1 -def get_column_def(columns, column: str, default: str) -> str: +def get_column_def(columns: List, column: str, default: str) -> str: return default if not has_column(columns, column) else column @@ -246,14 +246,15 @@ class Trade(_DECL_BASE): if self.initial_stop_loss_pct else None), } - def adjust_min_max_rates(self, current_price: float): + def adjust_min_max_rates(self, current_price: float) -> None: """ Adjust the max_rate and min_rate. """ self.max_rate = max(current_price, self.max_rate or self.open_rate) self.min_rate = min(current_price, self.min_rate or self.open_rate) - def adjust_stop_loss(self, current_price: float, stoploss: float, initial: bool = False): + def adjust_stop_loss(self, current_price: float, stoploss: float, + initial: bool = False) -> None: """ This adjusts the stop loss to it's most recently observed setting :param current_price: Current rate the asset is traded @@ -317,10 +318,10 @@ class Trade(_DECL_BASE): elif order_type in ('market', 'limit') and order['side'] == 'sell': self.close(order['price']) logger.info('%s_SELL has been fulfilled for %s.', order_type.upper(), self) - elif order_type == 'stop_loss_limit': + elif order_type in ('stop_loss_limit', 'stop-loss'): self.stoploss_order_id = None self.close_rate_requested = self.stop_loss - logger.info('STOP_LOSS_LIMIT is hit for %s.', self) + logger.info('%s is hit for %s.', order_type.upper(), self) self.close(order['average']) else: raise ValueError(f'Unknown order type: {order_type}') diff --git a/freqtrade/plot/plotting.py b/freqtrade/plot/plotting.py index 5301d762d..4a892792a 100644 --- a/freqtrade/plot/plotting.py +++ b/freqtrade/plot/plotting.py @@ -3,11 +3,14 @@ from pathlib import Path from typing import Any, Dict, List import pandas as pd + from freqtrade.configuration import TimeRange -from freqtrade.data import history from freqtrade.data.btanalysis import (combine_tickers_with_mean, create_cum_profit, extract_trades_of_period, load_trades) +from freqtrade.data.converter import trim_dataframe +from freqtrade.data.history import load_data +from freqtrade.misc import pair_to_filename from freqtrade.resolvers import StrategyResolver logger = logging.getLogger(__name__) @@ -36,18 +39,19 @@ def init_plotscript(config): # Set timerange to use timerange = TimeRange.parse_timerange(config.get("timerange")) - tickers = history.load_data( + tickers = load_data( datadir=config.get("datadir"), pairs=pairs, timeframe=config.get('ticker_interval', '5m'), timerange=timerange, + data_format=config.get('dataformat_ohlcv', 'json'), ) trades = load_trades(config['trade_source'], db_url=config.get('db_url'), exportfilename=config.get('exportfilename'), ) - trades = history.trim_dataframe(trades, timerange, 'open_time') + trades = trim_dataframe(trades, timerange, 'open_time') return {"tickers": tickers, "trades": trades, "pairs": pairs, @@ -370,12 +374,12 @@ def generate_profit_graph(pairs: str, tickers: Dict[str, pd.DataFrame], return fig -def generate_plot_filename(pair, timeframe) -> str: +def generate_plot_filename(pair: str, timeframe: str) -> str: """ Generate filenames per pair/timeframe to be used for storing plots """ - pair_name = pair.replace("/", "_") - file_name = 'freqtrade-plot-' + pair_name + '-' + timeframe + '.html' + pair_s = pair_to_filename(pair) + file_name = 'freqtrade-plot-' + pair_s + '-' + timeframe + '.html' logger.info('Generate plot file for %s', pair) diff --git a/freqtrade/resolvers/iresolver.py b/freqtrade/resolvers/iresolver.py index 5a844097c..52d944f2c 100644 --- a/freqtrade/resolvers/iresolver.py +++ b/freqtrade/resolvers/iresolver.py @@ -7,7 +7,7 @@ import importlib.util import inspect import logging from pathlib import Path -from typing import Any, Dict, Generator, List, Optional, Tuple, Type, Union +from typing import Any, Dict, Iterator, List, Optional, Tuple, Type, Union from freqtrade.exceptions import OperationalException @@ -22,13 +22,15 @@ class IResolver: object_type: Type[Any] object_type_str: str user_subdir: Optional[str] = None - initial_search_path: Path + initial_search_path: Optional[Path] @classmethod - def build_search_paths(cls, config, user_subdir: Optional[str] = None, + def build_search_paths(cls, config: Dict[str, Any], user_subdir: Optional[str] = None, extra_dir: Optional[str] = None) -> List[Path]: - abs_paths: List[Path] = [cls.initial_search_path] + abs_paths: List[Path] = [] + if cls.initial_search_path: + abs_paths.append(cls.initial_search_path) if user_subdir: abs_paths.insert(0, config['user_data_dir'].joinpath(user_subdir)) @@ -40,12 +42,14 @@ class IResolver: return abs_paths @classmethod - def _get_valid_object(cls, module_path: Path, - object_name: Optional[str]) -> Generator[Any, None, None]: + def _get_valid_object(cls, module_path: Path, object_name: Optional[str], + enum_failed: bool = False) -> Iterator[Any]: """ Generator returning objects with matching object_type and object_name in the path given. :param module_path: absolute path to the module :param object_name: Class name of the object + :param enum_failed: If True, will return None for modules which fail. + Otherwise, failing modules are skipped. :return: generator containing matching objects """ @@ -58,10 +62,13 @@ class IResolver: except (ModuleNotFoundError, SyntaxError) as err: # Catch errors in case a specific module is not installed logger.warning(f"Could not import {module_path} due to '{err}'") + if enum_failed: + return iter([None]) valid_objects_gen = ( obj for name, obj in inspect.getmembers(module, inspect.isclass) - if (object_name is None or object_name == name) and cls.object_type in obj.__bases__ + if ((object_name is None or object_name == name) and + issubclass(obj, cls.object_type) and obj is not cls.object_type) ) return valid_objects_gen @@ -135,10 +142,13 @@ class IResolver: ) @classmethod - def search_all_objects(cls, directory: Path) -> List[Dict[str, Any]]: + def search_all_objects(cls, directory: Path, + enum_failed: bool) -> List[Dict[str, Any]]: """ Searches a directory for valid objects :param directory: Path to search + :param enum_failed: If True, will return None for modules which fail. + Otherwise, failing modules are skipped. :return: List of dicts containing 'name', 'class' and 'location' entires """ logger.debug(f"Searching for {cls.object_type.__name__} '{directory}'") @@ -150,9 +160,10 @@ class IResolver: continue module_path = entry.resolve() logger.debug(f"Path {module_path}") - for obj in cls._get_valid_object(module_path, object_name=None): + for obj in cls._get_valid_object(module_path, object_name=None, + enum_failed=enum_failed): objects.append( - {'name': obj.__name__, + {'name': obj.__name__ if obj is not None else '', 'class': obj, 'location': entry, }) diff --git a/freqtrade/resolvers/strategy_resolver.py b/freqtrade/resolvers/strategy_resolver.py index 9e64f38df..cddc7c9cd 100644 --- a/freqtrade/resolvers/strategy_resolver.py +++ b/freqtrade/resolvers/strategy_resolver.py @@ -9,10 +9,10 @@ from base64 import urlsafe_b64decode from collections import OrderedDict from inspect import getfullargspec from pathlib import Path -from typing import Dict, Optional +from typing import Any, Dict, Optional from freqtrade.constants import (REQUIRED_ORDERTIF, REQUIRED_ORDERTYPES, - USERPATH_STRATEGY) + USERPATH_STRATEGIES) from freqtrade.exceptions import OperationalException from freqtrade.resolvers import IResolver from freqtrade.strategy.interface import IStrategy @@ -26,11 +26,11 @@ class StrategyResolver(IResolver): """ object_type = IStrategy object_type_str = "Strategy" - user_subdir = USERPATH_STRATEGY - initial_search_path = Path(__file__).parent.parent.joinpath('strategy').resolve() + user_subdir = USERPATH_STRATEGIES + initial_search_path = None @staticmethod - def load_strategy(config: Optional[Dict] = None) -> IStrategy: + def load_strategy(config: Dict[str, Any] = None) -> IStrategy: """ Load the custom class from config parameter :param config: configuration dictionary or None @@ -96,7 +96,8 @@ class StrategyResolver(IResolver): return strategy @staticmethod - def _override_attribute_helper(strategy, config, attribute: str, default): + def _override_attribute_helper(strategy, config: Dict[str, Any], + attribute: str, default: Any): """ Override attributes in the strategy. Prevalence: @@ -140,7 +141,7 @@ class StrategyResolver(IResolver): """ abs_paths = StrategyResolver.build_search_paths(config, - user_subdir=USERPATH_STRATEGY, + user_subdir=USERPATH_STRATEGIES, extra_dir=extra_dir) if ":" in strategy_name: diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 41097c211..3411318bb 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -26,7 +26,9 @@ class RPCMessageType(Enum): WARNING_NOTIFICATION = 'warning' CUSTOM_NOTIFICATION = 'custom' BUY_NOTIFICATION = 'buy' + BUY_CANCEL_NOTIFICATION = 'buy_cancel' SELL_NOTIFICATION = 'sell' + SELL_CANCEL_NOTIFICATION = 'sell_cancel' def __repr__(self): return self.value @@ -39,6 +41,7 @@ class RPCException(Exception): raise RPCException('*Status:* `no active trade`') """ + def __init__(self, message: str) -> None: super().__init__(self) self.message = message @@ -139,7 +142,8 @@ class RPC: results.append(trade_dict) return results - def _rpc_status_table(self, stake_currency, fiat_display_currency: str) -> Tuple[List, List]: + def _rpc_status_table(self, stake_currency: str, + fiat_display_currency: str) -> Tuple[List, List]: trades = Trade.get_open_trades() if not trades: raise RPCException('no active trade') @@ -156,15 +160,17 @@ class RPC: profit_str = f'{trade_perc:.2f}%' if self._fiat_converter: fiat_profit = self._fiat_converter.convert_amount( - trade_profit, - stake_currency, - fiat_display_currency - ) + trade_profit, + stake_currency, + fiat_display_currency + ) if fiat_profit and not isnan(fiat_profit): profit_str += f" ({fiat_profit:.2f})" trades_list.append([ trade.id, - trade.pair, + trade.pair + ('*' if (trade.open_order_id is not None + and trade.close_rate_requested is None) else '') + + ('**' if (trade.close_rate_requested is not None) else ''), shorten_date(arrow.get(trade.open_date).humanize(only_distance=True)), profit_str ]) @@ -385,7 +391,7 @@ class RPC: return {'status': 'No more buy will occur from now. Run /reload_conf to reset.'} - def _rpc_forcesell(self, trade_id) -> Dict[str, str]: + def _rpc_forcesell(self, trade_id: str) -> Dict[str, str]: """ Handler for forcesell . Sells the given trade at current price diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index f687fe4d1..670275991 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -61,7 +61,7 @@ class RPCManager: except NotImplementedError: logger.error(f"Message type {msg['type']} not implemented by handler {mod.name}.") - def startup_messages(self, config, pairlist) -> None: + def startup_messages(self, config: Dict[str, Any], pairlist) -> None: if config['dry_run']: self.send_msg({ 'type': RPCMessageType.WARNING_NOTIFICATION, diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index e9ecdcff6..e3958b31a 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -134,13 +134,18 @@ class Telegram(RPC): msg['stake_amount_fiat'] = 0 message = ("*{exchange}:* Buying {pair}\n" - "at rate `{limit:.8f}\n" - "({stake_amount:.6f} {stake_currency}").format(**msg) + "*Amount:* `{amount:.8f}`\n" + "*Open Rate:* `{limit:.8f}`\n" + "*Current Rate:* `{current_rate:.8f}`\n" + "*Total:* `({stake_amount:.6f} {stake_currency}").format(**msg) if msg.get('fiat_currency', None): - message += ",{stake_amount_fiat:.3f} {fiat_currency}".format(**msg) + message += ", {stake_amount_fiat:.3f} {fiat_currency}".format(**msg) message += ")`" + elif msg['type'] == RPCMessageType.BUY_CANCEL_NOTIFICATION: + message = "*{exchange}:* Cancelling Open Buy Order for {pair}".format(**msg) + elif msg['type'] == RPCMessageType.SELL_NOTIFICATION: msg['amount'] = round(msg['amount'], 8) msg['profit_percent'] = round(msg['profit_percent'] * 100, 2) @@ -149,10 +154,10 @@ class Telegram(RPC): msg['duration_min'] = msg['duration'].total_seconds() / 60 message = ("*{exchange}:* Selling {pair}\n" - "*Rate:* `{limit:.8f}`\n" "*Amount:* `{amount:.8f}`\n" "*Open Rate:* `{open_rate:.8f}`\n" "*Current Rate:* `{current_rate:.8f}`\n" + "*Close Rate:* `{limit:.8f}`\n" "*Sell Reason:* `{sell_reason}`\n" "*Duration:* `{duration} ({duration_min:.1f} min)`\n" "*Profit:* `{profit_percent:.2f}%`").format(**msg) @@ -163,8 +168,11 @@ class Telegram(RPC): and self._fiat_converter): msg['profit_fiat'] = self._fiat_converter.convert_amount( msg['profit_amount'], msg['stake_currency'], msg['fiat_currency']) - message += ('` ({gain}: {profit_amount:.8f} {stake_currency}`' - '` / {profit_fiat:.3f} {fiat_currency})`').format(**msg) + message += (' `({gain}: {profit_amount:.8f} {stake_currency}' + ' / {profit_fiat:.3f} {fiat_currency})`').format(**msg) + + elif msg['type'] == RPCMessageType.SELL_CANCEL_NOTIFICATION: + message = "*{exchange}:* Cancelling Open Sell Order for {pair}".format(**msg) elif msg['type'] == RPCMessageType.STATUS_NOTIFICATION: message = '*Status:* `{status}`'.format(**msg) @@ -553,6 +561,8 @@ class Telegram(RPC): "*/stop:* `Stops the trader`\n" \ "*/status [table]:* `Lists all open trades`\n" \ " *table :* `will display trades in a table`\n" \ + " `pending buy orders are marked with an asterisk (*)`\n" \ + " `pending sell orders are marked with a double asterisk (**)`\n" \ "*/profit:* `Lists cumulative profit from all finished trades`\n" \ "*/forcesell |all:* `Instantly sells the given trade or all trades, " \ "regardless of profit`\n" \ diff --git a/freqtrade/rpc/webhook.py b/freqtrade/rpc/webhook.py index 37ca466de..1309663d4 100644 --- a/freqtrade/rpc/webhook.py +++ b/freqtrade/rpc/webhook.py @@ -41,8 +41,12 @@ class Webhook(RPC): if msg['type'] == RPCMessageType.BUY_NOTIFICATION: valuedict = self._config['webhook'].get('webhookbuy', None) + elif msg['type'] == RPCMessageType.BUY_CANCEL_NOTIFICATION: + valuedict = self._config['webhook'].get('webhookbuycancel', None) elif msg['type'] == RPCMessageType.SELL_NOTIFICATION: 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): diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 27bc8280e..ae3dbd307 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -180,7 +180,7 @@ class IStrategy(ABC): if pair not in self._pair_locked_until or self._pair_locked_until[pair] < until: self._pair_locked_until[pair] = until - def unlock_pair(self, pair) -> None: + def unlock_pair(self, pair: str) -> None: """ Unlocks a pair previously locked using lock_pair. Not used by freqtrade itself, but intended to be used if users lock pairs @@ -439,7 +439,7 @@ class IStrategy(ABC): else: return current_profit > roi - def tickerdata_to_dataframe(self, tickerdata: Dict[str, List]) -> Dict[str, DataFrame]: + def tickerdata_to_dataframe(self, tickerdata: Dict[str, DataFrame]) -> Dict[str, DataFrame]: """ Creates a dataframe and populates indicators for given ticker data Used by optimize operations only, not during dry / live runs. diff --git a/freqtrade/templates/base_config.json.j2 b/freqtrade/templates/base_config.json.j2 new file mode 100644 index 000000000..88edeb1e8 --- /dev/null +++ b/freqtrade/templates/base_config.json.j2 @@ -0,0 +1,58 @@ +{ + "max_open_trades": {{ max_open_trades }}, + "stake_currency": "{{ stake_currency }}", + "stake_amount": {{ stake_amount }}, + "tradable_balance_ratio": 0.99, + "fiat_display_currency": "{{ fiat_display_currency }}", + "ticker_interval": "{{ ticker_interval }}", + "dry_run": {{ dry_run | lower }}, + "unfilledtimeout": { + "buy": 10, + "sell": 30 + }, + "bid_strategy": { + "ask_last_balance": 0.0, + "use_order_book": false, + "order_book_top": 1, + "check_depth_of_market": { + "enabled": false, + "bids_to_ask_delta": 1 + } + }, + "ask_strategy": { + "use_order_book": false, + "order_book_min": 1, + "order_book_max": 9, + "use_sell_signal": true, + "sell_profit_only": false, + "ignore_roi_if_buy_signal": false + }, + {{ exchange | indent(4) }}, + "pairlists": [ + {"method": "StaticPairList"} + ], + "edge": { + "enabled": false, + "process_throttle_secs": 3600, + "calculate_since_number_of_days": 7, + "allowed_risk": 0.01, + "stoploss_range_min": -0.01, + "stoploss_range_max": -0.1, + "stoploss_range_step": -0.01, + "minimum_winrate": 0.60, + "minimum_expectancy": 0.20, + "min_trade_number": 10, + "max_trade_duration_minute": 1440, + "remove_pumps": false + }, + "telegram": { + "enabled": {{ telegram | lower }}, + "token": "{{ telegram_token }}", + "chat_id": "{{ telegram_chat_id }}" + }, + "initial_state": "running", + "forcebuy_enable": false, + "internals": { + "process_throttle_secs": 5 + } +} diff --git a/freqtrade/templates/sample_hyperopt_advanced.py b/freqtrade/templates/sample_hyperopt_advanced.py index b4bbee3fb..e66ef948b 100644 --- a/freqtrade/templates/sample_hyperopt_advanced.py +++ b/freqtrade/templates/sample_hyperopt_advanced.py @@ -230,7 +230,7 @@ class AdvancedSampleHyperOpt(IHyperOpt): 'stoploss' optimization hyperspace. """ return [ - Real(-0.5, -0.02, name='stoploss'), + Real(-0.35, -0.02, name='stoploss'), ] @staticmethod @@ -249,8 +249,15 @@ class AdvancedSampleHyperOpt(IHyperOpt): # other 'trailing' hyperspace parameters. Categorical([True], name='trailing_stop'), - Real(0.02, 0.35, name='trailing_stop_positive'), - Real(0.01, 0.1, name='trailing_stop_positive_offset'), + Real(0.01, 0.35, name='trailing_stop_positive'), + + # 'trailing_stop_positive_offset' should be greater than 'trailing_stop_positive', + # so this intermediate parameter is used as the value of the difference between + # them. The value of the 'trailing_stop_positive_offset' is constructed in the + # generate_trailing_params() method. + # This is similar to the hyperspace dimensions used for constructing the ROI tables. + Real(0.001, 0.1, name='trailing_stop_positive_offset_p1'), + Categorical([True, False], name='trailing_only_offset_is_reached'), ] diff --git a/freqtrade/templates/sample_strategy.py b/freqtrade/templates/sample_strategy.py index 92f6aefba..17372e1e0 100644 --- a/freqtrade/templates/sample_strategy.py +++ b/freqtrade/templates/sample_strategy.py @@ -124,24 +124,70 @@ class SampleStrategy(IStrategy): # Momentum Indicators # ------------------------------------ - # RSI - dataframe['rsi'] = ta.RSI(dataframe) - # ADX dataframe['adx'] = ta.ADX(dataframe) + # # Plus Directional Indicator / Movement + # dataframe['plus_dm'] = ta.PLUS_DM(dataframe) + # dataframe['plus_di'] = ta.PLUS_DI(dataframe) + + # # Minus Directional Indicator / Movement + # dataframe['minus_dm'] = ta.MINUS_DM(dataframe) + # dataframe['minus_di'] = ta.MINUS_DI(dataframe) + # # Aroon, Aroon Oscillator # aroon = ta.AROON(dataframe) # dataframe['aroonup'] = aroon['aroonup'] # dataframe['aroondown'] = aroon['aroondown'] # dataframe['aroonosc'] = ta.AROONOSC(dataframe) - # # Awesome oscillator + # # Awesome Oscillator # dataframe['ao'] = qtpylib.awesome_oscillator(dataframe) - # # Commodity Channel Index: values Oversold:<-100, Overbought:>100 + # # Keltner Channel + # keltner = qtpylib.keltner_channel(dataframe) + # dataframe["kc_upperband"] = keltner["upper"] + # dataframe["kc_lowerband"] = keltner["lower"] + # dataframe["kc_middleband"] = keltner["mid"] + # dataframe["kc_percent"] = ( + # (dataframe["close"] - dataframe["kc_lowerband"]) / + # (dataframe["kc_upperband"] - dataframe["kc_lowerband"]) + # ) + # dataframe["kc_width"] = ( + # (dataframe["kc_upperband"] - dataframe["kc_lowerband"]) / dataframe["kc_middleband"] + # ) + + # # Ultimate Oscillator + # dataframe['uo'] = ta.ULTOSC(dataframe) + + # # Commodity Channel Index: values [Oversold:-100, Overbought:100] # dataframe['cci'] = ta.CCI(dataframe) + # RSI + dataframe['rsi'] = ta.RSI(dataframe) + + # # Inverse Fisher transform on RSI: values [-1.0, 1.0] (https://goo.gl/2JGGoy) + # rsi = 0.1 * (dataframe['rsi'] - 50) + # dataframe['fisher_rsi'] = (np.exp(2 * rsi) - 1) / (np.exp(2 * rsi) + 1) + + # # Inverse Fisher transform on RSI normalized: values [0.0, 100.0] (https://goo.gl/2JGGoy) + # dataframe['fisher_rsi_norma'] = 50 * (dataframe['fisher_rsi'] + 1) + + # # Stochastic Slow + # stoch = ta.STOCH(dataframe) + # dataframe['slowd'] = stoch['slowd'] + # dataframe['slowk'] = stoch['slowk'] + + # Stochastic Fast + stoch_fast = ta.STOCHF(dataframe) + dataframe['fastd'] = stoch_fast['fastd'] + dataframe['fastk'] = stoch_fast['fastk'] + + # # Stochastic RSI + # stoch_rsi = ta.STOCHRSI(dataframe) + # dataframe['fastd_rsi'] = stoch_rsi['fastd'] + # dataframe['fastk_rsi'] = stoch_rsi['fastk'] + # MACD macd = ta.MACD(dataframe) dataframe['macd'] = macd['macd'] @@ -151,60 +197,58 @@ class SampleStrategy(IStrategy): # MFI dataframe['mfi'] = ta.MFI(dataframe) - # # Minus Directional Indicator / Movement - # dataframe['minus_dm'] = ta.MINUS_DM(dataframe) - # dataframe['minus_di'] = ta.MINUS_DI(dataframe) - - # # Plus Directional Indicator / Movement - # dataframe['plus_dm'] = ta.PLUS_DM(dataframe) - # dataframe['plus_di'] = ta.PLUS_DI(dataframe) - # dataframe['minus_di'] = ta.MINUS_DI(dataframe) - # # ROC # dataframe['roc'] = ta.ROC(dataframe) - # # Inverse Fisher transform on RSI, values [-1.0, 1.0] (https://goo.gl/2JGGoy) - # rsi = 0.1 * (dataframe['rsi'] - 50) - # dataframe['fisher_rsi'] = (np.exp(2 * rsi) - 1) / (np.exp(2 * rsi) + 1) - - # # Inverse Fisher transform on RSI normalized, value [0.0, 100.0] (https://goo.gl/2JGGoy) - # dataframe['fisher_rsi_norma'] = 50 * (dataframe['fisher_rsi'] + 1) - - # # Stoch - # stoch = ta.STOCH(dataframe) - # dataframe['slowd'] = stoch['slowd'] - # dataframe['slowk'] = stoch['slowk'] - - # Stoch fast - stoch_fast = ta.STOCHF(dataframe) - dataframe['fastd'] = stoch_fast['fastd'] - dataframe['fastk'] = stoch_fast['fastk'] - - # # Stoch RSI - # stoch_rsi = ta.STOCHRSI(dataframe) - # dataframe['fastd_rsi'] = stoch_rsi['fastd'] - # dataframe['fastk_rsi'] = stoch_rsi['fastk'] - # Overlap Studies # ------------------------------------ - # Bollinger bands + # Bollinger Bands bollinger = qtpylib.bollinger_bands(qtpylib.typical_price(dataframe), window=20, stds=2) dataframe['bb_lowerband'] = bollinger['lower'] dataframe['bb_middleband'] = bollinger['mid'] dataframe['bb_upperband'] = bollinger['upper'] + dataframe["bb_percent"] = ( + (dataframe["close"] - dataframe["bb_lowerband"]) / + (dataframe["bb_upperband"] - dataframe["bb_lowerband"]) + ) + dataframe["bb_width"] = ( + (dataframe["bb_upperband"] - dataframe["bb_lowerband"]) / dataframe["bb_middleband"] + ) + + # Bollinger Bands - Weighted (EMA based instead of SMA) + # weighted_bollinger = qtpylib.weighted_bollinger_bands( + # qtpylib.typical_price(dataframe), window=20, stds=2 + # ) + # dataframe["wbb_upperband"] = weighted_bollinger["upper"] + # dataframe["wbb_lowerband"] = weighted_bollinger["lower"] + # dataframe["wbb_middleband"] = weighted_bollinger["mid"] + # dataframe["wbb_percent"] = ( + # (dataframe["close"] - dataframe["wbb_lowerband"]) / + # (dataframe["wbb_upperband"] - dataframe["wbb_lowerband"]) + # ) + # dataframe["wbb_width"] = ( + # (dataframe["wbb_upperband"] - dataframe["wbb_lowerband"]) / + # dataframe["wbb_middleband"] + # ) # # EMA - Exponential Moving Average # dataframe['ema3'] = ta.EMA(dataframe, timeperiod=3) # dataframe['ema5'] = ta.EMA(dataframe, timeperiod=5) # dataframe['ema10'] = ta.EMA(dataframe, timeperiod=10) + # dataframe['ema21'] = ta.EMA(dataframe, timeperiod=21) # dataframe['ema50'] = ta.EMA(dataframe, timeperiod=50) # dataframe['ema100'] = ta.EMA(dataframe, timeperiod=100) # # SMA - Simple Moving Average - # dataframe['sma'] = ta.SMA(dataframe, timeperiod=40) + # dataframe['sma3'] = ta.SMA(dataframe, timeperiod=3) + # dataframe['sma5'] = ta.SMA(dataframe, timeperiod=5) + # dataframe['sma10'] = ta.SMA(dataframe, timeperiod=10) + # dataframe['sma21'] = ta.SMA(dataframe, timeperiod=21) + # dataframe['sma50'] = ta.SMA(dataframe, timeperiod=50) + # dataframe['sma100'] = ta.SMA(dataframe, timeperiod=100) - # SAR Parabol + # Parabolic SAR dataframe['sar'] = ta.SAR(dataframe) # TEMA - Triple Exponential Moving Average @@ -264,7 +308,7 @@ class SampleStrategy(IStrategy): # # Chart type # # ------------------------------------ - # # Heikinashi stategy + # # Heikin Ashi Strategy # heikinashi = qtpylib.heikinashi(dataframe) # dataframe['ha_open'] = heikinashi['open'] # dataframe['ha_close'] = heikinashi['close'] diff --git a/freqtrade/templates/strategy_analysis_example.ipynb b/freqtrade/templates/strategy_analysis_example.ipynb index eea8fb85f..399235cfe 100644 --- a/freqtrade/templates/strategy_analysis_example.ipynb +++ b/freqtrade/templates/strategy_analysis_example.ipynb @@ -6,7 +6,8 @@ "source": [ "# Strategy analysis example\n", "\n", - "Debugging a strategy can be time-consuming. FreqTrade offers helper functions to visualize raw data." + "Debugging a strategy can be time-consuming. Freqtrade offers helper functions to visualize raw data.\n", + "The following assumes you work with SampleStrategy, data for 5m timeframe from Binance and have downloaded them into the data directory in the default location." ] }, { @@ -23,18 +24,21 @@ "outputs": [], "source": [ "from pathlib import Path\n", + "from freqtrade.configuration import Configuration\n", + "\n", "# Customize these according to your needs.\n", "\n", + "# Initialize empty configuration object\n", + "config = Configuration.from_files([])\n", + "# Optionally, use existing configuration file\n", + "# config = Configuration.from_files([\"config.json\"])\n", + "\n", "# Define some constants\n", - "timeframe = \"5m\"\n", + "config[\"ticker_interval\"] = \"5m\"\n", "# Name of the strategy class\n", - "strategy_name = 'SampleStrategy'\n", - "# Path to user data\n", - "user_data_dir = Path('user_data')\n", - "# Location of the strategy\n", - "strategy_location = user_data_dir / 'strategies'\n", + "config[\"strategy\"] = \"SampleStrategy\"\n", "# Location of the data\n", - "data_location = Path(user_data_dir, 'data', 'binance')\n", + "data_location = Path(config['user_data_dir'], 'data', 'binance')\n", "# Pair to analyze - Only use one pair here\n", "pair = \"BTC_USDT\"" ] @@ -49,7 +53,7 @@ "from freqtrade.data.history import load_pair_history\n", "\n", "candles = load_pair_history(datadir=data_location,\n", - " timeframe=timeframe,\n", + " timeframe=config[\"ticker_interval\"],\n", " pair=pair)\n", "\n", "# Confirm success\n", @@ -73,9 +77,7 @@ "source": [ "# Load strategy using values set above\n", "from freqtrade.resolvers import StrategyResolver\n", - "strategy = StrategyResolver.load_strategy({'strategy': strategy_name,\n", - " 'user_data_dir': user_data_dir,\n", - " 'strategy_path': strategy_location})\n", + "strategy = StrategyResolver.load_strategy(config)\n", "\n", "# Generate buy/sell signals using strategy\n", "df = strategy.analyze_ticker(candles, {'pair': pair})\n", @@ -137,7 +139,7 @@ "from freqtrade.data.btanalysis import load_backtest_data\n", "\n", "# Load backtest results\n", - "trades = load_backtest_data(user_data_dir / \"backtest_results/backtest-result.json\")\n", + "trades = load_backtest_data(config[\"user_data_dir\"] / \"backtest_results/backtest-result.json\")\n", "\n", "# Show value-counts per pair\n", "trades.groupby(\"pair\")[\"sell_reason\"].value_counts()" diff --git a/freqtrade/templates/subtemplates/exchange_binance.j2 b/freqtrade/templates/subtemplates/exchange_binance.j2 new file mode 100644 index 000000000..03aa0560c --- /dev/null +++ b/freqtrade/templates/subtemplates/exchange_binance.j2 @@ -0,0 +1,41 @@ +"exchange": { + "name": "{{ exchange_name | lower }}", + "key": "{{ exchange_key }}", + "secret": "{{ exchange_secret }}", + "ccxt_config": {"enableRateLimit": true}, + "ccxt_async_config": { + "enableRateLimit": true, + "rateLimit": 200 + }, + "pair_whitelist": [ + "ALGO/BTC", + "ATOM/BTC", + "BAT/BTC", + "BCH/BTC", + "BRD/BTC", + "EOS/BTC", + "ETH/BTC", + "IOTA/BTC", + "LINK/BTC", + "LTC/BTC", + "NEO/BTC", + "NXS/BTC", + "XMR/BTC", + "XRP/BTC", + "XTZ/BTC" + ], + "pair_blacklist": [ + "BNB/BTC", + "BNB/BUSD", + "BNB/ETH", + "BNB/EUR", + "BNB/NGN", + "BNB/PAX", + "BNB/RUB", + "BNB/TRY", + "BNB/TUSD", + "BNB/USDC", + "BNB/USDS", + "BNB/USDT", + ] +} diff --git a/freqtrade/templates/subtemplates/exchange_bittrex.j2 b/freqtrade/templates/subtemplates/exchange_bittrex.j2 new file mode 100644 index 000000000..7b27318ca --- /dev/null +++ b/freqtrade/templates/subtemplates/exchange_bittrex.j2 @@ -0,0 +1,31 @@ +"order_types": { + "buy": "limit", + "sell": "limit", + "emergencysell": "limit", + "stoploss": "limit", + "stoploss_on_exchange": false +}, +"exchange": { + "name": "{{ exchange_name | lower }}", + "key": "{{ exchange_key }}", + "secret": "{{ exchange_secret }}", + "ccxt_config": {"enableRateLimit": true}, + "ccxt_async_config": { + "enableRateLimit": true, + "rateLimit": 500 + }, + "pair_whitelist": [ + "ETH/BTC", + "LTC/BTC", + "ETC/BTC", + "DASH/BTC", + "ZEC/BTC", + "XLM/BTC", + "XRP/BTC", + "TRX/BTC", + "ADA/BTC", + "XMR/BTC" + ], + "pair_blacklist": [ + ] +} diff --git a/freqtrade/templates/subtemplates/exchange_generic.j2 b/freqtrade/templates/subtemplates/exchange_generic.j2 new file mode 100644 index 000000000..ade9c2f28 --- /dev/null +++ b/freqtrade/templates/subtemplates/exchange_generic.j2 @@ -0,0 +1,15 @@ +"exchange": { + "name": "{{ exchange_name | lower }}", + "key": "{{ exchange_key }}", + "secret": "{{ exchange_secret }}", + "ccxt_config": {"enableRateLimit": true}, + "ccxt_async_config": { + "enableRateLimit": true + }, + "pair_whitelist": [ + + ], + "pair_blacklist": [ + + ] +} diff --git a/freqtrade/templates/subtemplates/exchange_kraken.j2 b/freqtrade/templates/subtemplates/exchange_kraken.j2 new file mode 100644 index 000000000..7139a0830 --- /dev/null +++ b/freqtrade/templates/subtemplates/exchange_kraken.j2 @@ -0,0 +1,36 @@ +"download_trades": true, +"exchange": { + "name": "kraken", + "key": "{{ exchange_key }}", + "secret": "{{ exchange_secret }}", + "ccxt_config": {"enableRateLimit": true}, + "ccxt_async_config": { + "enableRateLimit": true, + "rateLimit": 1000 + }, + "pair_whitelist": [ + "ADA/EUR", + "ATOM/EUR", + "BAT/EUR", + "BCH/EUR", + "BTC/EUR", + "DAI/EUR", + "DASH/EUR", + "EOS/EUR", + "ETC/EUR", + "ETH/EUR", + "LINK/EUR", + "LTC/EUR", + "QTUM/EUR", + "REP/EUR", + "WAVES/EUR", + "XLM/EUR", + "XMR/EUR", + "XRP/EUR", + "XTZ/EUR", + "ZEC/EUR" + ], + "pair_blacklist": [ + + ] +} diff --git a/freqtrade/templates/subtemplates/indicators_full.j2 b/freqtrade/templates/subtemplates/indicators_full.j2 index 879a2daa0..60a358bec 100644 --- a/freqtrade/templates/subtemplates/indicators_full.j2 +++ b/freqtrade/templates/subtemplates/indicators_full.j2 @@ -2,24 +2,70 @@ # Momentum Indicators # ------------------------------------ -# RSI -dataframe['rsi'] = ta.RSI(dataframe) - # ADX dataframe['adx'] = ta.ADX(dataframe) +# # Plus Directional Indicator / Movement +# dataframe['plus_dm'] = ta.PLUS_DM(dataframe) +# dataframe['plus_di'] = ta.PLUS_DI(dataframe) + +# # Minus Directional Indicator / Movement +# dataframe['minus_dm'] = ta.MINUS_DM(dataframe) +# dataframe['minus_di'] = ta.MINUS_DI(dataframe) + # # Aroon, Aroon Oscillator # aroon = ta.AROON(dataframe) # dataframe['aroonup'] = aroon['aroonup'] # dataframe['aroondown'] = aroon['aroondown'] # dataframe['aroonosc'] = ta.AROONOSC(dataframe) -# # Awesome oscillator +# # Awesome Oscillator # dataframe['ao'] = qtpylib.awesome_oscillator(dataframe) -# # Commodity Channel Index: values Oversold:<-100, Overbought:>100 +# # Keltner Channel +# keltner = qtpylib.keltner_channel(dataframe) +# dataframe["kc_upperband"] = keltner["upper"] +# dataframe["kc_lowerband"] = keltner["lower"] +# dataframe["kc_middleband"] = keltner["mid"] +# dataframe["kc_percent"] = ( +# (dataframe["close"] - dataframe["kc_lowerband"]) / +# (dataframe["kc_upperband"] - dataframe["kc_lowerband"]) +# ) +# dataframe["kc_width"] = ( +# (dataframe["kc_upperband"] - dataframe["kc_lowerband"]) / dataframe["kc_middleband"] +# ) + +# # Ultimate Oscillator +# dataframe['uo'] = ta.ULTOSC(dataframe) + +# # Commodity Channel Index: values [Oversold:-100, Overbought:100] # dataframe['cci'] = ta.CCI(dataframe) +# RSI +dataframe['rsi'] = ta.RSI(dataframe) + +# # Inverse Fisher transform on RSI: values [-1.0, 1.0] (https://goo.gl/2JGGoy) +# rsi = 0.1 * (dataframe['rsi'] - 50) +# dataframe['fisher_rsi'] = (np.exp(2 * rsi) - 1) / (np.exp(2 * rsi) + 1) + +# # Inverse Fisher transform on RSI normalized: values [0.0, 100.0] (https://goo.gl/2JGGoy) +# dataframe['fisher_rsi_norma'] = 50 * (dataframe['fisher_rsi'] + 1) + +# # Stochastic Slow +# stoch = ta.STOCH(dataframe) +# dataframe['slowd'] = stoch['slowd'] +# dataframe['slowk'] = stoch['slowk'] + +# Stochastic Fast +stoch_fast = ta.STOCHF(dataframe) +dataframe['fastd'] = stoch_fast['fastd'] +dataframe['fastk'] = stoch_fast['fastk'] + +# # Stochastic RSI +# stoch_rsi = ta.STOCHRSI(dataframe) +# dataframe['fastd_rsi'] = stoch_rsi['fastd'] +# dataframe['fastk_rsi'] = stoch_rsi['fastk'] + # MACD macd = ta.MACD(dataframe) dataframe['macd'] = macd['macd'] @@ -29,60 +75,57 @@ dataframe['macdhist'] = macd['macdhist'] # MFI dataframe['mfi'] = ta.MFI(dataframe) -# # Minus Directional Indicator / Movement -# dataframe['minus_dm'] = ta.MINUS_DM(dataframe) -# dataframe['minus_di'] = ta.MINUS_DI(dataframe) - -# # Plus Directional Indicator / Movement -# dataframe['plus_dm'] = ta.PLUS_DM(dataframe) -# dataframe['plus_di'] = ta.PLUS_DI(dataframe) -# dataframe['minus_di'] = ta.MINUS_DI(dataframe) - # # ROC # dataframe['roc'] = ta.ROC(dataframe) -# # Inverse Fisher transform on RSI, values [-1.0, 1.0] (https://goo.gl/2JGGoy) -# rsi = 0.1 * (dataframe['rsi'] - 50) -# dataframe['fisher_rsi'] = (np.exp(2 * rsi) - 1) / (np.exp(2 * rsi) + 1) - -# # Inverse Fisher transform on RSI normalized, value [0.0, 100.0] (https://goo.gl/2JGGoy) -# dataframe['fisher_rsi_norma'] = 50 * (dataframe['fisher_rsi'] + 1) - -# # Stoch -# stoch = ta.STOCH(dataframe) -# dataframe['slowd'] = stoch['slowd'] -# dataframe['slowk'] = stoch['slowk'] - -# Stoch fast -stoch_fast = ta.STOCHF(dataframe) -dataframe['fastd'] = stoch_fast['fastd'] -dataframe['fastk'] = stoch_fast['fastk'] - -# # Stoch RSI -# stoch_rsi = ta.STOCHRSI(dataframe) -# dataframe['fastd_rsi'] = stoch_rsi['fastd'] -# dataframe['fastk_rsi'] = stoch_rsi['fastk'] - # Overlap Studies # ------------------------------------ -# Bollinger bands +# Bollinger Bands bollinger = qtpylib.bollinger_bands(qtpylib.typical_price(dataframe), window=20, stds=2) dataframe['bb_lowerband'] = bollinger['lower'] dataframe['bb_middleband'] = bollinger['mid'] dataframe['bb_upperband'] = bollinger['upper'] +dataframe["bb_percent"] = ( + (dataframe["close"] - dataframe["bb_lowerband"]) / + (dataframe["bb_upperband"] - dataframe["bb_lowerband"]) +) +dataframe["bb_width"] = ( + (dataframe["bb_upperband"] - dataframe["bb_lowerband"]) / dataframe["bb_middleband"] +) + +# Bollinger Bands - Weighted (EMA based instead of SMA) +# weighted_bollinger = qtpylib.weighted_bollinger_bands( +# qtpylib.typical_price(dataframe), window=20, stds=2 +# ) +# dataframe["wbb_upperband"] = weighted_bollinger["upper"] +# dataframe["wbb_lowerband"] = weighted_bollinger["lower"] +# dataframe["wbb_middleband"] = weighted_bollinger["mid"] +# dataframe["wbb_percent"] = ( +# (dataframe["close"] - dataframe["wbb_lowerband"]) / +# (dataframe["wbb_upperband"] - dataframe["wbb_lowerband"]) +# ) +# dataframe["wbb_width"] = ( +# (dataframe["wbb_upperband"] - dataframe["wbb_lowerband"]) / dataframe["wbb_middleband"] +# ) # # EMA - Exponential Moving Average # dataframe['ema3'] = ta.EMA(dataframe, timeperiod=3) # dataframe['ema5'] = ta.EMA(dataframe, timeperiod=5) # dataframe['ema10'] = ta.EMA(dataframe, timeperiod=10) +# dataframe['ema21'] = ta.EMA(dataframe, timeperiod=21) # dataframe['ema50'] = ta.EMA(dataframe, timeperiod=50) # dataframe['ema100'] = ta.EMA(dataframe, timeperiod=100) # # SMA - Simple Moving Average -# dataframe['sma'] = ta.SMA(dataframe, timeperiod=40) +# dataframe['sma3'] = ta.SMA(dataframe, timeperiod=3) +# dataframe['sma5'] = ta.SMA(dataframe, timeperiod=5) +# dataframe['sma10'] = ta.SMA(dataframe, timeperiod=10) +# dataframe['sma21'] = ta.SMA(dataframe, timeperiod=21) +# dataframe['sma50'] = ta.SMA(dataframe, timeperiod=50) +# dataframe['sma100'] = ta.SMA(dataframe, timeperiod=100) -# SAR Parabol +# Parabolic SAR dataframe['sar'] = ta.SAR(dataframe) # TEMA - Triple Exponential Moving Average @@ -142,7 +185,7 @@ dataframe['htleadsine'] = hilbert['leadsine'] # # Chart type # # ------------------------------------ -# # Heikinashi stategy +# # Heikin Ashi Strategy # heikinashi = qtpylib.heikinashi(dataframe) # dataframe['ha_open'] = heikinashi['open'] # dataframe['ha_close'] = heikinashi['close'] diff --git a/freqtrade/vendor/qtpylib/indicators.py b/freqtrade/vendor/qtpylib/indicators.py index b3b2ac533..bef140396 100644 --- a/freqtrade/vendor/qtpylib/indicators.py +++ b/freqtrade/vendor/qtpylib/indicators.py @@ -288,9 +288,9 @@ def rolling_min(series, window=14, min_periods=None): def rolling_max(series, window=14, min_periods=None): min_periods = window if min_periods is None else min_periods try: - return series.rolling(window=window, min_periods=min_periods).min() + return series.rolling(window=window, min_periods=min_periods).max() except Exception as e: # noqa: F841 - return pd.Series(series).rolling(window=window, min_periods=min_periods).min() + return pd.Series(series).rolling(window=window, min_periods=min_periods).max() # --------------------------------------------- diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index c52767162..dd5e34fe6 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -30,24 +30,21 @@ class Wallets: self._last_wallet_refresh = 0 self.update() - def get_free(self, currency) -> float: - + def get_free(self, currency: str) -> float: balance = self._wallets.get(currency) if balance and balance.free: return balance.free else: return 0 - def get_used(self, currency) -> float: - + def get_used(self, currency: str) -> float: balance = self._wallets.get(currency) if balance and balance.used: return balance.used else: return 0 - def get_total(self, currency) -> float: - + def get_total(self, currency: str) -> float: balance = self._wallets.get(currency) if balance and balance.total: return balance.total @@ -87,7 +84,6 @@ class Wallets: self._wallets = _wallets def _update_live(self) -> None: - balances = self._exchange.get_balances() for currency in balances: diff --git a/freqtrade/worker.py b/freqtrade/worker.py index 972ff0d61..4c28ecaeb 100755 --- a/freqtrade/worker.py +++ b/freqtrade/worker.py @@ -4,6 +4,7 @@ Main Freqtrade worker class. import logging import time import traceback +from os import getpid from typing import Any, Callable, Dict, Optional import sdnotify @@ -22,16 +23,19 @@ class Worker: Freqtradebot worker class """ - def __init__(self, args: Dict[str, Any], config=None) -> None: + def __init__(self, args: Dict[str, Any], config: Dict[str, Any] = None) -> None: """ Init all variables and objects the bot needs to work """ - logger.info('Starting worker %s', __version__) + logger.info(f"Starting worker {__version__}") self._args = args self._config = config self._init(False) + self.last_throttle_start_time: float = 0 + self._heartbeat_msg: float = 0 + # Tell systemd that we completed initialization phase if self._sd_notify: logger.debug("sd_notify: READY=1") @@ -48,22 +52,14 @@ class Worker: # Init the instance of the bot self.freqtrade = FreqtradeBot(self._config) - self._throttle_secs = self._config.get('internals', {}).get( - 'process_throttle_secs', - constants.PROCESS_THROTTLE_SECS - ) + internals_config = self._config.get('internals', {}) + self._throttle_secs = internals_config.get('process_throttle_secs', + constants.PROCESS_THROTTLE_SECS) + self._heartbeat_interval = internals_config.get('heartbeat_interval', 60) self._sd_notify = sdnotify.SystemdNotifier() if \ self._config.get('internals', {}).get('sd_notify', False) else None - @property - def state(self) -> State: - return self.freqtrade.state - - @state.setter - def state(self, value: State) -> None: - self.freqtrade.state = value - def run(self) -> None: state = None while True: @@ -71,31 +67,33 @@ class Worker: if state == State.RELOAD_CONF: self._reconfigure() - def _worker(self, old_state: Optional[State], throttle_secs: Optional[float] = None) -> State: + def _worker(self, old_state: Optional[State]) -> State: """ - Trading routine that must be run at each loop + The main routine that runs each throttling iteration and handles the states. :param old_state: the previous service state from the previous call :return: current service state """ state = self.freqtrade.state - if throttle_secs is None: - throttle_secs = self._throttle_secs # Log state transition if state != old_state: self.freqtrade.notify_status(f'{state.name.lower()}') - logger.info('Changing state to: %s', state.name) + logger.info(f"Changing state to: {state.name}") if state == State.RUNNING: self.freqtrade.startup() + # Reset heartbeat timestamp to log the heartbeat message at + # first throttling iteration when the state changes + self._heartbeat_msg = 0 + if state == State.STOPPED: # Ping systemd watchdog before sleeping in the stopped state if self._sd_notify: logger.debug("sd_notify: WATCHDOG=1\\nSTATUS=State: STOPPED.") self._sd_notify.notify("WATCHDOG=1\nSTATUS=State: STOPPED.") - time.sleep(throttle_secs) + self._throttle(func=self._process_stopped, throttle_secs=self._throttle_secs) elif state == State.RUNNING: # Ping systemd watchdog before throttling @@ -103,28 +101,40 @@ class Worker: logger.debug("sd_notify: WATCHDOG=1\\nSTATUS=State: RUNNING.") self._sd_notify.notify("WATCHDOG=1\nSTATUS=State: RUNNING.") - self._throttle(func=self._process, min_secs=throttle_secs) + self._throttle(func=self._process_running, throttle_secs=self._throttle_secs) + + if self._heartbeat_interval: + now = time.time() + if (now - self._heartbeat_msg) > self._heartbeat_interval: + logger.info(f"Bot heartbeat. PID={getpid()}, " + f"version='{__version__}', state='{state.name}'") + self._heartbeat_msg = now return state - def _throttle(self, func: Callable[..., Any], min_secs: float, *args, **kwargs) -> Any: + def _throttle(self, func: Callable[..., Any], throttle_secs: float, *args, **kwargs) -> Any: """ Throttles the given callable that it takes at least `min_secs` to finish execution. :param func: Any callable - :param min_secs: minimum execution time in seconds - :return: Any + :param throttle_secs: throttling interation execution time limit in seconds + :return: Any (result of execution of func) """ - start = time.time() + self.last_throttle_start_time = time.time() + logger.debug("========================================") result = func(*args, **kwargs) - end = time.time() - duration = max(min_secs - (end - start), 0.0) - logger.debug('Throttling %s for %.2f seconds', func.__name__, duration) - time.sleep(duration) + time_passed = time.time() - self.last_throttle_start_time + sleep_duration = max(throttle_secs - time_passed, 0.0) + logger.debug(f"Throttling with '{func.__name__}()': sleep for {sleep_duration:.2f} s, " + f"last iteration took {time_passed:.2f} s.") + time.sleep(sleep_duration) return result - def _process(self) -> None: - logger.debug("========================================") + def _process_stopped(self) -> None: + # Maybe do here something in the future... + pass + + def _process_running(self) -> None: try: self.freqtrade.process() except TemporaryError as error: diff --git a/mkdocs.yml b/mkdocs.yml index d53687c64..4e7e6ff75 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,8 +1,8 @@ site_name: Freqtrade nav: - - About: index.md - - Installation: installation.md + - Home: index.md - Installation Docker: docker.md + - Installation: installation.md - Configuration: configuration.md - Strategy Customization: strategy-customization.md - Stoploss: stoploss.md diff --git a/requirements-common.txt b/requirements-common.txt index e4fe54721..bdb1f1127 100644 --- a/requirements-common.txt +++ b/requirements-common.txt @@ -1,18 +1,18 @@ # requirements without requirements installable via conda # mainly used for Raspberry pi installs -ccxt==1.21.91 +ccxt==1.22.95 SQLAlchemy==1.3.13 -python-telegram-bot==12.3.0 +python-telegram-bot==12.4.2 arrow==0.15.5 cachetools==4.0.0 -requests==2.22.0 +requests==2.23.0 urllib3==1.25.8 -wrapt==1.11.2 +wrapt==1.12.0 jsonschema==3.2.0 TA-Lib==0.4.17 tabulate==0.8.6 coinmarketcap==5.0.3 -jinja2==2.10.3 +jinja2==2.11.1 # find first, C search in arrays py_find_1st==1.1.4 @@ -28,3 +28,6 @@ flask==1.1.1 # Support for colorized terminal output colorama==0.4.3 +# Building config files interactively +questionary==1.5.1 +prompt-toolkit==3.0.3 diff --git a/requirements-dev.txt b/requirements-dev.txt index 6330d93e5..1e58ae6e0 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,12 +3,12 @@ -r requirements-plot.txt -r requirements-hyperopt.txt -coveralls==1.10.0 +coveralls==1.11.1 flake8==3.7.9 flake8-type-annotations==0.1.0 flake8-tidy-imports==4.0.0 mypy==0.761 -pytest==5.3.4 +pytest==5.3.5 pytest-asyncio==0.10.0 pytest-cov==2.8.1 pytest-mock==2.0.0 diff --git a/requirements-hyperopt.txt b/requirements-hyperopt.txt index 43cad1a0e..2984229c1 100644 --- a/requirements-hyperopt.txt +++ b/requirements-hyperopt.txt @@ -4,6 +4,6 @@ # Required for hyperopt scipy==1.4.1 scikit-learn==0.22.1 -scikit-optimize==0.5.2 +scikit-optimize==0.7.4 filelock==3.0.12 joblib==0.14.1 diff --git a/requirements-plot.txt b/requirements-plot.txt index 26467d90b..5e62a5e95 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.5.0 +plotly==4.5.1 diff --git a/requirements.txt b/requirements.txt index c7dd07ee4..68024f587 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,4 @@ -r requirements-common.txt numpy==1.18.1 -pandas==0.25.3 +pandas==1.0.1 diff --git a/setup.py b/setup.py index 7d8d7b68d..63a595f32 100644 --- a/setup.py +++ b/setup.py @@ -79,6 +79,8 @@ setup(name='freqtrade', 'sdnotify', 'colorama', 'jinja2', + 'questionary', + 'prompt-toolkit', # from requirements.txt 'numpy', 'pandas', diff --git a/setup.sh b/setup.sh index fb5102e12..e120190ce 100755 --- a/setup.sh +++ b/setup.sh @@ -17,6 +17,14 @@ function check_installed_python() { exit 2 fi + which python3.8 + if [ $? -eq 0 ]; then + echo "using Python 3.8" + PYTHON=python3.8 + check_installed_pip + return + fi + which python3.7 if [ $? -eq 0 ]; then echo "using Python 3.7" @@ -215,27 +223,8 @@ function config_generator() { function config() { echo "-------------------------" - echo "Generating config file" + echo "Please use 'freqtrade new-config -c config.json' to generate a new configuration file." echo "-------------------------" - if [ -f config.json ] - then - read -p "A config file already exist, do you want to override it [y/N]? " - if [[ $REPLY =~ ^[Yy]$ ]] - then - config_generator - else - echo "Configuration of config.json ignored." - fi - else - config_generator - fi - - echo - echo "-------------------------" - echo "Config file generated" - echo "-------------------------" - echo "Edit ./config.json to modify Pair and other configurations." - echo } function install() { diff --git a/tests/commands/test_build_config.py b/tests/commands/test_build_config.py new file mode 100644 index 000000000..d4ebe1de2 --- /dev/null +++ b/tests/commands/test_build_config.py @@ -0,0 +1,116 @@ +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import rapidjson + +from freqtrade.commands.build_config_commands import (ask_user_config, + ask_user_overwrite, + start_new_config, + validate_is_float, + validate_is_int) +from freqtrade.exceptions import OperationalException +from tests.conftest import get_args, log_has_re + + +def test_validate_is_float(): + assert validate_is_float('2.0') + assert validate_is_float('2.1') + assert validate_is_float('0.1') + assert validate_is_float('-0.5') + assert not validate_is_float('-0.5e') + + +def test_validate_is_int(): + assert validate_is_int('2') + assert validate_is_int('6') + assert validate_is_int('-1') + assert validate_is_int('500') + assert not validate_is_int('2.0') + assert not validate_is_int('2.1') + assert not validate_is_int('-2.1') + assert not validate_is_int('-ee') + + +@pytest.mark.parametrize('exchange', ['bittrex', 'binance', 'kraken', 'ftx']) +def test_start_new_config(mocker, caplog, exchange): + wt_mock = mocker.patch.object(Path, "write_text", MagicMock()) + mocker.patch.object(Path, "exists", MagicMock(return_value=True)) + unlink_mock = mocker.patch.object(Path, "unlink", MagicMock()) + mocker.patch('freqtrade.commands.build_config_commands.ask_user_overwrite', return_value=True) + + sample_selections = { + 'max_open_trades': 3, + 'stake_currency': 'USDT', + 'stake_amount': 100, + 'fiat_display_currency': 'EUR', + 'ticker_interval': '15m', + 'dry_run': True, + 'exchange_name': exchange, + 'exchange_key': 'sampleKey', + 'exchange_secret': 'Samplesecret', + 'telegram': False, + 'telegram_token': 'asdf1244', + 'telegram_chat_id': '1144444', + } + mocker.patch('freqtrade.commands.build_config_commands.ask_user_config', + return_value=sample_selections) + args = [ + "new-config", + "--config", + "coolconfig.json" + ] + start_new_config(get_args(args)) + + assert log_has_re("Writing config to .*", caplog) + assert wt_mock.call_count == 1 + assert unlink_mock.call_count == 1 + result = rapidjson.loads(wt_mock.call_args_list[0][0][0], + parse_mode=rapidjson.PM_COMMENTS | rapidjson.PM_TRAILING_COMMAS) + assert result['exchange']['name'] == exchange + assert result['ticker_interval'] == '15m' + + +def test_start_new_config_exists(mocker, caplog): + mocker.patch.object(Path, "exists", MagicMock(return_value=True)) + mocker.patch('freqtrade.commands.build_config_commands.ask_user_overwrite', return_value=False) + args = [ + "new-config", + "--config", + "coolconfig.json" + ] + with pytest.raises(OperationalException, match=r"Configuration .* already exists\."): + start_new_config(get_args(args)) + + +def test_ask_user_overwrite(mocker): + """ + Once https://github.com/tmbo/questionary/issues/35 is implemented, improve this test. + """ + prompt_mock = mocker.patch('freqtrade.commands.build_config_commands.prompt', + return_value={'overwrite': False}) + assert not ask_user_overwrite(Path('test.json')) + assert prompt_mock.call_count == 1 + + prompt_mock.reset_mock() + prompt_mock = mocker.patch('freqtrade.commands.build_config_commands.prompt', + return_value={'overwrite': True}) + assert ask_user_overwrite(Path('test.json')) + assert prompt_mock.call_count == 1 + + +def test_ask_user_config(mocker): + """ + Once https://github.com/tmbo/questionary/issues/35 is implemented, improve this test. + """ + prompt_mock = mocker.patch('freqtrade.commands.build_config_commands.prompt', + return_value={'overwrite': False}) + answers = ask_user_config() + assert isinstance(answers, dict) + assert prompt_mock.call_count == 1 + + prompt_mock = mocker.patch('freqtrade.commands.build_config_commands.prompt', + return_value={}) + + with pytest.raises(OperationalException, match=r"User interrupted interactive questions\."): + ask_user_config() diff --git a/tests/commands/test_commands.py b/tests/commands/test_commands.py index 65d7f6eaf..a9fe0f637 100644 --- a/tests/commands/test_commands.py +++ b/tests/commands/test_commands.py @@ -4,9 +4,10 @@ from unittest.mock import MagicMock, PropertyMock import pytest -from freqtrade.commands import (start_create_userdir, start_download_data, - start_hyperopt_list, start_hyperopt_show, - start_list_exchanges, start_list_markets, +from freqtrade.commands import (start_convert_data, start_create_userdir, + start_download_data, start_hyperopt_list, + start_hyperopt_show, start_list_exchanges, + start_list_hyperopts, start_list_markets, start_list_strategies, start_list_timeframes, start_new_hyperopt, start_new_strategy, start_test_pairlist, start_trading) @@ -639,7 +640,7 @@ def test_start_list_strategies(mocker, caplog, capsys): args = [ "list-strategies", "--strategy-path", - str(Path(__file__).parent.parent / "strategy"), + str(Path(__file__).parent.parent / "strategy" / "strats"), "-1" ] pargs = get_args(args) @@ -654,7 +655,7 @@ def test_start_list_strategies(mocker, caplog, capsys): args = [ "list-strategies", "--strategy-path", - str(Path(__file__).parent.parent / "strategy"), + str(Path(__file__).parent.parent / "strategy" / "strats"), ] pargs = get_args(args) # pargs['config'] = None @@ -665,6 +666,39 @@ def test_start_list_strategies(mocker, caplog, capsys): assert "DefaultStrategy" in captured.out +def test_start_list_hyperopts(mocker, caplog, capsys): + + args = [ + "list-hyperopts", + "--hyperopt-path", + str(Path(__file__).parent.parent / "optimize"), + "-1" + ] + pargs = get_args(args) + # pargs['config'] = None + start_list_hyperopts(pargs) + captured = capsys.readouterr() + assert "TestHyperoptLegacy" not in captured.out + assert "legacy_hyperopt.py" not in captured.out + assert "DefaultHyperOpt" in captured.out + assert "test_hyperopt.py" not in captured.out + + # Test regular output + args = [ + "list-hyperopts", + "--hyperopt-path", + str(Path(__file__).parent.parent / "optimize"), + ] + pargs = get_args(args) + # pargs['config'] = None + start_list_hyperopts(pargs) + captured = capsys.readouterr() + assert "TestHyperoptLegacy" not in captured.out + assert "legacy_hyperopt.py" not in captured.out + assert "DefaultHyperOpt" in captured.out + assert "test_hyperopt.py" in captured.out + + def test_start_test_pairlist(mocker, caplog, tickers, default_conf, capsys): patch_exchange(mocker, mock_markets=True) mocker.patch.multiple('freqtrade.exchange.Exchange', @@ -744,6 +778,121 @@ def test_hyperopt_list(mocker, capsys, hyperopt_results): assert all(x not in captured.out for x in [" 1/12", " 3/12", " 4/12", " 5/12", " 6/12", " 7/12", " 8/12", " 9/12", " 11/12", " 12/12"]) + args = [ + "hyperopt-list", + "--no-details", + "--no-color", + "--min-trades", "20" + ] + pargs = get_args(args) + pargs['config'] = None + start_hyperopt_list(pargs) + captured = capsys.readouterr() + assert all(x in captured.out + for x in [" 3/12", " 6/12", " 7/12", " 9/12", " 11/12"]) + assert all(x not in captured.out + for x in [" 1/12", " 2/12", " 4/12", " 5/12", " 8/12", " 10/12", " 12/12"]) + args = [ + "hyperopt-list", + "--profitable", + "--no-details", + "--max-trades", "20" + ] + pargs = get_args(args) + pargs['config'] = None + start_hyperopt_list(pargs) + captured = capsys.readouterr() + assert all(x in captured.out + for x in [" 2/12", " 10/12"]) + assert all(x not in captured.out + for x in [" 1/12", " 3/12", " 4/12", " 5/12", " 6/12", " 7/12", " 8/12", " 9/12", + " 11/12", " 12/12"]) + args = [ + "hyperopt-list", + "--profitable", + "--no-details", + "--min-avg-profit", "0.11" + ] + pargs = get_args(args) + pargs['config'] = None + start_hyperopt_list(pargs) + captured = capsys.readouterr() + assert all(x in captured.out + for x in [" 2/12"]) + assert all(x not in captured.out + for x in [" 1/12", " 3/12", " 4/12", " 5/12", " 6/12", " 7/12", " 8/12", " 9/12", + " 10/12", " 11/12", " 12/12"]) + args = [ + "hyperopt-list", + "--no-details", + "--max-avg-profit", "0.10" + ] + pargs = get_args(args) + pargs['config'] = None + start_hyperopt_list(pargs) + captured = capsys.readouterr() + assert all(x in captured.out + for x in [" 1/12", " 3/12", " 5/12", " 6/12", " 7/12", " 8/12", " 9/12", + " 11/12"]) + assert all(x not in captured.out + for x in [" 2/12", " 4/12", " 10/12", " 12/12"]) + args = [ + "hyperopt-list", + "--no-details", + "--min-total-profit", "0.4" + ] + pargs = get_args(args) + pargs['config'] = None + start_hyperopt_list(pargs) + captured = capsys.readouterr() + assert all(x in captured.out + for x in [" 10/12"]) + assert all(x not in captured.out + for x in [" 1/12", " 2/12", " 3/12", " 4/12", " 5/12", " 6/12", " 7/12", " 8/12", + " 9/12", " 11/12", " 12/12"]) + args = [ + "hyperopt-list", + "--no-details", + "--max-total-profit", "0.4" + ] + pargs = get_args(args) + pargs['config'] = None + start_hyperopt_list(pargs) + captured = capsys.readouterr() + assert all(x in captured.out + for x in [" 1/12", " 2/12", " 3/12", " 5/12", " 6/12", " 7/12", " 8/12", + " 9/12", " 11/12"]) + assert all(x not in captured.out + for x in [" 4/12", " 10/12", " 12/12"]) + args = [ + "hyperopt-list", + "--profitable", + "--no-details", + "--min-avg-time", "2000" + ] + pargs = get_args(args) + pargs['config'] = None + start_hyperopt_list(pargs) + captured = capsys.readouterr() + assert all(x in captured.out + for x in [" 10/12"]) + assert all(x not in captured.out + for x in [" 1/12", " 2/12", " 3/12", " 4/12", " 5/12", " 6/12", " 7/12", + " 8/12", " 9/12", " 11/12", " 12/12"]) + args = [ + "hyperopt-list", + "--no-details", + "--max-avg-time", "1500" + ] + pargs = get_args(args) + pargs['config'] = None + start_hyperopt_list(pargs) + captured = capsys.readouterr() + assert all(x in captured.out + for x in [" 2/12", " 6/12"]) + assert all(x not in captured.out + for x in [" 1/12", " 3/12", " 4/12", " 5/12", " 7/12", " 8/12" + " 9/12", " 10/12", " 11/12", " 12/12"]) def test_hyperopt_show(mocker, capsys, hyperopt_results): @@ -824,3 +973,47 @@ def test_hyperopt_show(mocker, capsys, hyperopt_results): with pytest.raises(OperationalException, match="The index of the epoch to show should be less than 4."): start_hyperopt_show(pargs) + + +def test_convert_data(mocker, testdatadir): + ohlcv_mock = mocker.patch("freqtrade.commands.data_commands.convert_ohlcv_format") + trades_mock = mocker.patch("freqtrade.commands.data_commands.convert_trades_format") + args = [ + "convert-data", + "--format-from", + "json", + "--format-to", + "jsongz", + "--datadir", + str(testdatadir), + ] + pargs = get_args(args) + pargs['config'] = None + start_convert_data(pargs, True) + assert trades_mock.call_count == 0 + assert ohlcv_mock.call_count == 1 + assert ohlcv_mock.call_args[1]['convert_from'] == 'json' + assert ohlcv_mock.call_args[1]['convert_to'] == 'jsongz' + assert ohlcv_mock.call_args[1]['erase'] is False + + +def test_convert_data_trades(mocker, testdatadir): + ohlcv_mock = mocker.patch("freqtrade.commands.data_commands.convert_ohlcv_format") + trades_mock = mocker.patch("freqtrade.commands.data_commands.convert_trades_format") + args = [ + "convert-trade-data", + "--format-from", + "jsongz", + "--format-to", + "json", + "--datadir", + str(testdatadir), + ] + pargs = get_args(args) + pargs['config'] = None + start_convert_data(pargs, False) + assert ohlcv_mock.call_count == 0 + assert trades_mock.call_count == 1 + 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 diff --git a/tests/conftest.py b/tests/conftest.py index 395388f73..acb730330 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -257,6 +257,7 @@ def default_conf(testdatadir): "db_url": "sqlite://", "user_data_dir": Path("user_data"), "verbosity": 3, + "strategy_path": str(Path(__file__).parent / "strategy" / "strats"), "strategy": "DefaultStrategy" } return configuration @@ -640,6 +641,31 @@ def shitcoinmarkets(markets): }, 'info': {}, }, + 'NANO/USDT': { + "percentage": True, + "tierBased": False, + "taker": 0.001, + "maker": 0.001, + "precision": { + "base": 8, + "quote": 8, + "amount": 2, + "price": 4 + }, + "limits": { + }, + "id": "NANOUSDT", + "symbol": "NANO/USDT", + "base": "NANO", + "quote": "USDT", + "baseId": "NANO", + "quoteId": "USDT", + "info": {}, + "type": "spot", + "spot": True, + "future": False, + "active": True + }, }) return shitmarkets @@ -1114,6 +1140,28 @@ def tickers(): 'quoteVolume': 1154.19266394, 'info': {} }, + "NANO/USDT": { + "symbol": "NANO/USDT", + "timestamp": 1580469388244, + "datetime": "2020-01-31T11:16:28.244Z", + "high": 0.7519, + "low": 0.7154, + "bid": 0.7305, + "bidVolume": 300.3, + "ask": 0.7342, + "askVolume": 15.14, + "vwap": 0.73645591, + "open": 0.7154, + "close": 0.7342, + "last": 0.7342, + "previousClose": 0.7189, + "change": 0.0188, + "percentage": 2.628, + "average": None, + "baseVolume": 439472.44, + "quoteVolume": 323652.075405, + "info": {} + }, }) diff --git a/tests/data/test_converter.py b/tests/data/test_converter.py index 414551c95..a0ec2f46f 100644 --- a/tests/data/test_converter.py +++ b/tests/data/test_converter.py @@ -1,9 +1,15 @@ # pragma pylint: disable=missing-docstring, C0103 import logging -from freqtrade.data.converter import parse_ticker_dataframe, ohlcv_fill_up_missing_data -from freqtrade.data.history import load_pair_history, validate_backtest_data, get_timerange +from freqtrade.configuration.timerange import TimeRange +from freqtrade.data.converter import (convert_ohlcv_format, + convert_trades_format, + ohlcv_fill_up_missing_data, + parse_ticker_dataframe, 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 def test_dataframe_correct_columns(result): @@ -145,3 +151,113 @@ def test_ohlcv_drop_incomplete(caplog): assert len(data) == 3 assert log_has("Dropping last candle", caplog) + + +def test_trim_dataframe(testdatadir) -> None: + data = load_data( + datadir=testdatadir, + timeframe='1m', + pairs=['UNITTEST/BTC'] + )['UNITTEST/BTC'] + min_date = int(data.iloc[0]['date'].timestamp()) + max_date = int(data.iloc[-1]['date'].timestamp()) + data_modify = data.copy() + + # Remove first 30 minutes (1800 s) + tr = TimeRange('date', None, min_date + 1800, 0) + data_modify = trim_dataframe(data_modify, tr) + assert not data_modify.equals(data) + assert len(data_modify) < len(data) + assert len(data_modify) == len(data) - 30 + assert all(data_modify.iloc[-1] == data.iloc[-1]) + assert all(data_modify.iloc[0] == data.iloc[30]) + + data_modify = data.copy() + # Remove last 30 minutes (1800 s) + tr = TimeRange(None, 'date', 0, max_date - 1800) + data_modify = trim_dataframe(data_modify, tr) + assert not data_modify.equals(data) + assert len(data_modify) < len(data) + assert len(data_modify) == len(data) - 30 + assert all(data_modify.iloc[0] == data.iloc[0]) + assert all(data_modify.iloc[-1] == data.iloc[-31]) + + data_modify = data.copy() + # Remove first 25 and last 30 minutes (1800 s) + tr = TimeRange('date', 'date', min_date + 1500, max_date - 1800) + data_modify = trim_dataframe(data_modify, tr) + assert not data_modify.equals(data) + assert len(data_modify) < len(data) + assert len(data_modify) == len(data) - 55 + # first row matches 25th original row + 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 + + assert not file_new.exists() + + convert_trades_format(default_conf, convert_from='jsongz', + convert_to='json', erase=False) + + assert file_new.exists() + assert file.exists() + + # Remove original file + file.unlink() + # Convert back + convert_trades_format(default_conf, convert_from='json', + convert_to='jsongz', erase=True) + + assert file.exists() + assert not file_new.exists() + + _clean_test_file(file) + if file_new.exists(): + file_new.unlink() + + +def test_convert_ohlcv_format(mocker, default_conf, testdatadir): + file1 = testdatadir / "XRP_ETH-5m.json" + file1_new = testdatadir / "XRP_ETH-5m.json.gz" + file2 = testdatadir / "XRP_ETH-1m.json" + file2_new = testdatadir / "XRP_ETH-1m.json.gz" + _backup_file(file1, copy_file=True) + _backup_file(file2, copy_file=True) + default_conf['datadir'] = testdatadir + default_conf['pairs'] = ['XRP_ETH'] + default_conf['timeframes'] = ['1m', '5m'] + + assert not file1_new.exists() + assert not file2_new.exists() + + convert_ohlcv_format(default_conf, convert_from='json', + convert_to='jsongz', erase=False) + + assert file1_new.exists() + assert file2_new.exists() + assert file1.exists() + assert file2.exists() + + # Remove original files + file1.unlink() + file2.unlink() + # Convert back + convert_ohlcv_format(default_conf, convert_from='jsongz', + convert_to='json', erase=True) + + assert file1.exists() + assert file2.exists() + assert not file1_new.exists() + assert not file2_new.exists() + + _clean_test_file(file1) + _clean_test_file(file2) + if file1_new.exists(): + file1_new.unlink() + if file2_new.exists(): + file2_new.unlink() diff --git a/tests/data/test_history.py b/tests/data/test_history.py index 7b3143db9..9c9af9acd 100644 --- a/tests/data/test_history.py +++ b/tests/data/test_history.py @@ -7,24 +7,24 @@ from shutil import copyfile from unittest.mock import MagicMock, PropertyMock import arrow +import pytest from pandas import DataFrame +from pandas.testing import assert_frame_equal from freqtrade.configuration import TimeRange -from freqtrade.data.history import (_download_pair_history, - _download_trades_history, - _load_cached_data_for_updating, - convert_trades_to_ohlcv, get_timerange, - load_data, load_pair_history, - load_tickerdata_file, pair_data_filename, - pair_trades_filename, - refresh_backtest_ohlcv_data, - refresh_backtest_trades_data, - refresh_data, - trim_dataframe, trim_tickerlist, - validate_backtest_data) +from freqtrade.data.converter import parse_ticker_dataframe +from freqtrade.data.history.history_utils import ( + _download_pair_history, _download_trades_history, + _load_cached_data_for_updating, convert_trades_to_ohlcv, get_timerange, + load_data, load_pair_history, refresh_backtest_ohlcv_data, + refresh_backtest_trades_data, refresh_data, validate_backtest_data) +from freqtrade.data.history.idatahandler import (IDataHandler, get_datahandler, + get_datahandlerclass) +from freqtrade.data.history.jsondatahandler import (JsonDataHandler, + JsonGzDataHandler) from freqtrade.exchange import timeframe_to_minutes from freqtrade.misc import file_dump_json -from freqtrade.strategy.default_strategy import DefaultStrategy +from freqtrade.resolvers import StrategyResolver from tests.conftest import (get_patched_exchange, log_has, log_has_re, patch_exchange) @@ -96,8 +96,9 @@ def test_load_data_1min_ticker(ticker_history, mocker, caplog, testdatadir) -> N def test_load_data_startup_candles(mocker, caplog, default_conf, testdatadir) -> None: - ltfmock = mocker.patch('freqtrade.data.history.load_tickerdata_file', - MagicMock(return_value=None)) + ltfmock = mocker.patch( + 'freqtrade.data.history.jsondatahandler.JsonDataHandler._ohlcv_load', + MagicMock(return_value=DataFrame())) timerange = TimeRange('date', None, 1510639620, 0) load_pair_history(pair='UNITTEST/BTC', timeframe='1m', datadir=testdatadir, timerange=timerange, @@ -143,27 +144,52 @@ def test_testdata_path(testdatadir) -> None: assert str(Path('tests') / 'testdata') in str(testdatadir) -def test_pair_data_filename(): - fn = pair_data_filename(Path('freqtrade/hello/world'), 'ETH/BTC', '5m') +@pytest.mark.parametrize("pair,expected_result", [ + ("ETH/BTC", 'freqtrade/hello/world/ETH_BTC-5m.json'), + ("Fabric Token/ETH", 'freqtrade/hello/world/Fabric_Token_ETH-5m.json'), + ("ETHH20", 'freqtrade/hello/world/ETHH20-5m.json'), + (".XBTBON2H", 'freqtrade/hello/world/_XBTBON2H-5m.json'), + ("ETHUSD.d", 'freqtrade/hello/world/ETHUSD_d-5m.json'), + ("ACC_OLD/BTC", 'freqtrade/hello/world/ACC_OLD_BTC-5m.json'), +]) +def test_json_pair_data_filename(pair, expected_result): + fn = JsonDataHandler._pair_data_filename(Path('freqtrade/hello/world'), pair, '5m') assert isinstance(fn, Path) - assert fn == Path('freqtrade/hello/world/ETH_BTC-5m.json') - - -def test_pair_trades_filename(): - fn = pair_trades_filename(Path('freqtrade/hello/world'), 'ETH/BTC') + assert fn == Path(expected_result) + fn = JsonGzDataHandler._pair_data_filename(Path('freqtrade/hello/world'), pair, '5m') assert isinstance(fn, Path) - assert fn == Path('freqtrade/hello/world/ETH_BTC-trades.json.gz') + assert fn == Path(expected_result + '.gz') -def test_load_cached_data_for_updating(mocker) -> None: - datadir = Path(__file__).parent.parent.joinpath('testdata') +@pytest.mark.parametrize("pair,expected_result", [ + ("ETH/BTC", 'freqtrade/hello/world/ETH_BTC-trades.json'), + ("Fabric Token/ETH", 'freqtrade/hello/world/Fabric_Token_ETH-trades.json'), + ("ETHH20", 'freqtrade/hello/world/ETHH20-trades.json'), + (".XBTBON2H", 'freqtrade/hello/world/_XBTBON2H-trades.json'), + ("ETHUSD.d", 'freqtrade/hello/world/ETHUSD_d-trades.json'), + ("ACC_OLD_BTC", 'freqtrade/hello/world/ACC_OLD_BTC-trades.json'), +]) +def test_json_pair_trades_filename(pair, expected_result): + fn = JsonDataHandler._pair_trades_filename(Path('freqtrade/hello/world'), pair) + assert isinstance(fn, Path) + assert fn == Path(expected_result) + + fn = JsonGzDataHandler._pair_trades_filename(Path('freqtrade/hello/world'), pair) + assert isinstance(fn, Path) + assert fn == Path(expected_result + '.gz') + + +def test_load_cached_data_for_updating(mocker, testdatadir) -> None: + + data_handler = get_datahandler(testdatadir, 'json') test_data = None - test_filename = datadir.joinpath('UNITTEST_BTC-1m.json') + test_filename = testdatadir.joinpath('UNITTEST_BTC-1m.json') with open(test_filename, "rt") as file: test_data = json.load(file) - # change now time to test 'line' cases + test_data_df = parse_ticker_dataframe(test_data, '1m', 'UNITTEST/BTC', + fill_missing=False, drop_incomplete=False) # now = last cached item + 1 hour now_ts = test_data[-1][0] / 1000 + 60 * 60 mocker.patch('arrow.utcnow', return_value=arrow.get(now_ts)) @@ -171,72 +197,36 @@ def test_load_cached_data_for_updating(mocker) -> None: # timeframe starts earlier than the cached data # should fully update data timerange = TimeRange('date', None, test_data[0][0] / 1000 - 1, 0) - data, start_ts = _load_cached_data_for_updating(datadir, 'UNITTEST/BTC', '1m', timerange) - assert data == [] + data, start_ts = _load_cached_data_for_updating('UNITTEST/BTC', '1m', timerange, data_handler) + assert data.empty assert start_ts == test_data[0][0] - 1000 - # same with 'line' timeframe - num_lines = (test_data[-1][0] - test_data[1][0]) / 1000 / 60 + 120 - data, start_ts = _load_cached_data_for_updating(datadir, 'UNITTEST/BTC', '1m', - TimeRange(None, 'line', 0, -num_lines)) - assert data == [] - assert start_ts < test_data[0][0] - 1 - # timeframe starts in the center of the cached data # should return the chached data w/o the last item timerange = TimeRange('date', None, test_data[0][0] / 1000 + 1, 0) - data, start_ts = _load_cached_data_for_updating(datadir, 'UNITTEST/BTC', '1m', timerange) - assert data == test_data[:-1] - assert test_data[-2][0] < start_ts < test_data[-1][0] + data, start_ts = _load_cached_data_for_updating('UNITTEST/BTC', '1m', timerange, data_handler) - # same with 'line' timeframe - num_lines = (test_data[-1][0] - test_data[1][0]) / 1000 / 60 + 30 - timerange = TimeRange(None, 'line', 0, -num_lines) - data, start_ts = _load_cached_data_for_updating(datadir, 'UNITTEST/BTC', '1m', timerange) - assert data == test_data[:-1] - assert test_data[-2][0] < start_ts < test_data[-1][0] + assert_frame_equal(data, test_data_df.iloc[:-1]) + assert test_data[-2][0] <= start_ts < test_data[-1][0] # timeframe starts after the chached data # should return the chached data w/o the last item - timerange = TimeRange('date', None, test_data[-1][0] / 1000 + 1, 0) - data, start_ts = _load_cached_data_for_updating(datadir, 'UNITTEST/BTC', '1m', timerange) - assert data == test_data[:-1] - assert test_data[-2][0] < start_ts < test_data[-1][0] - - # Try loading last 30 lines. - # Not supported by _load_cached_data_for_updating, we always need to get the full data. - num_lines = 30 - timerange = TimeRange(None, 'line', 0, -num_lines) - data, start_ts = _load_cached_data_for_updating(datadir, 'UNITTEST/BTC', '1m', timerange) - assert data == test_data[:-1] - assert test_data[-2][0] < start_ts < test_data[-1][0] - - # no timeframe is set - # should return the chached data w/o the last item - num_lines = 30 - timerange = TimeRange(None, 'line', 0, -num_lines) - data, start_ts = _load_cached_data_for_updating(datadir, 'UNITTEST/BTC', '1m', timerange) - assert data == test_data[:-1] - assert test_data[-2][0] < start_ts < test_data[-1][0] + timerange = TimeRange('date', None, test_data[-1][0] / 1000 + 100, 0) + data, start_ts = _load_cached_data_for_updating('UNITTEST/BTC', '1m', timerange, data_handler) + assert_frame_equal(data, test_data_df.iloc[:-1]) + assert test_data[-2][0] <= start_ts < test_data[-1][0] # no datafile exist # should return timestamp start time timerange = TimeRange('date', None, now_ts - 10000, 0) - data, start_ts = _load_cached_data_for_updating(datadir, 'NONEXIST/BTC', '1m', timerange) - assert data == [] + data, start_ts = _load_cached_data_for_updating('NONEXIST/BTC', '1m', timerange, data_handler) + assert data.empty assert start_ts == (now_ts - 10000) * 1000 - # same with 'line' timeframe - num_lines = 30 - timerange = TimeRange(None, 'line', 0, -num_lines) - data, start_ts = _load_cached_data_for_updating(datadir, 'NONEXIST/BTC', '1m', timerange) - assert data == [] - assert start_ts == (now_ts - num_lines * 60) * 1000 - # no datafile exist, no timeframe is set # should return an empty array and None - data, start_ts = _load_cached_data_for_updating(datadir, 'NONEXIST/BTC', '1m', None) - assert data == [] + data, start_ts = _load_cached_data_for_updating('NONEXIST/BTC', '1m', None, data_handler) + assert data.empty assert start_ts is None @@ -293,7 +283,9 @@ def test_download_pair_history2(mocker, default_conf, testdatadir) -> None: [1509836520000, 0.00162008, 0.00162008, 0.00162008, 0.00162008, 108.14853839], [1509836580000, 0.00161, 0.00161, 0.00161, 0.00161, 82.390199] ] - json_dump_mock = mocker.patch('freqtrade.misc.file_dump_json', return_value=None) + json_dump_mock = mocker.patch( + 'freqtrade.data.history.jsondatahandler.JsonDataHandler.ohlcv_store', + return_value=None) mocker.patch('freqtrade.exchange.Exchange.get_historic_ohlcv', return_value=tick) exchange = get_patched_exchange(mocker, default_conf) _download_pair_history(testdatadir, exchange, pair="UNITTEST/BTC", timeframe='1m') @@ -325,17 +317,6 @@ def test_download_backtesting_data_exception(ticker_history, mocker, caplog, ) -def test_load_tickerdata_file(testdatadir) -> None: - # 7 does not exist in either format. - assert not load_tickerdata_file(testdatadir, 'UNITTEST/BTC', '7m') - # 1 exists only as a .json - tickerdata = load_tickerdata_file(testdatadir, 'UNITTEST/BTC', '1m') - assert _BTC_UNITTEST_LENGTH == len(tickerdata) - # 8 .json is empty and will fail if it's loaded. .json.gz is a copy of 1.json - tickerdata = load_tickerdata_file(testdatadir, 'UNITTEST/BTC', '8m') - assert _BTC_UNITTEST_LENGTH == len(tickerdata) - - def test_load_partial_missing(testdatadir, caplog) -> None: # Make sure we start fresh - test missing data at start start = arrow.get('2018-01-01T00:00:00') @@ -361,6 +342,7 @@ def test_load_partial_missing(testdatadir, caplog) -> None: # timedifference in 5 minutes td = ((end - start).total_seconds() // 60 // 5) + 1 assert td != len(tickerdata['UNITTEST/BTC']) + # Shift endtime with +5 - as last candle is dropped (partial candle) end_real = arrow.get(tickerdata['UNITTEST/BTC'].iloc[-1, 0]).shift(minutes=5) assert log_has(f'Missing data at end for pair ' @@ -370,7 +352,7 @@ def test_load_partial_missing(testdatadir, caplog) -> None: def test_init(default_conf, mocker) -> None: assert {} == load_data( - datadir='', + datadir=Path(''), pairs=[], timeframe=default_conf['ticker_interval'] ) @@ -379,110 +361,18 @@ def test_init(default_conf, mocker) -> None: def test_init_with_refresh(default_conf, mocker) -> None: exchange = get_patched_exchange(mocker, default_conf) refresh_data( - datadir='', + datadir=Path(''), pairs=[], timeframe=default_conf['ticker_interval'], exchange=exchange ) assert {} == load_data( - datadir='', + datadir=Path(''), pairs=[], timeframe=default_conf['ticker_interval'] ) -def test_trim_tickerlist(testdatadir) -> None: - file = testdatadir / 'UNITTEST_BTC-1m.json' - with open(file) as data_file: - ticker_list = json.load(data_file) - ticker_list_len = len(ticker_list) - - # Test the pattern ^(\d{8})-(\d{8})$ - # This pattern extract a window between the dates - timerange = TimeRange('date', 'date', ticker_list[5][0] / 1000, ticker_list[10][0] / 1000 - 1) - ticker = trim_tickerlist(ticker_list, timerange) - ticker_len = len(ticker) - - assert ticker_len == 5 - assert ticker_list[0] is not ticker[0] # The first element should be different - assert ticker_list[5] is ticker[0] # The list starts at the index 5 - assert ticker_list[9] is ticker[-1] # The list ends at the index 9 (5 elements) - - # Test the pattern ^-(\d{8})$ - # This pattern extracts elements from the start to the date - timerange = TimeRange(None, 'date', 0, ticker_list[10][0] / 1000 - 1) - ticker = trim_tickerlist(ticker_list, timerange) - ticker_len = len(ticker) - - assert ticker_len == 10 - assert ticker_list[0] is ticker[0] # The start of the list is included - assert ticker_list[9] is ticker[-1] # The element 10 is not included - - # Test the pattern ^(\d{8})-$ - # This pattern extracts elements from the date to now - timerange = TimeRange('date', None, ticker_list[10][0] / 1000 - 1, None) - ticker = trim_tickerlist(ticker_list, timerange) - ticker_len = len(ticker) - - assert ticker_len == ticker_list_len - 10 - assert ticker_list[10] is ticker[0] # The first element is element #10 - assert ticker_list[-1] is ticker[-1] # The last element is the same - - # Test a wrong pattern - # This pattern must return the list unchanged - timerange = TimeRange(None, None, None, 5) - ticker = trim_tickerlist(ticker_list, timerange) - ticker_len = len(ticker) - - assert ticker_list_len == ticker_len - - # passing empty list - timerange = TimeRange(None, None, None, 5) - ticker = trim_tickerlist([], timerange) - assert 0 == len(ticker) - assert not ticker - - -def test_trim_dataframe(testdatadir) -> None: - data = load_data( - datadir=testdatadir, - timeframe='1m', - pairs=['UNITTEST/BTC'] - )['UNITTEST/BTC'] - min_date = int(data.iloc[0]['date'].timestamp()) - max_date = int(data.iloc[-1]['date'].timestamp()) - data_modify = data.copy() - - # Remove first 30 minutes (1800 s) - tr = TimeRange('date', None, min_date + 1800, 0) - data_modify = trim_dataframe(data_modify, tr) - assert not data_modify.equals(data) - assert len(data_modify) < len(data) - assert len(data_modify) == len(data) - 30 - assert all(data_modify.iloc[-1] == data.iloc[-1]) - assert all(data_modify.iloc[0] == data.iloc[30]) - - data_modify = data.copy() - # Remove last 30 minutes (1800 s) - tr = TimeRange(None, 'date', 0, max_date - 1800) - data_modify = trim_dataframe(data_modify, tr) - assert not data_modify.equals(data) - assert len(data_modify) < len(data) - assert len(data_modify) == len(data) - 30 - assert all(data_modify.iloc[0] == data.iloc[0]) - assert all(data_modify.iloc[-1] == data.iloc[-31]) - - data_modify = data.copy() - # Remove first 25 and last 30 minutes (1800 s) - tr = TimeRange('date', 'date', min_date + 1500, max_date - 1800) - data_modify = trim_dataframe(data_modify, tr) - assert not data_modify.equals(data) - assert len(data_modify) < len(data) - assert len(data_modify) == len(data) - 55 - # first row matches 25th original row - assert all(data_modify.iloc[0] == data.iloc[25]) - - def test_file_dump_json_tofile(testdatadir) -> None: file = testdatadir / 'test_{id}.json'.format(id=str(uuid.uuid4())) data = {'bar': 'foo'} @@ -509,7 +399,9 @@ def test_file_dump_json_tofile(testdatadir) -> None: def test_get_timerange(default_conf, mocker, testdatadir) -> None: patch_exchange(mocker) - strategy = DefaultStrategy(default_conf) + + default_conf.update({'strategy': 'DefaultStrategy'}) + strategy = StrategyResolver.load_strategy(default_conf) data = strategy.tickerdata_to_dataframe( load_data( @@ -525,7 +417,9 @@ def test_get_timerange(default_conf, mocker, testdatadir) -> None: def test_validate_backtest_data_warn(default_conf, mocker, caplog, testdatadir) -> None: patch_exchange(mocker) - strategy = DefaultStrategy(default_conf) + + default_conf.update({'strategy': 'DefaultStrategy'}) + strategy = StrategyResolver.load_strategy(default_conf) data = strategy.tickerdata_to_dataframe( load_data( @@ -547,7 +441,9 @@ def test_validate_backtest_data_warn(default_conf, mocker, caplog, testdatadir) def test_validate_backtest_data(default_conf, mocker, caplog, testdatadir) -> None: patch_exchange(mocker) - strategy = DefaultStrategy(default_conf) + + default_conf.update({'strategy': 'DefaultStrategy'}) + strategy = StrategyResolver.load_strategy(default_conf) timerange = TimeRange('index', 'index', 200, 250) data = strategy.tickerdata_to_dataframe( @@ -567,7 +463,8 @@ def test_validate_backtest_data(default_conf, mocker, caplog, testdatadir) -> No def test_refresh_backtest_ohlcv_data(mocker, default_conf, markets, caplog, testdatadir): - dl_mock = mocker.patch('freqtrade.data.history._download_pair_history', MagicMock()) + dl_mock = mocker.patch('freqtrade.data.history.history_utils._download_pair_history', + MagicMock()) mocker.patch( 'freqtrade.exchange.Exchange.markets', PropertyMock(return_value=markets) ) @@ -588,7 +485,8 @@ def test_refresh_backtest_ohlcv_data(mocker, default_conf, markets, caplog, test def test_download_data_no_markets(mocker, default_conf, caplog, testdatadir): - dl_mock = mocker.patch('freqtrade.data.history._download_pair_history', MagicMock()) + dl_mock = mocker.patch('freqtrade.data.history.history_utils._download_pair_history', + MagicMock()) ex = get_patched_exchange(mocker, default_conf) mocker.patch( @@ -608,7 +506,8 @@ def test_download_data_no_markets(mocker, default_conf, caplog, testdatadir): def test_refresh_backtest_trades_data(mocker, default_conf, markets, caplog, testdatadir): - dl_mock = mocker.patch('freqtrade.data.history._download_trades_history', MagicMock()) + dl_mock = mocker.patch('freqtrade.data.history.history_utils._download_trades_history', + MagicMock()) mocker.patch( 'freqtrade.exchange.Exchange.markets', PropertyMock(return_value=markets) ) @@ -638,12 +537,12 @@ def test_download_trades_history(trades_history, mocker, default_conf, testdatad ght_mock) exchange = get_patched_exchange(mocker, default_conf) file1 = testdatadir / 'ETH_BTC-trades.json.gz' - + data_handler = get_datahandler(testdatadir, data_format='jsongz') _backup_file(file1) assert not file1.is_file() - assert _download_trades_history(datadir=testdatadir, exchange=exchange, + assert _download_trades_history(data_handler=data_handler, exchange=exchange, pair='ETH/BTC') assert log_has("New Amount of trades: 5", caplog) assert file1.is_file() @@ -654,7 +553,7 @@ def test_download_trades_history(trades_history, mocker, default_conf, testdatad mocker.patch('freqtrade.exchange.Exchange.get_historic_trades', MagicMock(side_effect=ValueError)) - assert not _download_trades_history(datadir=testdatadir, exchange=exchange, + assert not _download_trades_history(data_handler=data_handler, exchange=exchange, pair='ETH/BTC') assert log_has_re('Failed to download historic trades for pair: "ETH/BTC".*', caplog) @@ -686,3 +585,73 @@ def test_convert_trades_to_ohlcv(mocker, default_conf, testdatadir, caplog): _clean_test_file(file1) _clean_test_file(file5) + + +def test_jsondatahandler_ohlcv_get_pairs(testdatadir): + pairs = JsonDataHandler.ohlcv_get_pairs(testdatadir, '5m') + # Convert to set to avoid failures due to sorting + assert set(pairs) == {'UNITTEST/BTC', 'XLM/BTC', 'ETH/BTC', 'TRX/BTC', 'LTC/BTC', + 'XMR/BTC', 'ZEC/BTC', 'ADA/BTC', 'ETC/BTC', 'NXT/BTC', + 'DASH/BTC', 'XRP/ETH'} + + pairs = JsonGzDataHandler.ohlcv_get_pairs(testdatadir, '8m') + assert set(pairs) == {'UNITTEST/BTC'} + + +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'} + + +def test_jsondatahandler_ohlcv_purge(mocker, testdatadir): + mocker.patch.object(Path, "exists", MagicMock(return_value=False)) + mocker.patch.object(Path, "unlink", MagicMock()) + dh = JsonGzDataHandler(testdatadir) + assert not dh.ohlcv_purge('UNITTEST/NONEXIST', '5m') + + mocker.patch.object(Path, "exists", MagicMock(return_value=True)) + assert dh.ohlcv_purge('UNITTEST/NONEXIST', '5m') + + +def test_jsondatahandler_trades_purge(mocker, testdatadir): + mocker.patch.object(Path, "exists", MagicMock(return_value=False)) + mocker.patch.object(Path, "unlink", MagicMock()) + dh = JsonGzDataHandler(testdatadir) + assert not dh.trades_purge('UNITTEST/NONEXIST') + + mocker.patch.object(Path, "exists", MagicMock(return_value=True)) + assert dh.trades_purge('UNITTEST/NONEXIST') + + +def test_jsondatahandler_ohlcv_append(testdatadir): + dh = JsonGzDataHandler(testdatadir) + with pytest.raises(NotImplementedError): + dh.ohlcv_append('UNITTEST/ETH', '5m', DataFrame()) + + +def test_jsondatahandler_trades_append(testdatadir): + dh = JsonGzDataHandler(testdatadir) + with pytest.raises(NotImplementedError): + dh.trades_append('UNITTEST/ETH', []) + + +def test_gethandlerclass(): + cl = get_datahandlerclass('json') + assert cl == JsonDataHandler + assert issubclass(cl, IDataHandler) + cl = get_datahandlerclass('jsongz') + assert cl == JsonGzDataHandler + assert issubclass(cl, IDataHandler) + assert issubclass(cl, JsonDataHandler) + with pytest.raises(ValueError, match=r"No datahandler for .*"): + get_datahandlerclass('DeadBeef') + + +def test_get_datahandler(testdatadir): + dh = get_datahandler(testdatadir, 'json') + assert type(dh) == JsonDataHandler + dh = get_datahandler(testdatadir, 'jsongz') + assert type(dh) == JsonGzDataHandler + dh1 = get_datahandler(testdatadir, 'jsongz', dh) + assert id(dh1) == id(dh) diff --git a/tests/edge/test_edge.py b/tests/edge/test_edge.py index ef1280fa4..6b86d9c1f 100644 --- a/tests/edge/test_edge.py +++ b/tests/edge/test_edge.py @@ -163,8 +163,8 @@ def test_edge_results(edge_conf, mocker, caplog, data) -> None: for c, trade in enumerate(data.trades): res = results.iloc[c] assert res.exit_type == trade.sell_reason - assert arrow.get(res.open_time) == _get_frame_time_from_offset(trade.open_tick) - assert arrow.get(res.close_time) == _get_frame_time_from_offset(trade.close_tick) + assert res.open_time == np.datetime64(_get_frame_time_from_offset(trade.open_tick)) + assert res.close_time == np.datetime64(_get_frame_time_from_offset(trade.close_tick)) def test_adjust(mocker, edge_conf): diff --git a/tests/exchange/test_binance.py b/tests/exchange/test_binance.py index 4bc918c3d..e4599dcd7 100644 --- a/tests/exchange/test_binance.py +++ b/tests/exchange/test_binance.py @@ -9,7 +9,7 @@ from freqtrade.exceptions import (DependencyException, InvalidOrderException, from tests.conftest import get_patched_exchange -def test_stoploss_limit_order(default_conf, mocker): +def test_stoploss_order_binance(default_conf, mocker): api_mock = MagicMock() order_id = 'test_prod_buy_{}'.format(randint(0, 10 ** 6)) order_type = 'stop_loss_limit' @@ -28,46 +28,47 @@ def test_stoploss_limit_order(default_conf, mocker): exchange = get_patched_exchange(mocker, default_conf, api_mock, 'binance') with pytest.raises(OperationalException): - order = exchange.stoploss_limit(pair='ETH/BTC', amount=1, stop_price=190, rate=200) + order = exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=190, + order_types={'stoploss_on_exchange_limit_ratio': 1.05}) api_mock.create_order.reset_mock() - order = exchange.stoploss_limit(pair='ETH/BTC', amount=1, stop_price=220, rate=200) + order = exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) assert 'id' in order assert 'info' in order assert order['id'] == order_id - assert api_mock.create_order.call_args[0][0] == 'ETH/BTC' - assert api_mock.create_order.call_args[0][1] == order_type - assert api_mock.create_order.call_args[0][2] == 'sell' - assert api_mock.create_order.call_args[0][3] == 1 - assert api_mock.create_order.call_args[0][4] == 200 - assert api_mock.create_order.call_args[0][5] == {'stopPrice': 220} + assert api_mock.create_order.call_args_list[0][1]['symbol'] == 'ETH/BTC' + assert api_mock.create_order.call_args_list[0][1]['type'] == order_type + assert api_mock.create_order.call_args_list[0][1]['side'] == 'sell' + assert api_mock.create_order.call_args_list[0][1]['amount'] == 1 + assert api_mock.create_order.call_args_list[0][1]['price'] == 220 + assert api_mock.create_order.call_args_list[0][1]['params'] == {'stopPrice': 220} # test exception handling with pytest.raises(DependencyException): api_mock.create_order = MagicMock(side_effect=ccxt.InsufficientFunds("0 balance")) exchange = get_patched_exchange(mocker, default_conf, api_mock, 'binance') - exchange.stoploss_limit(pair='ETH/BTC', amount=1, stop_price=220, rate=200) + exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) with pytest.raises(InvalidOrderException): api_mock.create_order = MagicMock( side_effect=ccxt.InvalidOrder("binance Order would trigger immediately.")) exchange = get_patched_exchange(mocker, default_conf, api_mock, 'binance') - exchange.stoploss_limit(pair='ETH/BTC', amount=1, stop_price=220, rate=200) + exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) with pytest.raises(TemporaryError): api_mock.create_order = MagicMock(side_effect=ccxt.NetworkError("No connection")) exchange = get_patched_exchange(mocker, default_conf, api_mock, 'binance') - exchange.stoploss_limit(pair='ETH/BTC', amount=1, stop_price=220, rate=200) + exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) with pytest.raises(OperationalException, match=r".*DeadBeef.*"): api_mock.create_order = MagicMock(side_effect=ccxt.BaseError("DeadBeef")) exchange = get_patched_exchange(mocker, default_conf, api_mock, 'binance') - exchange.stoploss_limit(pair='ETH/BTC', amount=1, stop_price=220, rate=200) + exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) -def test_stoploss_limit_order_dry_run(default_conf, mocker): +def test_stoploss_order_dry_run_binance(default_conf, mocker): api_mock = MagicMock() order_type = 'stop_loss_limit' default_conf['dry_run'] = True @@ -77,11 +78,12 @@ def test_stoploss_limit_order_dry_run(default_conf, mocker): exchange = get_patched_exchange(mocker, default_conf, api_mock, 'binance') with pytest.raises(OperationalException): - order = exchange.stoploss_limit(pair='ETH/BTC', amount=1, stop_price=190, rate=200) + order = exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=190, + order_types={'stoploss_on_exchange_limit_ratio': 1.05}) api_mock.create_order.reset_mock() - order = exchange.stoploss_limit(pair='ETH/BTC', amount=1, stop_price=220, rate=200) + order = exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) assert 'id' in order assert 'info' in order @@ -90,3 +92,17 @@ def test_stoploss_limit_order_dry_run(default_conf, mocker): assert order['type'] == order_type assert order['price'] == 220 assert order['amount'] == 1 + + +def test_stoploss_adjust_binance(mocker, default_conf): + exchange = get_patched_exchange(mocker, default_conf, id='binance') + order = { + 'type': 'stop_loss_limit', + 'price': 1500, + 'info': {'stopPrice': 1500}, + } + assert exchange.stoploss_adjust(1501, order) + assert not exchange.stoploss_adjust(1499, order) + # Test with invalid order case + order['type'] = 'stop_loss' + assert not exchange.stoploss_adjust(1501, order) diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 7064d76e1..3830d0acb 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -76,9 +76,11 @@ def test_init_ccxt_kwargs(default_conf, mocker, caplog): mocker.patch('freqtrade.exchange.Exchange.validate_stakecurrency') caplog.set_level(logging.INFO) conf = copy.deepcopy(default_conf) - conf['exchange']['ccxt_async_config'] = {'aiohttp_trust_env': True} + conf['exchange']['ccxt_async_config'] = {'aiohttp_trust_env': True, 'asyncio_loop': True} ex = Exchange(conf) - assert log_has("Applying additional ccxt config: {'aiohttp_trust_env': True}", caplog) + assert log_has( + "Applying additional ccxt config: {'aiohttp_trust_env': True, 'asyncio_loop': True}", + caplog) assert ex._api_async.aiohttp_trust_env assert not ex._api.aiohttp_trust_env @@ -86,6 +88,8 @@ def test_init_ccxt_kwargs(default_conf, mocker, caplog): caplog.clear() conf = copy.deepcopy(default_conf) conf['exchange']['ccxt_config'] = {'TestKWARG': 11} + conf['exchange']['ccxt_async_config'] = {'asyncio_loop': True} + ex = Exchange(conf) assert not log_has("Applying additional ccxt config: {'aiohttp_trust_env': True}", caplog) assert not ex._api_async.aiohttp_trust_env @@ -1117,25 +1121,16 @@ def test_fetch_ticker(default_conf, mocker, exchange_name): assert ticker['bid'] == 0.5 assert ticker['ask'] == 1 - assert 'ETH/BTC' in exchange._cached_ticker - assert exchange._cached_ticker['ETH/BTC']['bid'] == 0.5 - assert exchange._cached_ticker['ETH/BTC']['ask'] == 1 - - # Test caching - api_mock.fetch_ticker = MagicMock() - exchange.fetch_ticker(pair='ETH/BTC', refresh=False) - assert api_mock.fetch_ticker.call_count == 0 - ccxt_exceptionhandlers(mocker, default_conf, api_mock, exchange_name, "fetch_ticker", "fetch_ticker", - pair='ETH/BTC', refresh=True) + pair='ETH/BTC') api_mock.fetch_ticker = MagicMock(return_value={}) exchange = get_patched_exchange(mocker, default_conf, api_mock, id=exchange_name) - exchange.fetch_ticker(pair='ETH/BTC', refresh=True) + exchange.fetch_ticker(pair='ETH/BTC') with pytest.raises(DependencyException, match=r'Pair XRP/ETH not available'): - exchange.fetch_ticker(pair='XRP/ETH', refresh=True) + exchange.fetch_ticker(pair='XRP/ETH') @pytest.mark.parametrize("exchange_name", EXCHANGES) @@ -1758,10 +1753,13 @@ def test_get_fee(default_conf, mocker, exchange_name): 'get_fee', 'calculate_fee', symbol="ETH/BTC") -def test_stoploss_limit_order_unsupported_exchange(default_conf, mocker): +def test_stoploss_order_unsupported_exchange(default_conf, mocker): exchange = get_patched_exchange(mocker, default_conf, 'bittrex') - with pytest.raises(OperationalException, match=r"stoploss_limit is not implemented .*"): - exchange.stoploss_limit(pair='ETH/BTC', amount=1, stop_price=220, rate=200) + with pytest.raises(OperationalException, match=r"stoploss is not implemented .*"): + exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) + + with pytest.raises(OperationalException, match=r"stoploss is not implemented .*"): + exchange.stoploss_adjust(1, {}) def test_merge_ft_has_dict(default_conf, mocker): diff --git a/tests/exchange/test_kraken.py b/tests/exchange/test_kraken.py index 8490ee1a2..d63dd66cc 100644 --- a/tests/exchange/test_kraken.py +++ b/tests/exchange/test_kraken.py @@ -3,6 +3,11 @@ from random import randint from unittest.mock import MagicMock +import ccxt +import pytest + +from freqtrade.exceptions import (DependencyException, InvalidOrderException, + OperationalException, TemporaryError) from tests.conftest import get_patched_exchange from tests.exchange.test_exchange import ccxt_exceptionhandlers @@ -149,3 +154,98 @@ def test_get_balances_prod(default_conf, mocker): assert balances['4ST']['used'] == 0.0 ccxt_exceptionhandlers(mocker, default_conf, api_mock, "kraken", "get_balances", "fetch_balance") + + +def test_stoploss_order_kraken(default_conf, mocker): + api_mock = MagicMock() + order_id = 'test_prod_buy_{}'.format(randint(0, 10 ** 6)) + order_type = 'stop-loss' + + api_mock.create_order = MagicMock(return_value={ + 'id': order_id, + 'info': { + 'foo': 'bar' + } + }) + + default_conf['dry_run'] = False + mocker.patch('freqtrade.exchange.Exchange.amount_to_precision', lambda s, x, y: y) + mocker.patch('freqtrade.exchange.Exchange.price_to_precision', lambda s, x, y: y) + + exchange = get_patched_exchange(mocker, default_conf, api_mock, 'kraken') + + # stoploss_on_exchange_limit_ratio is irrelevant for kraken market orders + order = exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=190, + order_types={'stoploss_on_exchange_limit_ratio': 1.05}) + assert api_mock.create_order.call_count == 1 + + api_mock.create_order.reset_mock() + + order = exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) + + assert 'id' in order + assert 'info' in order + assert order['id'] == order_id + assert api_mock.create_order.call_args_list[0][1]['symbol'] == 'ETH/BTC' + assert api_mock.create_order.call_args_list[0][1]['type'] == 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 + assert api_mock.create_order.call_args_list[0][1]['params'] == {'trading_agreement': 'agree'} + + # test exception handling + with pytest.raises(DependencyException): + api_mock.create_order = MagicMock(side_effect=ccxt.InsufficientFunds("0 balance")) + exchange = get_patched_exchange(mocker, default_conf, api_mock, 'kraken') + exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) + + with pytest.raises(InvalidOrderException): + api_mock.create_order = MagicMock( + side_effect=ccxt.InvalidOrder("kraken Order would trigger immediately.")) + exchange = get_patched_exchange(mocker, default_conf, api_mock, 'kraken') + exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) + + with pytest.raises(TemporaryError): + api_mock.create_order = MagicMock(side_effect=ccxt.NetworkError("No connection")) + exchange = get_patched_exchange(mocker, default_conf, api_mock, 'kraken') + exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) + + with pytest.raises(OperationalException, match=r".*DeadBeef.*"): + api_mock.create_order = MagicMock(side_effect=ccxt.BaseError("DeadBeef")) + exchange = get_patched_exchange(mocker, default_conf, api_mock, 'kraken') + exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) + + +def test_stoploss_order_dry_run_kraken(default_conf, mocker): + api_mock = MagicMock() + order_type = 'stop-loss' + default_conf['dry_run'] = True + mocker.patch('freqtrade.exchange.Exchange.amount_to_precision', lambda s, x, y: y) + mocker.patch('freqtrade.exchange.Exchange.price_to_precision', lambda s, x, y: y) + + exchange = get_patched_exchange(mocker, default_conf, api_mock, 'kraken') + + api_mock.create_order.reset_mock() + + order = exchange.stoploss(pair='ETH/BTC', amount=1, stop_price=220, order_types={}) + + assert 'id' in order + assert 'info' in order + assert 'type' in order + + assert order['type'] == order_type + assert order['price'] == 220 + assert order['amount'] == 1 + + +def test_stoploss_adjust_kraken(mocker, default_conf): + exchange = get_patched_exchange(mocker, default_conf, id='kraken') + order = { + 'type': 'stop-loss', + 'price': 1500, + } + assert exchange.stoploss_adjust(1501, order) + assert not exchange.stoploss_adjust(1499, order) + # Test with invalid order case ... + order['type'] = 'stop_loss_limit' + assert not exchange.stoploss_adjust(1501, order) diff --git a/tests/optimize/__init__.py b/tests/optimize/__init__.py index 8756143a0..13605a38c 100644 --- a/tests/optimize/__init__.py +++ b/tests/optimize/__init__.py @@ -1,4 +1,4 @@ -from typing import Dict, List, NamedTuple +from typing import Dict, List, NamedTuple, Optional import arrow from pandas import DataFrame @@ -23,14 +23,14 @@ class BTContainer(NamedTuple): """ Minimal BacktestContainer defining Backtest inputs and results. """ - data: List[float] + data: List[List[float]] stop_loss: float roi: Dict[str, float] trades: List[BTrade] profit_perc: float trailing_stop: bool = False trailing_only_offset_is_reached: bool = False - trailing_stop_positive: float = None + trailing_stop_positive: Optional[float] = None trailing_stop_positive_offset: float = 0.0 use_sell_signal: bool = False diff --git a/tests/optimize/test_backtest_detail.py b/tests/optimize/test_backtest_detail.py index bd2765430..e7bc76c1d 100644 --- a/tests/optimize/test_backtest_detail.py +++ b/tests/optimize/test_backtest_detail.py @@ -364,7 +364,7 @@ def test_backtest_results(default_conf, fee, mocker, caplog, data) -> None: default_conf["trailing_stop"] = data.trailing_stop default_conf["trailing_only_offset_is_reached"] = data.trailing_only_offset_is_reached # Only add this to configuration If it's necessary - if data.trailing_stop_positive: + if data.trailing_stop_positive is not None: default_conf["trailing_stop_positive"] = data.trailing_stop_positive default_conf["trailing_stop_positive_offset"] = data.trailing_stop_positive_offset default_conf["ask_strategy"] = {"use_sell_signal": data.use_sell_signal} diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index 07872da57..96855dc9d 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -1,6 +1,5 @@ # pragma pylint: disable=missing-docstring, W0212, line-too-long, C0103, unused-argument -import math import random from pathlib import Path from unittest.mock import MagicMock @@ -15,13 +14,13 @@ 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 parse_ticker_dataframe +from freqtrade.data.converter import clean_ohlcv_dataframe from freqtrade.data.dataprovider import DataProvider from freqtrade.data.history import get_timerange from freqtrade.exceptions import DependencyException, OperationalException from freqtrade.optimize.backtesting import Backtesting +from freqtrade.resolvers import StrategyResolver from freqtrade.state import RunMode -from freqtrade.strategy.default_strategy import DefaultStrategy from freqtrade.strategy.interface import SellType from tests.conftest import (get_args, log_has, log_has_re, patch_exchange, patched_configuration_load_config_file) @@ -50,47 +49,33 @@ def trim_dictlist(dict_list, num): def load_data_test(what, testdatadir): timerange = TimeRange.parse_timerange('1510694220-1510700340') - pair = history.load_tickerdata_file(testdatadir, timeframe='1m', - pair='UNITTEST/BTC', timerange=timerange) - datalen = len(pair) + data = history.load_pair_history(pair='UNITTEST/BTC', datadir=testdatadir, + timeframe='1m', timerange=timerange, + drop_incomplete=False, + fill_up_missing=False) base = 0.001 if what == 'raise': - data = [ - [ - pair[x][0], # Keep old dates - x * base, # But replace O,H,L,C - x * base + 0.0001, - x * base - 0.0001, - x * base, - pair[x][5], # Keep old volume - ] for x in range(0, datalen) - ] + data.loc[:, 'open'] = data.index * base + data.loc[:, 'high'] = data.index * base + 0.0001 + data.loc[:, 'low'] = data.index * base - 0.0001 + data.loc[:, 'close'] = data.index * base + if what == 'lower': - data = [ - [ - pair[x][0], # Keep old dates - 1 - x * base, # But replace O,H,L,C - 1 - x * base + 0.0001, - 1 - x * base - 0.0001, - 1 - x * base, - pair[x][5] # Keep old volume - ] for x in range(0, datalen) - ] + data.loc[:, 'open'] = 1 - data.index * base + data.loc[:, 'high'] = 1 - data.index * base + 0.0001 + data.loc[:, 'low'] = 1 - data.index * base - 0.0001 + data.loc[:, 'close'] = 1 - data.index * base + if what == 'sine': hz = 0.1 # frequency - data = [ - [ - pair[x][0], # Keep old dates - math.sin(x * hz) / 1000 + base, # But replace O,H,L,C - math.sin(x * hz) / 1000 + base + 0.0001, - math.sin(x * hz) / 1000 + base - 0.0001, - math.sin(x * hz) / 1000 + base, - pair[x][5] # Keep old volume - ] for x in range(0, datalen) - ] - return {'UNITTEST/BTC': parse_ticker_dataframe(data, '1m', pair="UNITTEST/BTC", - fill_missing=True)} + data.loc[:, 'open'] = np.sin(data.index * hz) / 1000 + base + data.loc[:, 'high'] = np.sin(data.index * hz) / 1000 + base + 0.0001 + data.loc[:, 'low'] = np.sin(data.index * hz) / 1000 + base - 0.0001 + data.loc[:, 'close'] = np.sin(data.index * hz) / 1000 + base + + return {'UNITTEST/BTC': clean_ohlcv_dataframe(data, timeframe='1m', pair='UNITTEST/BTC', + fill_missing=True)} def simple_backtest(config, contour, num_results, mocker, testdatadir) -> None: @@ -114,21 +99,6 @@ def simple_backtest(config, contour, num_results, mocker, testdatadir) -> None: assert len(results) == num_results -def mocked_load_data(datadir, pairs=[], timeframe='0m', - timerange=None, *args, **kwargs): - tickerdata = history.load_tickerdata_file(datadir, 'UNITTEST/BTC', '1m', timerange=timerange) - pairdata = {'UNITTEST/BTC': parse_ticker_dataframe(tickerdata, '1m', pair="UNITTEST/BTC", - fill_missing=True)} - return pairdata - - -# use for mock ccxt.fetch_ohlvc' -def _load_pair_as_ticks(pair, tickfreq): - ticks = history.load_tickerdata_file(None, timeframe=tickfreq, pair=pair) - ticks = ticks[-201:] - return ticks - - # FIX: fixturize this? def _make_backtest_conf(mocker, datadir, conf=None, pair='UNITTEST/BTC'): data = history.load_data(datadir=datadir, timeframe='1m', pairs=[pair]) @@ -287,8 +257,8 @@ def test_start(mocker, fee, default_conf, caplog) -> None: '--config', 'config.json', '--strategy', 'DefaultStrategy', ] - args = get_args(args) - start_backtesting(args) + pargs = get_args(args) + start_backtesting(pargs) assert log_has('Starting freqtrade in Backtesting mode', caplog) assert start_mock.call_count == 1 @@ -339,18 +309,17 @@ def test_tickerdata_with_fee(default_conf, mocker, testdatadir) -> None: def test_tickerdata_to_dataframe_bt(default_conf, mocker, testdatadir) -> None: patch_exchange(mocker) - # timerange = TimeRange(None, 'line', 0, -100) timerange = TimeRange.parse_timerange('1510694220-1510700340') - tick = history.load_tickerdata_file(testdatadir, 'UNITTEST/BTC', '1m', timerange=timerange) - tickerlist = {'UNITTEST/BTC': parse_ticker_dataframe(tick, '1m', pair="UNITTEST/BTC", - fill_missing=True)} - + tickerlist = history.load_data(testdatadir, '1m', ['UNITTEST/BTC'], timerange=timerange, + fill_up_missing=True) backtesting = Backtesting(default_conf) data = backtesting.strategy.tickerdata_to_dataframe(tickerlist) assert len(data['UNITTEST/BTC']) == 102 # Load strategy to compare the result between Backtesting function and strategy are the same - strategy = DefaultStrategy(default_conf) + default_conf.update({'strategy': 'DefaultStrategy'}) + strategy = StrategyResolver.load_strategy(default_conf) + data2 = strategy.tickerdata_to_dataframe(tickerlist) assert data['UNITTEST/BTC'].equals(data2['UNITTEST/BTC']) @@ -359,7 +328,6 @@ def test_backtesting_start(default_conf, mocker, testdatadir, caplog) -> None: def get_timerange(input1): return Arrow(2017, 11, 14, 21, 17), Arrow(2017, 11, 14, 22, 59) - mocker.patch('freqtrade.data.history.load_data', mocked_load_data) mocker.patch('freqtrade.data.history.get_timerange', get_timerange) mocker.patch('freqtrade.exchange.Exchange.refresh_latest_ohlcv', MagicMock()) patch_exchange(mocker) @@ -389,7 +357,8 @@ def test_backtesting_start_no_data(default_conf, mocker, caplog, testdatadir) -> def get_timerange(input1): return Arrow(2017, 11, 14, 21, 17), Arrow(2017, 11, 14, 22, 59) - mocker.patch('freqtrade.data.history.load_pair_history', MagicMock(return_value=pd.DataFrame())) + mocker.patch('freqtrade.data.history.history_utils.load_pair_history', + MagicMock(return_value=pd.DataFrame())) mocker.patch('freqtrade.data.history.get_timerange', get_timerange) mocker.patch('freqtrade.exchange.Exchange.refresh_latest_ohlcv', MagicMock()) patch_exchange(mocker) @@ -693,13 +662,7 @@ def test_backtest_record(default_conf, fee, mocker): def test_backtest_start_timerange(default_conf, mocker, caplog, testdatadir): default_conf['exchange']['pair_whitelist'] = ['UNITTEST/BTC'] - async def load_pairs(pair, timeframe, since): - return _load_pair_as_ticks(pair, timeframe) - - api_mock = MagicMock() - api_mock.fetch_ohlcv = load_pairs - - patch_exchange(mocker, api_mock) + patch_exchange(mocker) mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest', MagicMock()) mocker.patch('freqtrade.optimize.backtesting.generate_text_table', MagicMock()) @@ -739,12 +702,7 @@ def test_backtest_start_timerange(default_conf, mocker, caplog, testdatadir): def test_backtest_start_multi_strat(default_conf, mocker, caplog, testdatadir): default_conf['exchange']['pair_whitelist'] = ['UNITTEST/BTC'] - async def load_pairs(pair, timeframe, since): - return _load_pair_as_ticks(pair, timeframe) - api_mock = MagicMock() - api_mock.fetch_ohlcv = load_pairs - - patch_exchange(mocker, api_mock) + patch_exchange(mocker) backtestmock = MagicMock() mocker.patch('freqtrade.optimize.backtesting.Backtesting.backtest', backtestmock) gen_table_mock = MagicMock() @@ -757,14 +715,14 @@ def test_backtest_start_multi_strat(default_conf, mocker, caplog, testdatadir): 'backtesting', '--config', 'config.json', '--datadir', str(testdatadir), - '--strategy-path', str(Path(__file__).parents[2] / 'freqtrade/templates'), + '--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', - 'SampleStrategy', + 'TestStrategyLegacy', ] args = get_args(args) start_backtesting(args) @@ -787,7 +745,7 @@ def test_backtest_start_multi_strat(default_conf, mocker, caplog, testdatadir): '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 SampleStrategy', + 'Running backtesting for Strategy TestStrategyLegacy', ] for line in exists: diff --git a/tests/optimize/test_edge_cli.py b/tests/optimize/test_edge_cli.py index 96dd0899d..a5e468542 100644 --- a/tests/optimize/test_edge_cli.py +++ b/tests/optimize/test_edge_cli.py @@ -82,8 +82,8 @@ def test_start(mocker, fee, edge_conf, caplog) -> None: '--config', 'config.json', '--strategy', 'DefaultStrategy', ] - args = get_args(args) - start_edge(args) + pargs = get_args(args) + start_edge(pargs) assert log_has('Starting freqtrade in Edge mode', caplog) assert start_mock.call_count == 1 diff --git a/tests/optimize/test_hyperopt.py b/tests/optimize/test_hyperopt.py index 69d110649..e3212e0cd 100644 --- a/tests/optimize/test_hyperopt.py +++ b/tests/optimize/test_hyperopt.py @@ -2,6 +2,7 @@ import locale from datetime import datetime from pathlib import Path +from typing import Dict, List from unittest.mock import MagicMock, PropertyMock import pandas as pd @@ -9,9 +10,9 @@ import pytest from arrow import Arrow from filelock import Timeout -from freqtrade.commands.optimize_commands import setup_optimize_configuration, start_hyperopt -from freqtrade.data.converter import parse_ticker_dataframe -from freqtrade.data.history import load_tickerdata_file +from freqtrade.commands.optimize_commands import (setup_optimize_configuration, + start_hyperopt) +from freqtrade.data.history import load_data from freqtrade.exceptions import OperationalException from freqtrade.optimize.default_hyperopt import DefaultHyperOpt from freqtrade.optimize.default_hyperopt_loss import DefaultHyperOptLoss @@ -42,13 +43,19 @@ def hyperopt_results(): 'profit_percent': [-0.1, 0.2, 0.3], 'profit_abs': [-0.2, 0.4, 0.6], 'trade_duration': [10, 30, 10], - 'sell_reason': [SellType.STOP_LOSS, SellType.ROI, SellType.ROI] + 'sell_reason': [SellType.STOP_LOSS, SellType.ROI, SellType.ROI], + 'close_time': + [ + datetime(2019, 1, 1, 9, 26, 3, 478039), + datetime(2019, 2, 1, 9, 26, 3, 478039), + datetime(2019, 3, 1, 9, 26, 3, 478039) + ] } ) # Functions for recurrent object patching -def create_trials(mocker, hyperopt, testdatadir) -> None: +def create_trials(mocker, hyperopt, testdatadir) -> List[Dict]: """ When creating trials, mock the hyperopt Trials so that *by default* - we don't create any pickle'd files in the filesystem @@ -222,10 +229,10 @@ def test_start_not_installed(mocker, default_conf, caplog, import_fails) -> None '--hyperopt', 'DefaultHyperOpt', '--epochs', '5' ] - args = get_args(args) + pargs = get_args(args) with pytest.raises(OperationalException, match=r"Please ensure that the hyperopt dependencies"): - start_hyperopt(args) + start_hyperopt(pargs) def test_start(mocker, default_conf, caplog) -> None: @@ -240,8 +247,8 @@ def test_start(mocker, default_conf, caplog) -> None: '--hyperopt', 'DefaultHyperOpt', '--epochs', '5' ] - args = get_args(args) - start_hyperopt(args) + pargs = get_args(args) + start_hyperopt(pargs) assert log_has('Starting freqtrade in Hyperopt mode', caplog) assert start_mock.call_count == 1 @@ -263,9 +270,9 @@ def test_start_no_data(mocker, default_conf, caplog) -> None: '--hyperopt', 'DefaultHyperOpt', '--epochs', '5' ] - args = get_args(args) + pargs = get_args(args) with pytest.raises(OperationalException, match='No data found. Terminating.'): - start_hyperopt(args) + start_hyperopt(pargs) def test_start_filelock(mocker, default_conf, caplog) -> None: @@ -280,16 +287,19 @@ def test_start_filelock(mocker, default_conf, caplog) -> None: '--hyperopt', 'DefaultHyperOpt', '--epochs', '5' ] - args = get_args(args) - start_hyperopt(args) + pargs = get_args(args) + start_hyperopt(pargs) assert log_has("Another running instance of freqtrade Hyperopt detected.", caplog) def test_loss_calculation_prefer_correct_trade_count(default_conf, hyperopt_results) -> None: hl = HyperOptLossResolver.load_hyperoptloss(default_conf) - correct = hl.hyperopt_loss_function(hyperopt_results, 600) - over = hl.hyperopt_loss_function(hyperopt_results, 600 + 100) - under = hl.hyperopt_loss_function(hyperopt_results, 600 - 100) + correct = hl.hyperopt_loss_function(hyperopt_results, 600, + datetime(2019, 1, 1), datetime(2019, 5, 1)) + over = hl.hyperopt_loss_function(hyperopt_results, 600 + 100, + datetime(2019, 1, 1), datetime(2019, 5, 1)) + under = hl.hyperopt_loss_function(hyperopt_results, 600 - 100, + datetime(2019, 1, 1), datetime(2019, 5, 1)) assert over > correct assert under > correct @@ -299,8 +309,10 @@ def test_loss_calculation_prefer_shorter_trades(default_conf, hyperopt_results) resultsb.loc[1, 'trade_duration'] = 20 hl = HyperOptLossResolver.load_hyperoptloss(default_conf) - longer = hl.hyperopt_loss_function(hyperopt_results, 100) - shorter = hl.hyperopt_loss_function(resultsb, 100) + longer = hl.hyperopt_loss_function(hyperopt_results, 100, + datetime(2019, 1, 1), datetime(2019, 5, 1)) + shorter = hl.hyperopt_loss_function(resultsb, 100, + datetime(2019, 1, 1), datetime(2019, 5, 1)) assert shorter < longer @@ -311,9 +323,12 @@ def test_loss_calculation_has_limited_profit(default_conf, hyperopt_results) -> results_under['profit_percent'] = hyperopt_results['profit_percent'] / 2 hl = HyperOptLossResolver.load_hyperoptloss(default_conf) - correct = hl.hyperopt_loss_function(hyperopt_results, 600) - over = hl.hyperopt_loss_function(results_over, 600) - under = hl.hyperopt_loss_function(results_under, 600) + correct = hl.hyperopt_loss_function(hyperopt_results, 600, + datetime(2019, 1, 1), datetime(2019, 5, 1)) + over = hl.hyperopt_loss_function(results_over, 600, + datetime(2019, 1, 1), datetime(2019, 5, 1)) + under = hl.hyperopt_loss_function(results_under, 600, + datetime(2019, 1, 1), datetime(2019, 5, 1)) assert over < correct assert under > correct @@ -336,6 +351,24 @@ def test_sharpe_loss_prefers_higher_profits(default_conf, hyperopt_results) -> N assert under > correct +def test_sharpe_loss_daily_prefers_higher_profits(default_conf, hyperopt_results) -> None: + results_over = hyperopt_results.copy() + results_over['profit_percent'] = hyperopt_results['profit_percent'] * 2 + results_under = hyperopt_results.copy() + results_under['profit_percent'] = hyperopt_results['profit_percent'] / 2 + + default_conf.update({'hyperopt_loss': 'SharpeHyperOptLossDaily'}) + hl = HyperOptLossResolver.load_hyperoptloss(default_conf) + correct = hl.hyperopt_loss_function(hyperopt_results, len(hyperopt_results), + datetime(2019, 1, 1), datetime(2019, 5, 1)) + over = hl.hyperopt_loss_function(results_over, len(hyperopt_results), + datetime(2019, 1, 1), datetime(2019, 5, 1)) + under = hl.hyperopt_loss_function(results_under, len(hyperopt_results), + datetime(2019, 1, 1), datetime(2019, 5, 1)) + assert over < correct + assert under > correct + + def test_onlyprofit_loss_prefers_higher_profits(default_conf, hyperopt_results) -> None: results_over = hyperopt_results.copy() results_over['profit_percent'] = hyperopt_results['profit_percent'] * 2 @@ -543,9 +576,7 @@ def test_has_space(hyperopt, spaces, expected_results): def test_populate_indicators(hyperopt, testdatadir) -> None: - tick = load_tickerdata_file(testdatadir, 'UNITTEST/BTC', '1m') - tickerlist = {'UNITTEST/BTC': parse_ticker_dataframe(tick, '1m', pair="UNITTEST/BTC", - fill_missing=True)} + tickerlist = load_data(testdatadir, '1m', ['UNITTEST/BTC'], fill_up_missing=True) dataframes = hyperopt.backtesting.strategy.tickerdata_to_dataframe(tickerlist) dataframe = hyperopt.custom_hyperopt.populate_indicators(dataframes['UNITTEST/BTC'], {'pair': 'UNITTEST/BTC'}) @@ -557,9 +588,7 @@ def test_populate_indicators(hyperopt, testdatadir) -> None: def test_buy_strategy_generator(hyperopt, testdatadir) -> None: - tick = load_tickerdata_file(testdatadir, 'UNITTEST/BTC', '1m') - tickerlist = {'UNITTEST/BTC': parse_ticker_dataframe(tick, '1m', pair="UNITTEST/BTC", - fill_missing=True)} + tickerlist = load_data(testdatadir, '1m', ['UNITTEST/BTC'], fill_up_missing=True) dataframes = hyperopt.backtesting.strategy.tickerdata_to_dataframe(tickerlist) dataframe = hyperopt.custom_hyperopt.populate_indicators(dataframes['UNITTEST/BTC'], {'pair': 'UNITTEST/BTC'}) diff --git a/tests/optimize/test_optimize_reports.py b/tests/optimize/test_optimize_reports.py index 518b50d0f..57e928cca 100644 --- a/tests/optimize/test_optimize_reports.py +++ b/tests/optimize/test_optimize_reports.py @@ -15,20 +15,21 @@ def test_generate_text_table(default_conf, mocker): 'profit_percent': [0.1, 0.2], 'profit_abs': [0.2, 0.4], 'trade_duration': [10, 30], - 'profit': [2, 0], - 'loss': [0, 0] + 'wins': [2, 0], + 'draws': [0, 0], + 'losses': [0, 0] } ) result_str = ( - '| pair | buy count | avg profit % | cum profit % | ' - 'tot profit BTC | tot profit % | avg duration | profit | loss |\n' - '|:--------|------------:|---------------:|---------------:|' - '-----------------:|---------------:|:---------------|---------:|-------:|\n' - '| ETH/BTC | 2 | 15.00 | 30.00 | ' - '0.60000000 | 15.00 | 0:20:00 | 2 | 0 |\n' - '| TOTAL | 2 | 15.00 | 30.00 | ' - '0.60000000 | 15.00 | 0:20:00 | 2 | 0 |' + '| Pair | Buys | Avg Profit % | Cum Profit % | Tot Profit BTC |' + ' Tot Profit % | Avg Duration | Wins | Draws | Losses |\n' + '|:--------|-------:|---------------:|---------------:|-----------------:|' + '---------------:|:---------------|-------:|--------:|---------:|\n' + '| ETH/BTC | 2 | 15.00 | 30.00 | 0.60000000 |' + ' 15.00 | 0:20:00 | 2 | 0 | 0 |\n' + '| 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, @@ -43,56 +44,65 @@ def test_generate_text_table_sell_reason(default_conf, mocker): 'profit_percent': [0.1, 0.2, -0.1], 'profit_abs': [0.2, 0.4, -0.2], 'trade_duration': [10, 30, 10], - 'profit': [2, 0, 0], - 'loss': [0, 0, 1], + 'wins': [2, 0, 0], + 'draws': [0, 0, 0], + 'losses': [0, 0, 1], 'sell_reason': [SellType.ROI, SellType.ROI, SellType.STOP_LOSS] } ) result_str = ( - '| Sell Reason | Count | Profit | Loss | Profit % |\n' - '|:--------------|--------:|---------:|-------:|-----------:|\n' - '| roi | 2 | 2 | 0 | 15 |\n' - '| stop_loss | 1 | 0 | 1 | -10 |' + '| Sell Reason | Sells | Wins | Draws | Losses |' + ' Avg Profit % | Cum Profit % | Tot Profit BTC | Tot Profit % |\n' + '|:--------------|--------:|-------:|--------:|---------:|' + '---------------:|---------------:|-----------------:|---------------:|\n' + '| roi | 2 | 2 | 0 | 0 |' + ' 15 | 30 | 0.6 | 15 |\n' + '| stop_loss | 1 | 0 | 0 | 1 |' + ' -10 | -10 | -0.2 | -5 |' ) assert generate_text_table_sell_reason( - data={'ETH/BTC': {}}, results=results) == result_str + data={'ETH/BTC': {}}, + stake_currency='BTC', max_open_trades=2, + results=results) == result_str def test_generate_text_table_strategy(default_conf, mocker): results = {} - results['ETH/BTC'] = pd.DataFrame( + results['TestStrategy1'] = pd.DataFrame( { 'pair': ['ETH/BTC', 'ETH/BTC', 'ETH/BTC'], 'profit_percent': [0.1, 0.2, 0.3], 'profit_abs': [0.2, 0.4, 0.5], 'trade_duration': [10, 30, 10], - 'profit': [2, 0, 0], - 'loss': [0, 0, 1], + 'wins': [2, 0, 0], + 'draws': [0, 0, 0], + 'losses': [0, 0, 1], 'sell_reason': [SellType.ROI, SellType.ROI, SellType.STOP_LOSS] } ) - results['LTC/BTC'] = pd.DataFrame( + results['TestStrategy2'] = pd.DataFrame( { 'pair': ['LTC/BTC', 'LTC/BTC', 'LTC/BTC'], 'profit_percent': [0.4, 0.2, 0.3], 'profit_abs': [0.4, 0.4, 0.5], 'trade_duration': [15, 30, 15], - 'profit': [4, 1, 0], - 'loss': [0, 0, 1], + 'wins': [4, 1, 0], + 'draws': [0, 0, 0], + 'losses': [0, 0, 1], 'sell_reason': [SellType.ROI, SellType.ROI, SellType.STOP_LOSS] } ) result_str = ( - '| Strategy | buy count | avg profit % | cum profit % ' - '| tot profit BTC | tot profit % | avg duration | profit | loss |\n' - '|:-----------|------------:|---------------:|---------------:' - '|-----------------:|---------------:|:---------------|---------:|-------:|\n' - '| ETH/BTC | 3 | 20.00 | 60.00 ' - '| 1.10000000 | 30.00 | 0:17:00 | 3 | 0 |\n' - '| LTC/BTC | 3 | 30.00 | 90.00 ' - '| 1.30000000 | 45.00 | 0:20:00 | 3 | 0 |' + '| Strategy | Buys | Avg Profit % | Cum Profit % | Tot' + ' Profit BTC | Tot Profit % | Avg Duration | Wins | Draws | Losses |\n' + '|:--------------|-------:|---------------:|---------------:|------' + '-----------:|---------------:|:---------------|-------:|--------:|---------:|\n' + '| TestStrategy1 | 3 | 20.00 | 60.00 | ' + ' 1.10000000 | 30.00 | 0:17:00 | 3 | 0 | 0 |\n' + '| 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 @@ -105,4 +115,4 @@ def test_generate_edge_table(edge_conf, mocker): assert generate_edge_table(results).count(':|') == 7 assert generate_edge_table(results).count('| ETH/BTC |') == 1 assert generate_edge_table(results).count( - '| risk reward ratio | required risk reward | expectancy |') == 1 + '| Risk Reward Ratio | Required Risk Reward | Expectancy |') == 1 diff --git a/tests/pairlist/test_pairlist.py b/tests/pairlist/test_pairlist.py index ac4cbc813..b8a4be037 100644 --- a/tests/pairlist/test_pairlist.py +++ b/tests/pairlist/test_pairlist.py @@ -141,7 +141,7 @@ def test_VolumePairList_refresh_empty(mocker, markets_empty, whitelist_conf): ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "bidVolume"}], "BTC", ['HOT/BTC', 'FUEL/BTC', 'XRP/BTC', 'LTC/BTC', 'TKN/BTC']), ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}], - "USDT", ['ETH/USDT']), + "USDT", ['ETH/USDT', 'NANO/USDT']), # No pair for ETH ... ([{"method": "VolumePairList", "number_assets": 5, "sort_key": "quoteVolume"}], "ETH", []), @@ -160,6 +160,10 @@ def test_VolumePairList_refresh_empty(mocker, markets_empty, whitelist_conf): {"method": "PrecisionFilter"}, {"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']), @@ -167,6 +171,10 @@ def test_VolumePairList_refresh_empty(mocker, markets_empty, whitelist_conf): ([{"method": "StaticPairList"}, {"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']), ]) def test_VolumePairList_whitelist_gen(mocker, whitelist_conf, shitcoinmarkets, tickers, pairlists, base_currency, whitelist_result, diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index 36fce1797..40b2d6627 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -65,10 +65,8 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: 'open_order': '(limit buy rem=0.00000000)' } == results[0] - mocker.patch('freqtrade.exchange.Exchange.fetch_ticker', + mocker.patch('freqtrade.freqtradebot.FreqtradeBot.get_sell_rate', MagicMock(side_effect=DependencyException(f"Pair 'ETH/BTC' not available"))) - # invalidate ticker cache - rpc._freqtrade.exchange._cached_ticker = {} results = rpc._rpc_trade_status() assert isnan(results[0]['current_profit']) assert isnan(results[0]['current_rate']) @@ -122,7 +120,7 @@ def test_rpc_status_table(default_conf, ticker, fee, mocker) -> None: assert "Since" in headers assert "Pair" in headers assert 'instantly' == result[0][2] - assert 'ETH/BTC' == result[0][1] + assert 'ETH/BTC' in result[0][1] assert '-0.59%' == result[0][3] # Test with fiatconvert @@ -131,16 +129,14 @@ def test_rpc_status_table(default_conf, ticker, fee, mocker) -> None: assert "Since" in headers assert "Pair" in headers assert 'instantly' == result[0][2] - assert 'ETH/BTC' == result[0][1] + assert 'ETH/BTC' in result[0][1] assert '-0.59% (-0.09)' == result[0][3] - mocker.patch('freqtrade.exchange.Exchange.fetch_ticker', + mocker.patch('freqtrade.freqtradebot.FreqtradeBot.get_sell_rate', MagicMock(side_effect=DependencyException(f"Pair 'ETH/BTC' not available"))) - # invalidate ticker cache - rpc._freqtrade.exchange._cached_ticker = {} result, headers = rpc._rpc_status_table(default_conf['stake_currency'], 'USD') assert 'instantly' == result[0][2] - assert 'ETH/BTC' == result[0][1] + assert 'ETH/BTC' in result[0][1] assert 'nan%' == result[0][3] @@ -260,10 +256,8 @@ def test_rpc_trade_statistics(default_conf, ticker, ticker_sell_up, fee, assert prec_satoshi(stats['best_rate'], 6.2) # Test non-available pair - mocker.patch('freqtrade.exchange.Exchange.fetch_ticker', + mocker.patch('freqtrade.freqtradebot.FreqtradeBot.get_sell_rate', MagicMock(side_effect=DependencyException(f"Pair 'ETH/BTC' not available"))) - # invalidate ticker cache - rpc._freqtrade.exchange._cached_ticker = {} 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_telegram.py b/tests/rpc/test_rpc_telegram.py index ffc29ee12..a8b8e0c5a 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -284,7 +284,7 @@ def test_status_table_handle(default_conf, update, ticker, fee, mocker) -> None: fields = re.sub('[ ]+', ' ', line[2].strip()).split(' ') assert int(fields[0]) == 1 - assert fields[1] == 'ETH/BTC' + assert 'ETH/BTC' in fields[1] assert msg_mock.call_count == 1 @@ -1200,12 +1200,35 @@ def test_send_msg_buy_notification(default_conf, mocker) -> None: 'stake_amount': 0.001, 'stake_amount_fiat': 0.0, 'stake_currency': 'BTC', - 'fiat_currency': 'USD' + 'fiat_currency': 'USD', + 'current_rate': 1.099e-05, + 'amount': 1333.3333333333335, + 'open_date': arrow.utcnow().shift(hours=-1) }) assert msg_mock.call_args[0][0] \ == '*Bittrex:* Buying ETH/BTC\n' \ - 'at rate `0.00001099\n' \ - '(0.001000 BTC,0.000 USD)`' + '*Amount:* `1333.33333333`\n' \ + '*Open Rate:* `0.00001099`\n' \ + '*Current Rate:* `0.00001099`\n' \ + '*Total:* `(0.001000 BTC, 0.000 USD)`' + + +def test_send_msg_buy_cancel_notification(default_conf, mocker) -> None: + msg_mock = MagicMock() + mocker.patch.multiple( + 'freqtrade.rpc.telegram.Telegram', + _init=MagicMock(), + _send_msg=msg_mock + ) + freqtradebot = get_patched_freqtradebot(mocker, default_conf) + telegram = Telegram(freqtradebot) + telegram.send_msg({ + 'type': RPCMessageType.BUY_CANCEL_NOTIFICATION, + 'exchange': 'Bittrex', + 'pair': 'ETH/BTC', + }) + assert msg_mock.call_args[0][0] \ + == ('*Bittrex:* Cancelling Open Buy Order for ETH/BTC') def test_send_msg_sell_notification(default_conf, mocker) -> None: @@ -1239,13 +1262,13 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None: }) assert msg_mock.call_args[0][0] \ == ('*Binance:* Selling KEY/ETH\n' - '*Rate:* `0.00003201`\n' '*Amount:* `1333.33333333`\n' '*Open Rate:* `0.00007500`\n' '*Current Rate:* `0.00003201`\n' + '*Close Rate:* `0.00003201`\n' '*Sell Reason:* `stop_loss`\n' '*Duration:* `1:00:00 (60.0 min)`\n' - '*Profit:* `-57.41%`` (loss: -0.05746268 ETH`` / -24.812 USD)`') + '*Profit:* `-57.41%` `(loss: -0.05746268 ETH / -24.812 USD)`') msg_mock.reset_mock() telegram.send_msg({ @@ -1267,10 +1290,10 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None: }) assert msg_mock.call_args[0][0] \ == ('*Binance:* Selling KEY/ETH\n' - '*Rate:* `0.00003201`\n' '*Amount:* `1333.33333333`\n' '*Open Rate:* `0.00007500`\n' '*Current Rate:* `0.00003201`\n' + '*Close Rate:* `0.00003201`\n' '*Sell Reason:* `stop_loss`\n' '*Duration:* `1 day, 2:30:00 (1590.0 min)`\n' '*Profit:* `-57.41%`') @@ -1278,6 +1301,37 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None: telegram._fiat_converter.convert_amount = old_convamount +def test_send_msg_sell_cancel_notification(default_conf, mocker) -> None: + msg_mock = MagicMock() + mocker.patch.multiple( + 'freqtrade.rpc.telegram.Telegram', + _init=MagicMock(), + _send_msg=msg_mock + ) + freqtradebot = get_patched_freqtradebot(mocker, default_conf) + telegram = Telegram(freqtradebot) + old_convamount = telegram._fiat_converter.convert_amount + telegram._fiat_converter.convert_amount = lambda a, b, c: -24.812 + telegram.send_msg({ + 'type': RPCMessageType.SELL_CANCEL_NOTIFICATION, + 'exchange': 'Binance', + 'pair': 'KEY/ETH', + }) + assert msg_mock.call_args[0][0] \ + == ('*Binance:* Cancelling Open Sell Order for KEY/ETH') + + msg_mock.reset_mock() + telegram.send_msg({ + 'type': RPCMessageType.SELL_CANCEL_NOTIFICATION, + 'exchange': 'Binance', + 'pair': 'KEY/ETH', + }) + assert msg_mock.call_args[0][0] \ + == ('*Binance:* Cancelling Open Sell Order for KEY/ETH') + # Reset singleton function to avoid random breaks + telegram._fiat_converter.convert_amount = old_convamount + + def test_send_msg_status_notification(default_conf, mocker) -> None: msg_mock = MagicMock() mocker.patch.multiple( @@ -1360,12 +1414,17 @@ def test_send_msg_buy_notification_no_fiat(default_conf, mocker) -> None: 'stake_amount': 0.001, 'stake_amount_fiat': 0.0, 'stake_currency': 'BTC', - 'fiat_currency': None + 'fiat_currency': None, + 'current_rate': 1.099e-05, + 'amount': 1333.3333333333335, + 'open_date': arrow.utcnow().shift(hours=-1) }) assert msg_mock.call_args[0][0] \ == '*Bittrex:* Buying ETH/BTC\n' \ - 'at rate `0.00001099\n' \ - '(0.001000 BTC)`' + '*Amount:* `1333.33333333`\n' \ + '*Open Rate:* `0.00001099`\n' \ + '*Current Rate:* `0.00001099`\n' \ + '*Total:* `(0.001000 BTC)`' def test_send_msg_sell_notification_no_fiat(default_conf, mocker) -> None: @@ -1398,10 +1457,10 @@ def test_send_msg_sell_notification_no_fiat(default_conf, mocker) -> None: }) assert msg_mock.call_args[0][0] \ == '*Binance:* Selling KEY/ETH\n' \ - '*Rate:* `0.00003201`\n' \ '*Amount:* `1333.33333333`\n' \ '*Open Rate:* `0.00007500`\n' \ '*Current Rate:* `0.00003201`\n' \ + '*Close Rate:* `0.00003201`\n' \ '*Sell Reason:* `stop_loss`\n' \ '*Duration:* `2:35:03 (155.1 min)`\n' \ '*Profit:* `-57.41%`' diff --git a/tests/rpc/test_rpc_webhook.py b/tests/rpc/test_rpc_webhook.py index c066aa8e7..3f3f36766 100644 --- a/tests/rpc/test_rpc_webhook.py +++ b/tests/rpc/test_rpc_webhook.py @@ -13,24 +13,34 @@ from tests.conftest import get_patched_freqtradebot, log_has def get_webhook_dict() -> dict: return { - "enabled": True, - "url": "https://maker.ifttt.com/trigger/freqtrade_test/with/key/c764udvJ5jfSlswVRukZZ2/", - "webhookbuy": { - "value1": "Buying {pair}", - "value2": "limit {limit:8f}", - "value3": "{stake_amount:8f} {stake_currency}" - }, - "webhooksell": { - "value1": "Selling {pair}", - "value2": "limit {limit:8f}", - "value3": "profit: {profit_amount:8f} {stake_currency}" - }, - "webhookstatus": { - "value1": "Status: {status}", - "value2": "", - "value3": "" - } - } + "enabled": True, + "url": "https://maker.ifttt.com/trigger/freqtrade_test/with/key/c764udvJ5jfSlswVRukZZ2/", + "webhookbuy": { + "value1": "Buying {pair}", + "value2": "limit {limit:8f}", + "value3": "{stake_amount:8f} {stake_currency}" + }, + "webhookbuycancel": { + "value1": "Cancelling Open Buy Order for {pair}", + "value2": "limit {limit:8f}", + "value3": "{stake_amount:8f} {stake_currency}" + }, + "webhooksell": { + "value1": "Selling {pair}", + "value2": "limit {limit:8f}", + "value3": "profit: {profit_amount:8f} {stake_currency}" + }, + "webhooksellcancel": { + "value1": "Cancelling Open Sell Order for {pair}", + "value2": "limit {limit:8f}", + "value3": "profit: {profit_amount:8f} {stake_currency}" + }, + "webhookstatus": { + "value1": "Status: {status}", + "value2": "", + "value3": "" + } + } def test__init__(mocker, default_conf): @@ -44,6 +54,9 @@ def test_send_msg(default_conf, mocker): msg_mock = MagicMock() mocker.patch("freqtrade.rpc.webhook.Webhook._send_msg", msg_mock) webhook = Webhook(get_patched_freqtradebot(mocker, default_conf)) + # Test buy + msg_mock = MagicMock() + mocker.patch("freqtrade.rpc.webhook.Webhook._send_msg", msg_mock) msg = { 'type': RPCMessageType.BUY_NOTIFICATION, 'exchange': 'Bittrex', @@ -54,8 +67,6 @@ def test_send_msg(default_conf, mocker): 'stake_currency': 'BTC', 'fiat_currency': 'EUR' } - msg_mock = MagicMock() - mocker.patch("freqtrade.rpc.webhook.Webhook._send_msg", msg_mock) webhook.send_msg(msg=msg) assert msg_mock.call_count == 1 assert (msg_mock.call_args[0][0]["value1"] == @@ -64,6 +75,27 @@ def test_send_msg(default_conf, mocker): default_conf["webhook"]["webhookbuy"]["value2"].format(**msg)) assert (msg_mock.call_args[0][0]["value3"] == default_conf["webhook"]["webhookbuy"]["value3"].format(**msg)) + # Test buy cancel + msg_mock = MagicMock() + mocker.patch("freqtrade.rpc.webhook.Webhook._send_msg", msg_mock) + msg = { + 'type': RPCMessageType.BUY_CANCEL_NOTIFICATION, + 'exchange': 'Bittrex', + 'pair': 'ETH/BTC', + 'limit': 0.005, + 'stake_amount': 0.8, + 'stake_amount_fiat': 500, + 'stake_currency': 'BTC', + 'fiat_currency': 'EUR' + } + webhook.send_msg(msg=msg) + assert msg_mock.call_count == 1 + assert (msg_mock.call_args[0][0]["value1"] == + default_conf["webhook"]["webhookbuycancel"]["value1"].format(**msg)) + assert (msg_mock.call_args[0][0]["value2"] == + default_conf["webhook"]["webhookbuycancel"]["value2"].format(**msg)) + assert (msg_mock.call_args[0][0]["value3"] == + default_conf["webhook"]["webhookbuycancel"]["value3"].format(**msg)) # Test sell msg_mock = MagicMock() mocker.patch("freqtrade.rpc.webhook.Webhook._send_msg", msg_mock) @@ -90,7 +122,32 @@ def test_send_msg(default_conf, mocker): default_conf["webhook"]["webhooksell"]["value2"].format(**msg)) assert (msg_mock.call_args[0][0]["value3"] == default_conf["webhook"]["webhooksell"]["value3"].format(**msg)) - + # Test sell cancel + msg_mock = MagicMock() + mocker.patch("freqtrade.rpc.webhook.Webhook._send_msg", msg_mock) + msg = { + 'type': RPCMessageType.SELL_CANCEL_NOTIFICATION, + 'exchange': 'Bittrex', + 'pair': 'ETH/BTC', + 'gain': "profit", + 'limit': 0.005, + 'amount': 0.8, + 'order_type': 'limit', + 'open_rate': 0.004, + 'current_rate': 0.005, + 'profit_amount': 0.001, + 'profit_percent': 0.20, + 'stake_currency': 'BTC', + 'sell_reason': SellType.STOP_LOSS.value + } + webhook.send_msg(msg=msg) + assert msg_mock.call_count == 1 + assert (msg_mock.call_args[0][0]["value1"] == + default_conf["webhook"]["webhooksellcancel"]["value1"].format(**msg)) + assert (msg_mock.call_args[0][0]["value2"] == + default_conf["webhook"]["webhooksellcancel"]["value2"].format(**msg)) + assert (msg_mock.call_args[0][0]["value3"] == + default_conf["webhook"]["webhooksellcancel"]["value3"].format(**msg)) for msgtype in [RPCMessageType.STATUS_NOTIFICATION, RPCMessageType.WARNING_NOTIFICATION, RPCMessageType.CUSTOM_NOTIFICATION]: diff --git a/freqtrade/strategy/default_strategy.py b/tests/strategy/strats/default_strategy.py similarity index 100% rename from freqtrade/strategy/default_strategy.py rename to tests/strategy/strats/default_strategy.py diff --git a/tests/strategy/strats/failing_strategy.py b/tests/strategy/strats/failing_strategy.py new file mode 100644 index 000000000..f8eaac3c3 --- /dev/null +++ b/tests/strategy/strats/failing_strategy.py @@ -0,0 +1,9 @@ +# The strategy which fails to load due to non-existent dependency + +import nonexiting_module # noqa + +from freqtrade.strategy.interface import IStrategy + + +class TestStrategyLegacy(IStrategy): + pass diff --git a/tests/strategy/legacy_strategy.py b/tests/strategy/strats/legacy_strategy.py similarity index 100% rename from tests/strategy/legacy_strategy.py rename to tests/strategy/strats/legacy_strategy.py diff --git a/tests/strategy/test_default_strategy.py b/tests/strategy/test_default_strategy.py index 17d6b8ee0..0b8ea9f85 100644 --- a/tests/strategy/test_default_strategy.py +++ b/tests/strategy/test_default_strategy.py @@ -1,6 +1,6 @@ from pandas import DataFrame -from freqtrade.strategy.default_strategy import DefaultStrategy +from .strats.default_strategy import DefaultStrategy def test_default_strategy_structure(): diff --git a/tests/strategy/test_interface.py b/tests/strategy/test_interface.py index 89c38bda1..86d0738c6 100644 --- a/tests/strategy/test_interface.py +++ b/tests/strategy/test_interface.py @@ -7,11 +7,11 @@ import arrow from pandas import DataFrame from freqtrade.configuration import TimeRange -from freqtrade.data.converter import parse_ticker_dataframe -from freqtrade.data.history import load_tickerdata_file +from freqtrade.data.history import load_data from freqtrade.persistence import Trade +from freqtrade.resolvers import StrategyResolver +from .strats.default_strategy import DefaultStrategy from tests.conftest import get_patched_exchange, log_has -from freqtrade.strategy.default_strategy import DefaultStrategy # Avoid to reinit the same object again and again _STRATEGY = DefaultStrategy(config={}) @@ -104,12 +104,12 @@ def test_get_signal_handles_exceptions(mocker, default_conf): def test_tickerdata_to_dataframe(default_conf, testdatadir) -> None: - strategy = DefaultStrategy(default_conf) + default_conf.update({'strategy': 'DefaultStrategy'}) + strategy = StrategyResolver.load_strategy(default_conf) timerange = TimeRange.parse_timerange('1510694220-1510700340') - tick = load_tickerdata_file(testdatadir, 'UNITTEST/BTC', '1m', timerange=timerange) - tickerlist = {'UNITTEST/BTC': parse_ticker_dataframe(tick, '1m', pair="UNITTEST/BTC", - fill_missing=True)} + tickerlist = load_data(testdatadir, '1m', ['UNITTEST/BTC'], timerange=timerange, + fill_up_missing=True) data = strategy.tickerdata_to_dataframe(tickerlist) assert len(data['UNITTEST/BTC']) == 102 # partial candle was removed @@ -120,7 +120,8 @@ def test_min_roi_reached(default_conf, fee) -> None: min_roi_list = [{20: 0.05, 55: 0.01, 0: 0.1}, {0: 0.1, 20: 0.05, 55: 0.01}] for roi in min_roi_list: - strategy = DefaultStrategy(default_conf) + default_conf.update({'strategy': 'DefaultStrategy'}) + strategy = StrategyResolver.load_strategy(default_conf) strategy.minimal_roi = roi trade = Trade( pair='ETH/BTC', @@ -158,7 +159,8 @@ def test_min_roi_reached2(default_conf, fee) -> None: }, ] for roi in min_roi_list: - strategy = DefaultStrategy(default_conf) + default_conf.update({'strategy': 'DefaultStrategy'}) + strategy = StrategyResolver.load_strategy(default_conf) strategy.minimal_roi = roi trade = Trade( pair='ETH/BTC', @@ -192,7 +194,8 @@ def test_min_roi_reached3(default_conf, fee) -> None: 30: 0.05, 55: 0.30, } - strategy = DefaultStrategy(default_conf) + default_conf.update({'strategy': 'DefaultStrategy'}) + strategy = StrategyResolver.load_strategy(default_conf) strategy.minimal_roi = min_roi trade = Trade( pair='ETH/BTC', @@ -292,7 +295,8 @@ def test__analyze_ticker_internal_skip_analyze(ticker_history, mocker, caplog) - def test_is_pair_locked(default_conf): - strategy = DefaultStrategy(default_conf) + default_conf.update({'strategy': 'DefaultStrategy'}) + strategy = StrategyResolver.load_strategy(default_conf) # dict should be empty assert not strategy._pair_locked_until diff --git a/tests/strategy/test_strategy.py b/tests/strategy/test_strategy.py index d3977ae44..13ca68bf0 100644 --- a/tests/strategy/test_strategy.py +++ b/tests/strategy/test_strategy.py @@ -2,7 +2,6 @@ import logging import warnings from base64 import urlsafe_b64encode -from os import path from pathlib import Path import pytest @@ -15,7 +14,7 @@ from tests.conftest import log_has, log_has_re def test_search_strategy(): - default_location = Path(__file__).parent.parent.joinpath('strategy').resolve() + default_location = Path(__file__).parent / 'strats' s, _ = StrategyResolver._search_object( directory=default_location, @@ -30,12 +29,23 @@ def test_search_strategy(): assert s is None -def test_search_all_strategies(): - directory = Path(__file__).parent - strategies = StrategyResolver.search_all_objects(directory) +def test_search_all_strategies_no_failed(): + directory = Path(__file__).parent / "strats" + strategies = StrategyResolver.search_all_objects(directory, enum_failed=False) + assert isinstance(strategies, list) + assert len(strategies) == 2 + assert isinstance(strategies[0], dict) + + +def test_search_all_strategies_with_failed(): + directory = Path(__file__).parent / "strats" + strategies = StrategyResolver.search_all_objects(directory, enum_failed=True) assert isinstance(strategies, list) assert len(strategies) == 3 - assert isinstance(strategies[0], dict) + # with enum_failed=True search_all_objects() shall find 2 good strategies + # and 1 which fails to load + assert len([x for x in strategies if x['class'] is not None]) == 2 + assert len([x for x in strategies if x['class'] is None]) == 1 def test_load_strategy(default_conf, result): @@ -61,13 +71,12 @@ def test_load_strategy_base64(result, caplog, default_conf): def test_load_strategy_invalid_directory(result, caplog, default_conf): default_conf['strategy'] = 'DefaultStrategy' extra_dir = Path.cwd() / 'some/path' - strategy = StrategyResolver._load_strategy('DefaultStrategy', config=default_conf, - extra_dir=extra_dir) + with pytest.raises(OperationalException): + StrategyResolver._load_strategy('DefaultStrategy', config=default_conf, + extra_dir=extra_dir) assert log_has_re(r'Path .*' + r'some.*path.*' + r'.* does not exist', caplog) - assert 'rsi' in strategy.advise_indicators(result, {'pair': 'ETH/BTC'}) - def test_load_not_found_strategy(default_conf): default_conf['strategy'] = 'NotFoundStrategy' @@ -315,7 +324,7 @@ def test_strategy_override_use_sell_profit_only(caplog, default_conf): @pytest.mark.filterwarnings("ignore:deprecated") def test_deprecate_populate_indicators(result, default_conf): - default_location = path.join(path.dirname(path.realpath(__file__))) + default_location = Path(__file__).parent / "strats" default_conf.update({'strategy': 'TestStrategyLegacy', 'strategy_path': default_location}) strategy = StrategyResolver.load_strategy(default_conf) @@ -349,7 +358,7 @@ def test_deprecate_populate_indicators(result, default_conf): @pytest.mark.filterwarnings("ignore:deprecated") def test_call_deprecated_function(result, monkeypatch, default_conf): - default_location = path.join(path.dirname(path.realpath(__file__))) + default_location = Path(__file__).parent / "strats" default_conf.update({'strategy': 'TestStrategyLegacy', 'strategy_path': default_location}) strategy = StrategyResolver.load_strategy(default_conf) diff --git a/tests/test_arguments.py b/tests/test_arguments.py index 60da0082a..0052a61d0 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -18,7 +18,8 @@ def test_parse_args_none() -> None: assert isinstance(arguments.parser, argparse.ArgumentParser) -def test_parse_args_defaults() -> None: +def test_parse_args_defaults(mocker) -> None: + mocker.patch.object(Path, "is_file", MagicMock(side_effect=[False, True])) args = Arguments(['trade']).get_parsed_arg() assert args["config"] == ['config.json'] assert args["strategy_path"] is None @@ -26,6 +27,26 @@ def test_parse_args_defaults() -> None: assert args["verbosity"] == 0 +def test_parse_args_default_userdatadir(mocker) -> None: + mocker.patch.object(Path, "is_file", MagicMock(return_value=True)) + args = Arguments(['trade']).get_parsed_arg() + # configuration defaults to user_data if that is available. + assert args["config"] == [str(Path('user_data/config.json'))] + assert args["strategy_path"] is None + assert args["datadir"] is None + assert args["verbosity"] == 0 + + +def test_parse_args_userdatadir(mocker) -> None: + mocker.patch.object(Path, "is_file", MagicMock(return_value=True)) + args = Arguments(['trade', '--user-data-dir', 'user_data']).get_parsed_arg() + # configuration defaults to user_data if that is available. + assert args["config"] == [str(Path('user_data/config.json'))] + assert args["strategy_path"] is None + assert args["datadir"] is None + assert args["verbosity"] == 0 + + def test_parse_args_config() -> None: args = Arguments(['trade', '-c', '/dev/null']).get_parsed_arg() assert args["config"] == ['/dev/null'] @@ -208,7 +229,7 @@ def test_config_notrequired(mocker) -> None: assert pargs["config"] is None # When file exists: - mocker.patch.object(Path, "is_file", MagicMock(return_value=True)) + mocker.patch.object(Path, "is_file", MagicMock(side_effect=[False, True])) args = [ 'download-data', ] diff --git a/tests/test_configuration.py b/tests/test_configuration.py index 74de166c1..d810305db 100644 --- a/tests/test_configuration.py +++ b/tests/test_configuration.py @@ -212,6 +212,7 @@ def test_load_config_file_exception(mocker) -> None: def test_load_config(default_conf, mocker) -> None: + del default_conf['strategy_path'] patched_configuration_load_config_file(mocker, default_conf) args = Arguments(['trade']).get_parsed_arg() diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index e0f2ecd3a..d670282e6 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -300,7 +300,7 @@ def test_edge_overrides_stoploss(limit_buy_order, fee, caplog, mocker, edge_conf # stoploss shoud be hit assert freqtrade.handle_trade(trade) is True - assert log_has('executed sell, reason: SellType.STOP_LOSS', caplog) + assert log_has('Executing Sell for NEO/BTC. Reason: SellType.STOP_LOSS', caplog) assert trade.sell_reason == SellType.STOP_LOSS.value @@ -782,7 +782,7 @@ def test_process_exchange_failures(default_conf, ticker, mocker) -> None: worker = Worker(args=None, config=default_conf) patch_get_signal(worker.freqtrade) - worker._process() + worker._process_running() assert sleep_mock.has_calls() @@ -797,10 +797,10 @@ def test_process_operational_exception(default_conf, ticker, mocker) -> None: worker = Worker(args=None, config=default_conf) patch_get_signal(worker.freqtrade) - assert worker.state == State.RUNNING + assert worker.freqtrade.state == State.RUNNING - worker._process() - assert worker.state == State.STOPPED + worker._process_running() + assert worker.freqtrade.state == State.STOPPED assert 'OperationalException' in msg_mock.call_args_list[-1][0][0]['status'] @@ -915,13 +915,21 @@ def test_process_informative_pairs_added(default_conf, ticker, mocker) -> None: (5, 10, 1.0, 5), # last bigger than ask (5, 10, 0.5, 5), # last bigger than ask ]) -def test_get_buy_rate(mocker, default_conf, ask, last, last_ab, expected) -> None: +def test_get_buy_rate(mocker, default_conf, caplog, ask, last, last_ab, expected) -> None: default_conf['bid_strategy']['ask_last_balance'] = last_ab freqtrade = get_patched_freqtradebot(mocker, default_conf) mocker.patch('freqtrade.exchange.Exchange.fetch_ticker', MagicMock(return_value={'ask': ask, 'last': last})) - assert freqtrade.get_buy_rate('ETH/BTC') == expected + assert freqtrade.get_buy_rate('ETH/BTC', True) == expected + assert not log_has("Using cached buy rate for ETH/BTC.", caplog) + + assert freqtrade.get_buy_rate('ETH/BTC', False) == expected + assert log_has("Using cached buy rate for ETH/BTC.", caplog) + # Running a 2nd time with Refresh on! + caplog.clear() + assert freqtrade.get_buy_rate('ETH/BTC', True) == expected + assert not log_has("Using cached buy rate for ETH/BTC.", caplog) def test_execute_buy(mocker, default_conf, fee, limit_buy_order) -> None: @@ -1023,8 +1031,8 @@ def test_add_stoploss_on_exchange(mocker, default_conf, limit_buy_order) -> None mocker.patch('freqtrade.freqtradebot.FreqtradeBot.get_real_amount', return_value=limit_buy_order['amount']) - stoploss_limit = MagicMock(return_value={'id': 13434334}) - mocker.patch('freqtrade.exchange.Exchange.stoploss_limit', stoploss_limit) + stoploss = MagicMock(return_value={'id': 13434334}) + mocker.patch('freqtrade.exchange.Exchange.stoploss', stoploss) freqtrade = FreqtradeBot(default_conf) freqtrade.strategy.order_types['stoploss_on_exchange'] = True @@ -1037,13 +1045,13 @@ def test_add_stoploss_on_exchange(mocker, default_conf, limit_buy_order) -> None freqtrade.exit_positions(trades) assert trade.stoploss_order_id == '13434334' - assert stoploss_limit.call_count == 1 + assert stoploss.call_count == 1 assert trade.is_open is True def test_handle_stoploss_on_exchange(mocker, default_conf, fee, caplog, limit_buy_order, limit_sell_order) -> None: - stoploss_limit = MagicMock(return_value={'id': 13434334}) + stoploss = MagicMock(return_value={'id': 13434334}) patch_RPCManager(mocker) patch_exchange(mocker) mocker.patch.multiple( @@ -1056,7 +1064,7 @@ def test_handle_stoploss_on_exchange(mocker, default_conf, fee, caplog, buy=MagicMock(return_value={'id': limit_buy_order['id']}), sell=MagicMock(return_value={'id': limit_sell_order['id']}), get_fee=fee, - stoploss_limit=stoploss_limit + stoploss=stoploss ) freqtrade = FreqtradeBot(default_conf) patch_get_signal(freqtrade) @@ -1070,7 +1078,7 @@ def test_handle_stoploss_on_exchange(mocker, default_conf, fee, caplog, trade.stoploss_order_id = None assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert stoploss_limit.call_count == 1 + assert stoploss.call_count == 1 assert trade.stoploss_order_id == "13434334" # Second case: when stoploss is set but it is not yet hit @@ -1094,10 +1102,10 @@ def test_handle_stoploss_on_exchange(mocker, default_conf, fee, caplog, canceled_stoploss_order = MagicMock(return_value={'status': 'canceled'}) mocker.patch('freqtrade.exchange.Exchange.get_order', canceled_stoploss_order) - stoploss_limit.reset_mock() + stoploss.reset_mock() assert freqtrade.handle_stoploss_on_exchange(trade) is False - assert stoploss_limit.call_count == 1 + assert stoploss.call_count == 1 assert trade.stoploss_order_id == "13434334" # Fourth case: when stoploss is set and it is hit @@ -1124,9 +1132,10 @@ def test_handle_stoploss_on_exchange(mocker, default_conf, fee, caplog, assert trade.is_open is False mocker.patch( - 'freqtrade.exchange.Exchange.stoploss_limit', + 'freqtrade.exchange.Exchange.stoploss', side_effect=DependencyException() ) + trade.is_open = True freqtrade.handle_stoploss_on_exchange(trade) assert log_has('Unable to place a stoploss order on exchange.', caplog) assert trade.stoploss_order_id is None @@ -1134,11 +1143,21 @@ def test_handle_stoploss_on_exchange(mocker, default_conf, fee, caplog, # Fifth case: get_order returns InvalidOrder # It should try to add stoploss order trade.stoploss_order_id = 100 - stoploss_limit.reset_mock() + stoploss.reset_mock() mocker.patch('freqtrade.exchange.Exchange.get_order', side_effect=InvalidOrderException()) - mocker.patch('freqtrade.exchange.Exchange.stoploss_limit', stoploss_limit) + mocker.patch('freqtrade.exchange.Exchange.stoploss', stoploss) freqtrade.handle_stoploss_on_exchange(trade) - assert stoploss_limit.call_count == 1 + assert stoploss.call_count == 1 + + # Sixth case: Closed Trade + # Should not create new order + trade.stoploss_order_id = None + trade.is_open = False + stoploss.reset_mock() + mocker.patch('freqtrade.exchange.Exchange.get_order') + mocker.patch('freqtrade.exchange.Exchange.stoploss', stoploss) + assert freqtrade.handle_stoploss_on_exchange(trade) is False + assert stoploss.call_count == 0 def test_handle_sle_cancel_cant_recreate(mocker, default_conf, fee, caplog, @@ -1157,7 +1176,7 @@ def test_handle_sle_cancel_cant_recreate(mocker, default_conf, fee, caplog, sell=MagicMock(return_value={'id': limit_sell_order['id']}), get_fee=fee, get_order=MagicMock(return_value={'status': 'canceled'}), - stoploss_limit=MagicMock(side_effect=DependencyException()), + stoploss=MagicMock(side_effect=DependencyException()), ) freqtrade = FreqtradeBot(default_conf) patch_get_signal(freqtrade) @@ -1165,7 +1184,7 @@ def test_handle_sle_cancel_cant_recreate(mocker, default_conf, fee, caplog, freqtrade.enter_positions() trade = Trade.query.first() trade.is_open = True - trade.open_order_id = '12345' + trade.open_order_id = None trade.stoploss_order_id = 100 assert trade @@ -1191,7 +1210,7 @@ def test_create_stoploss_order_invalid_order(mocker, default_conf, caplog, fee, sell=sell_mock, get_fee=fee, get_order=MagicMock(return_value={'status': 'canceled'}), - stoploss_limit=MagicMock(side_effect=InvalidOrderException()), + stoploss=MagicMock(side_effect=InvalidOrderException()), ) freqtrade = FreqtradeBot(default_conf) patch_get_signal(freqtrade) @@ -1221,7 +1240,7 @@ def test_create_stoploss_order_invalid_order(mocker, default_conf, caplog, fee, def test_handle_stoploss_on_exchange_trailing(mocker, default_conf, fee, caplog, limit_buy_order, limit_sell_order) -> None: # When trailing stoploss is set - stoploss_limit = MagicMock(return_value={'id': 13434334}) + stoploss = MagicMock(return_value={'id': 13434334}) patch_RPCManager(mocker) mocker.patch.multiple( 'freqtrade.exchange.Exchange', @@ -1233,7 +1252,8 @@ def test_handle_stoploss_on_exchange_trailing(mocker, default_conf, fee, caplog, buy=MagicMock(return_value={'id': limit_buy_order['id']}), sell=MagicMock(return_value={'id': limit_sell_order['id']}), get_fee=fee, - stoploss_limit=stoploss_limit + stoploss=stoploss, + stoploss_adjust=MagicMock(return_value=True), ) # enabling TSL @@ -1288,7 +1308,7 @@ def test_handle_stoploss_on_exchange_trailing(mocker, default_conf, fee, caplog, cancel_order_mock = MagicMock() stoploss_order_mock = MagicMock() mocker.patch('freqtrade.exchange.Exchange.cancel_order', cancel_order_mock) - mocker.patch('freqtrade.exchange.Exchange.stoploss_limit', stoploss_order_mock) + mocker.patch('freqtrade.exchange.Exchange.stoploss', stoploss_order_mock) # stoploss should not be updated as the interval is 60 seconds assert freqtrade.handle_trade(trade) is False @@ -1307,7 +1327,7 @@ def test_handle_stoploss_on_exchange_trailing(mocker, default_conf, fee, caplog, cancel_order_mock.assert_called_once_with(100, 'ETH/BTC') stoploss_order_mock.assert_called_once_with(amount=85.25149190110828, pair='ETH/BTC', - rate=0.00002344 * 0.95 * 0.99, + order_types=freqtrade.strategy.order_types, stop_price=0.00002344 * 0.95) # price fell below stoploss, so dry-run sells trade. @@ -1322,7 +1342,7 @@ def test_handle_stoploss_on_exchange_trailing(mocker, default_conf, fee, caplog, def test_handle_stoploss_on_exchange_trailing_error(mocker, default_conf, fee, caplog, limit_buy_order, limit_sell_order) -> None: # When trailing stoploss is set - stoploss_limit = MagicMock(return_value={'id': 13434334}) + stoploss = MagicMock(return_value={'id': 13434334}) patch_exchange(mocker) mocker.patch.multiple( @@ -1335,7 +1355,8 @@ def test_handle_stoploss_on_exchange_trailing_error(mocker, default_conf, fee, c buy=MagicMock(return_value={'id': limit_buy_order['id']}), sell=MagicMock(return_value={'id': limit_sell_order['id']}), get_fee=fee, - stoploss_limit=stoploss_limit + stoploss=stoploss, + stoploss_adjust=MagicMock(return_value=True), ) # enabling TSL @@ -1375,12 +1396,12 @@ def test_handle_stoploss_on_exchange_trailing_error(mocker, default_conf, fee, c assert log_has_re(r"Could not cancel stoploss order abcd for pair ETH/BTC.*", caplog) # Still try to create order - assert stoploss_limit.call_count == 1 + assert stoploss.call_count == 1 # Fail creating stoploss order caplog.clear() cancel_mock = mocker.patch("freqtrade.exchange.Exchange.cancel_order", MagicMock()) - mocker.patch("freqtrade.exchange.Exchange.stoploss_limit", side_effect=DependencyException()) + mocker.patch("freqtrade.exchange.Exchange.stoploss", side_effect=DependencyException()) freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging) assert cancel_mock.call_count == 1 assert log_has_re(r"Could not create trailing stoploss order for pair ETH/BTC\..*", caplog) @@ -1390,12 +1411,13 @@ def test_tsl_on_exchange_compatible_with_edge(mocker, edge_conf, fee, caplog, limit_buy_order, limit_sell_order) -> None: # When trailing stoploss is set - stoploss_limit = MagicMock(return_value={'id': 13434334}) + stoploss = MagicMock(return_value={'id': 13434334}) patch_RPCManager(mocker) patch_exchange(mocker) patch_edge(mocker) edge_conf['max_open_trades'] = float('inf') edge_conf['dry_run_wallet'] = 999.9 + edge_conf['exchange']['name'] = 'binance' mocker.patch.multiple( 'freqtrade.exchange.Exchange', fetch_ticker=MagicMock(return_value={ @@ -1406,7 +1428,7 @@ def test_tsl_on_exchange_compatible_with_edge(mocker, edge_conf, fee, caplog, buy=MagicMock(return_value={'id': limit_buy_order['id']}), sell=MagicMock(return_value={'id': limit_sell_order['id']}), get_fee=fee, - stoploss_limit=stoploss_limit + stoploss=stoploss, ) # enabling TSL @@ -1459,7 +1481,7 @@ def test_tsl_on_exchange_compatible_with_edge(mocker, edge_conf, fee, caplog, cancel_order_mock = MagicMock() stoploss_order_mock = MagicMock() mocker.patch('freqtrade.exchange.Exchange.cancel_order', cancel_order_mock) - mocker.patch('freqtrade.exchange.Exchange.stoploss_limit', stoploss_order_mock) + mocker.patch('freqtrade.exchange.Binance.stoploss', stoploss_order_mock) # price goes down 5% mocker.patch('freqtrade.exchange.Exchange.fetch_ticker', MagicMock(return_value={ @@ -1492,7 +1514,7 @@ def test_tsl_on_exchange_compatible_with_edge(mocker, edge_conf, fee, caplog, cancel_order_mock.assert_called_once_with(100, 'NEO/BTC') stoploss_order_mock.assert_called_once_with(amount=2131074.168797954, pair='NEO/BTC', - rate=0.00002344 * 0.99 * 0.99, + order_types=freqtrade.strategy.order_types, stop_price=0.00002344 * 0.99) @@ -1950,7 +1972,7 @@ def test_check_handle_cancelled_buy(default_conf, ticker, limit_buy_order_old, o trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all() nb_trades = len(trades) assert nb_trades == 0 - assert log_has_re("Buy order canceled on Exchange for Trade.*", caplog) + assert log_has_re("Buy order cancelled on exchange for Trade.*", caplog) def test_check_handle_timedout_buy_exception(default_conf, ticker, limit_buy_order_old, open_trade, @@ -2031,7 +2053,7 @@ def test_check_handle_cancelled_sell(default_conf, ticker, limit_sell_order_old, assert cancel_order_mock.call_count == 0 assert rpc_mock.call_count == 1 assert open_trade.is_open is True - assert log_has_re("Sell order canceled on exchange for Trade.*", caplog) + assert log_has_re("Sell order cancelled on exchange for Trade.*", caplog) def test_check_handle_timedout_partial(default_conf, ticker, limit_buy_order_old_partial, @@ -2053,7 +2075,7 @@ def test_check_handle_timedout_partial(default_conf, ticker, limit_buy_order_old # note this is for a partially-complete buy order freqtrade.check_handle_timedout() assert cancel_order_mock.call_count == 1 - assert rpc_mock.call_count == 1 + assert rpc_mock.call_count == 2 trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all() assert len(trades) == 1 assert trades[0].amount == 23.0 @@ -2087,7 +2109,7 @@ def test_check_handle_timedout_partial_fee(default_conf, ticker, open_trade, cap assert log_has_re(r"Applying fee on amount for Trade.* Order", caplog) assert cancel_order_mock.call_count == 1 - assert rpc_mock.call_count == 1 + assert rpc_mock.call_count == 2 trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all() assert len(trades) == 1 # Verify that tradehas been updated @@ -2126,7 +2148,7 @@ def test_check_handle_timedout_partial_except(default_conf, ticker, open_trade, assert log_has_re(r"Could not update trade amount: .*", caplog) assert cancel_order_mock.call_count == 1 - assert rpc_mock.call_count == 1 + assert rpc_mock.call_count == 2 trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all() assert len(trades) == 1 # Verify that tradehas been updated @@ -2423,7 +2445,7 @@ def test_execute_sell_with_stoploss_on_exchange(default_conf, ticker, fee, ticke default_conf['exchange']['name'] = 'binance' rpc_mock = patch_RPCManager(mocker) patch_exchange(mocker) - stoploss_limit = MagicMock(return_value={ + stoploss = MagicMock(return_value={ 'id': 123, 'info': { 'foo': 'bar' @@ -2437,7 +2459,7 @@ def test_execute_sell_with_stoploss_on_exchange(default_conf, ticker, fee, ticke get_fee=fee, amount_to_precision=lambda s, x, y: y, price_to_precision=lambda s, x, y: y, - stoploss_limit=stoploss_limit, + stoploss=stoploss, cancel_order=cancel_order, ) @@ -2482,14 +2504,14 @@ def test_may_execute_sell_after_stoploss_on_exchange_hit(default_conf, ticker, f price_to_precision=lambda s, x, y: y, ) - stoploss_limit = MagicMock(return_value={ + stoploss = MagicMock(return_value={ 'id': 123, 'info': { 'foo': 'bar' } }) - mocker.patch('freqtrade.exchange.Binance.stoploss_limit', stoploss_limit) + mocker.patch('freqtrade.exchange.Binance.stoploss', stoploss) freqtrade = FreqtradeBot(default_conf) freqtrade.strategy.order_types['stoploss_on_exchange'] = True @@ -2507,7 +2529,7 @@ def test_may_execute_sell_after_stoploss_on_exchange_hit(default_conf, ticker, f # Assuming stoploss on exchnage is hit # stoploss_order_id should become None # and trade should be sold at the price of stoploss - stoploss_limit_executed = MagicMock(return_value={ + stoploss_executed = MagicMock(return_value={ "id": "123", "timestamp": 1542707426845, "datetime": "2018-11-20T09:50:26.845Z", @@ -2525,7 +2547,7 @@ def test_may_execute_sell_after_stoploss_on_exchange_hit(default_conf, ticker, f "fee": None, "trades": None }) - mocker.patch('freqtrade.exchange.Exchange.get_order', stoploss_limit_executed) + mocker.patch('freqtrade.exchange.Exchange.get_order', stoploss_executed) freqtrade.exit_positions(trades) assert trade.stoploss_order_id is None @@ -3510,7 +3532,7 @@ def test_order_book_bid_strategy1(mocker, default_conf, order_book_l2) -> None: default_conf['telegram']['enabled'] = False freqtrade = FreqtradeBot(default_conf) - assert freqtrade.get_buy_rate('ETH/BTC') == 0.043935 + assert freqtrade.get_buy_rate('ETH/BTC', True) == 0.043935 assert ticker_mock.call_count == 0 @@ -3535,7 +3557,7 @@ def test_order_book_bid_strategy2(mocker, default_conf, order_book_l2) -> None: freqtrade = FreqtradeBot(default_conf) # orderbook shall be used even if tickers would be lower. - assert freqtrade.get_buy_rate('ETH/BTC') != 0.042 + assert freqtrade.get_buy_rate('ETH/BTC', True) != 0.042 assert ticker_mock.call_count == 0 @@ -3600,7 +3622,7 @@ def test_order_book_ask_strategy(default_conf, limit_buy_order, limit_sell_order assert freqtrade.handle_trade(trade) is True -def test_get_sell_rate(default_conf, mocker, ticker, order_book_l2) -> None: +def test_get_sell_rate(default_conf, mocker, caplog, ticker, order_book_l2) -> None: mocker.patch.multiple( 'freqtrade.exchange.Exchange', @@ -3612,8 +3634,15 @@ def test_get_sell_rate(default_conf, mocker, ticker, order_book_l2) -> None: # Test regular mode 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) assert isinstance(rate, float) assert rate == 0.00001098 + # Use caching + rate = ft.get_sell_rate(pair, False) + assert rate == 0.00001098 + assert log_has("Using cached sell rate for ETH/BTC.", caplog) + + caplog.clear() # Test orderbook mode default_conf['ask_strategy']['use_order_book'] = True @@ -3621,8 +3650,12 @@ def test_get_sell_rate(default_conf, mocker, ticker, order_book_l2) -> None: default_conf['ask_strategy']['order_book_max'] = 2 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) assert isinstance(rate, float) assert rate == 0.043936 + rate = ft.get_sell_rate(pair, False) + assert rate == 0.043936 + assert log_has("Using cached sell rate for ETH/BTC.", caplog) def test_startup_state(default_conf, mocker): @@ -3631,7 +3664,7 @@ def test_startup_state(default_conf, mocker): } mocker.patch('freqtrade.exchange.Exchange.exchange_has', MagicMock(return_value=True)) worker = get_patched_worker(mocker, default_conf) - assert worker.state is State.RUNNING + assert worker.freqtrade.state is State.RUNNING def test_startup_trade_reinit(default_conf, edge_conf, mocker): @@ -3651,30 +3684,6 @@ def test_startup_trade_reinit(default_conf, edge_conf, mocker): assert reinit_mock.call_count == 0 -def test_process_i_am_alive(default_conf, mocker, caplog): - patch_RPCManager(mocker) - patch_exchange(mocker) - mocker.patch('freqtrade.exchange.Exchange.exchange_has', MagicMock(return_value=True)) - - ftbot = get_patched_freqtradebot(mocker, default_conf) - message = r"Bot heartbeat\. PID=.*" - ftbot.process() - assert log_has_re(message, caplog) - assert ftbot._heartbeat_msg != 0 - - caplog.clear() - # Message is not shown before interval is up - ftbot.process() - assert not log_has_re(message, caplog) - - caplog.clear() - # Set clock - 70 seconds - ftbot._heartbeat_msg -= 70 - - ftbot.process() - assert log_has_re(message, caplog) - - @pytest.mark.usefixtures("init_persistence") def test_sync_wallet_dry_run(mocker, default_conf, ticker, fee, limit_buy_order, caplog): default_conf['dry_run'] = True diff --git a/tests/test_integration.py b/tests/test_integration.py index 9cb071bb8..c40da7e9d 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -20,7 +20,7 @@ def test_may_execute_sell_stoploss_on_exchange_multi(default_conf, ticker, fee, default_conf['max_open_trades'] = 3 default_conf['exchange']['name'] = 'binance' - stoploss_limit = { + stoploss = { 'id': 123, 'info': {} } @@ -53,7 +53,7 @@ def test_may_execute_sell_stoploss_on_exchange_multi(default_conf, ticker, fee, SellCheckTuple(sell_flag=True, sell_type=SellType.SELL_SIGNAL)] ) cancel_order_mock = MagicMock() - mocker.patch('freqtrade.exchange.Binance.stoploss_limit', stoploss_limit) + mocker.patch('freqtrade.exchange.Binance.stoploss', stoploss) mocker.patch.multiple( 'freqtrade.exchange.Exchange', fetch_ticker=ticker, diff --git a/tests/test_main.py b/tests/test_main.py index 1229f748a..70b784002 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -5,8 +5,9 @@ from unittest.mock import MagicMock, PropertyMock import pytest +from pathlib import Path from freqtrade.commands import Arguments -from freqtrade.exceptions import OperationalException, FreqtradeException +from freqtrade.exceptions import FreqtradeException, OperationalException from freqtrade.freqtradebot import FreqtradeBot from freqtrade.main import main from freqtrade.state import State @@ -26,6 +27,7 @@ def test_parse_args_backtesting(mocker) -> None: Test that main() can start backtesting and also ensure we can pass some specific arguments further argument parsing is done in test_arguments.py """ + mocker.patch.object(Path, "is_file", MagicMock(side_effect=[False, True])) backtesting_mock = mocker.patch('freqtrade.commands.start_backtesting') backtesting_mock.__name__ = PropertyMock("start_backtesting") # it's sys.exit(0) at the end of backtesting @@ -42,6 +44,7 @@ def test_parse_args_backtesting(mocker) -> None: def test_main_start_hyperopt(mocker) -> None: + mocker.patch.object(Path, "is_file", MagicMock(side_effect=[False, True])) hyperopt_mock = mocker.patch('freqtrade.commands.start_hyperopt', MagicMock()) hyperopt_mock.__name__ = PropertyMock("start_hyperopt") # it's sys.exit(0) at the end of hyperopt diff --git a/tests/test_misc.py b/tests/test_misc.py index 23231e2f0..83e008466 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -4,10 +4,12 @@ import datetime from pathlib import Path from unittest.mock import MagicMock +import pytest + from freqtrade.data.converter import parse_ticker_dataframe -from freqtrade.data.history import pair_data_filename from freqtrade.misc import (datesarray_to_datetimearray, file_dump_json, - file_load_json, format_ms_time, plural, shorten_date) + file_load_json, format_ms_time, pair_to_filename, + plural, shorten_date) def test_shorten_date() -> None: @@ -48,16 +50,36 @@ def test_file_dump_json(mocker) -> None: def test_file_load_json(mocker, testdatadir) -> None: # 7m .json does not exist - ret = file_load_json(pair_data_filename(testdatadir, 'UNITTEST/BTC', '7m')) + ret = file_load_json(testdatadir / 'UNITTEST_BTC-7m.json') assert not ret # 1m json exists (but no .gz exists) - ret = file_load_json(pair_data_filename(testdatadir, 'UNITTEST/BTC', '1m')) + ret = file_load_json(testdatadir / 'UNITTEST_BTC-1m.json') assert ret # 8 .json is empty and will fail if it's loaded. .json.gz is a copy of 1.json - ret = file_load_json(pair_data_filename(testdatadir, 'UNITTEST/BTC', '8m')) + ret = file_load_json(testdatadir / 'UNITTEST_BTC-8m.json') assert ret +@pytest.mark.parametrize("pair,expected_result", [ + ("ETH/BTC", 'ETH_BTC'), + ("Fabric Token/ETH", 'Fabric_Token_ETH'), + ("ETHH20", 'ETHH20'), + (".XBTBON2H", '_XBTBON2H'), + ("ETHUSD.d", 'ETHUSD_d'), + ("ADA-0327", 'ADA_0327'), + ("BTC-USD-200110", 'BTC_USD_200110'), + ("F-AKRO/USDT", 'F_AKRO_USDT'), + ("LC+/ETH", 'LC__ETH'), + ("CMT@18/ETH", 'CMT_18_ETH'), + ("LBTC:1022/SAI", 'LBTC_1022_SAI'), + ("$PAC/BTC", '_PAC_BTC'), + ("ACC_OLD/BTC", 'ACC_OLD_BTC'), +]) +def test_pair_to_filename(pair, expected_result): + pair_s = pair_to_filename(pair) + assert pair_s == expected_result + + def test_format_ms_time() -> None: # Date 2018-04-10 18:02:01 date_in_epoch_ms = 1523383321000 diff --git a/tests/test_plotting.py b/tests/test_plotting.py index e7ec4ce46..34d1f2b0c 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -19,7 +19,7 @@ from freqtrade.plot.plotting import (add_indicators, add_profit, generate_profit_graph, init_plotscript, load_and_plot_trades, plot_profit, plot_trades, store_plot_file) -from freqtrade.strategy.default_strategy import DefaultStrategy +from freqtrade.resolvers import StrategyResolver from tests.conftest import get_args, log_has, log_has_re @@ -70,9 +70,11 @@ def test_add_indicators(default_conf, testdatadir, caplog): indicators1 = {"ema10": {}} indicators2 = {"macd": {"color": "red"}} + default_conf.update({'strategy': 'DefaultStrategy'}) + strategy = StrategyResolver.load_strategy(default_conf) + # Generate buy/sell signals and indicators - strat = DefaultStrategy(default_conf) - data = strat.analyze_ticker(data, {'pair': pair}) + data = strategy.analyze_ticker(data, {'pair': pair}) fig = generate_empty_figure() # Row 1 @@ -181,9 +183,11 @@ def test_generate_candlestick_graph_no_trades(default_conf, mocker, testdatadir) data = history.load_pair_history(pair=pair, timeframe='1m', datadir=testdatadir, timerange=timerange) + default_conf.update({'strategy': 'DefaultStrategy'}) + strategy = StrategyResolver.load_strategy(default_conf) + # Generate buy/sell signals and indicators - strat = DefaultStrategy(default_conf) - data = strat.analyze_ticker(data, {'pair': pair}) + data = strategy.analyze_ticker(data, {'pair': pair}) indicators1 = [] indicators2 = [] diff --git a/tests/test_worker.py b/tests/test_worker.py index 72e215210..7b446ac6a 100644 --- a/tests/test_worker.py +++ b/tests/test_worker.py @@ -5,17 +5,17 @@ from unittest.mock import MagicMock, PropertyMock from freqtrade.data.dataprovider import DataProvider from freqtrade.state import State from freqtrade.worker import Worker -from tests.conftest import get_patched_worker, log_has +from tests.conftest import get_patched_worker, log_has, log_has_re def test_worker_state(mocker, default_conf, markets) -> None: mocker.patch('freqtrade.exchange.Exchange.markets', PropertyMock(return_value=markets)) worker = get_patched_worker(mocker, default_conf) - assert worker.state is State.RUNNING + assert worker.freqtrade.state is State.RUNNING default_conf.pop('initial_state') worker = Worker(args=None, config=default_conf) - assert worker.state is State.STOPPED + assert worker.freqtrade.state is State.STOPPED def test_worker_running(mocker, default_conf, caplog) -> None: @@ -38,15 +38,13 @@ def test_worker_running(mocker, default_conf, caplog) -> None: def test_worker_stopped(mocker, default_conf, caplog) -> None: mock_throttle = MagicMock() mocker.patch('freqtrade.worker.Worker._throttle', mock_throttle) - mock_sleep = mocker.patch('time.sleep', return_value=None) worker = get_patched_worker(mocker, default_conf) - worker.state = State.STOPPED + worker.freqtrade.state = State.STOPPED state = worker._worker(old_state=State.RUNNING) assert state is State.STOPPED assert log_has('Changing state to: STOPPED', caplog) - assert mock_throttle.call_count == 0 - assert mock_sleep.call_count == 1 + assert mock_throttle.call_count == 1 def test_throttle(mocker, default_conf, caplog) -> None: @@ -57,14 +55,14 @@ def test_throttle(mocker, default_conf, caplog) -> None: worker = get_patched_worker(mocker, default_conf) start = time.time() - result = worker._throttle(throttled_func, min_secs=0.1) + result = worker._throttle(throttled_func, throttle_secs=0.1) end = time.time() assert result == 42 assert end - start > 0.1 - assert log_has('Throttling throttled_func for 0.10 seconds', caplog) + assert log_has_re(r"Throttling with 'throttled_func\(\)': sleep for 0\.10 s.*", caplog) - result = worker._throttle(throttled_func, min_secs=-1) + result = worker._throttle(throttled_func, throttle_secs=-1) assert result == 42 @@ -74,8 +72,54 @@ def test_throttle_with_assets(mocker, default_conf) -> None: worker = get_patched_worker(mocker, default_conf) - result = worker._throttle(throttled_func, min_secs=0.1, nb_assets=666) + result = worker._throttle(throttled_func, throttle_secs=0.1, nb_assets=666) assert result == 666 - result = worker._throttle(throttled_func, min_secs=0.1) + result = worker._throttle(throttled_func, throttle_secs=0.1) assert result == -1 + + +def test_worker_heartbeat_running(default_conf, mocker, caplog): + message = r"Bot heartbeat\. PID=.*state='RUNNING'" + + mock_throttle = MagicMock() + mocker.patch('freqtrade.worker.Worker._throttle', mock_throttle) + worker = get_patched_worker(mocker, default_conf) + + worker.freqtrade.state = State.RUNNING + worker._worker(old_state=State.STOPPED) + assert log_has_re(message, caplog) + + caplog.clear() + # Message is not shown before interval is up + worker._worker(old_state=State.RUNNING) + assert not log_has_re(message, caplog) + + caplog.clear() + # Set clock - 70 seconds + worker._heartbeat_msg -= 70 + worker._worker(old_state=State.RUNNING) + assert log_has_re(message, caplog) + + +def test_worker_heartbeat_stopped(default_conf, mocker, caplog): + message = r"Bot heartbeat\. PID=.*state='STOPPED'" + + mock_throttle = MagicMock() + mocker.patch('freqtrade.worker.Worker._throttle', mock_throttle) + worker = get_patched_worker(mocker, default_conf) + + worker.freqtrade.state = State.STOPPED + worker._worker(old_state=State.RUNNING) + assert log_has_re(message, caplog) + + caplog.clear() + # Message is not shown before interval is up + worker._worker(old_state=State.STOPPED) + assert not log_has_re(message, caplog) + + caplog.clear() + # Set clock - 70 seconds + worker._heartbeat_msg -= 70 + worker._worker(old_state=State.STOPPED) + assert log_has_re(message, caplog)