diff --git a/.pyup.yml b/.pyup.yml index 462ae5783..b1b721113 100644 --- a/.pyup.yml +++ b/.pyup.yml @@ -11,7 +11,10 @@ update: all # allowed: True, False pin: True -schedule: "every day" +# update schedule +# default: empty +# allowed: "every day", "every week", .. +schedule: "every week" search: False @@ -22,7 +25,7 @@ requirements: - requirements.txt - requirements-dev.txt - requirements-plot.txt - - requirements-pi.txt + - requirements-common.txt # configure the branch prefix the bot is using diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c511f44d..e15059f56 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,7 +11,7 @@ Few pointers for contributions: - Create your PR against the `develop` branch, not `master`. - New features need to contain unit tests and must be PEP8 conformant (max-line-length = 100). -If you are unsure, discuss the feature on our [Slack](https://join.slack.com/t/highfrequencybot/shared_invite/enQtMjQ5NTM0OTYzMzY3LWMxYzE3M2MxNDdjMGM3ZTYwNzFjMGIwZGRjNTc3ZGU3MGE3NzdmZGMwNmU3NDM5ZTNmM2Y3NjRiNzk4NmM4OGE) +If you are unsure, discuss the feature on our [Slack](https://join.slack.com/t/highfrequencybot/shared_invite/enQtNjU5ODcwNjI1MDU3LWEyODBiNzkzNzcyNzU0MWYyYzE5NjIyOTQxMzBmMGUxOTIzM2YyN2Y4NWY1YTEwZDgwYTRmMzE2NmM5ZmY2MTg) or in a [issue](https://github.com/freqtrade/freqtrade/issues) before a PR. ## Getting started diff --git a/Dockerfile b/Dockerfile index e36766530..7a0298719 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ RUN cd /tmp && /tmp/install_ta-lib.sh && rm -r /tmp/*ta-lib* ENV LD_LIBRARY_PATH /usr/local/lib # Install dependencies -COPY requirements.txt /freqtrade/ +COPY requirements.txt requirements-common.txt /freqtrade/ RUN pip install numpy --no-cache-dir \ && pip install -r requirements.txt --no-cache-dir diff --git a/Dockerfile.pi b/Dockerfile.pi index 5184e2d37..1b9c4c579 100644 --- a/Dockerfile.pi +++ b/Dockerfile.pi @@ -27,9 +27,9 @@ RUN wget https://github.com/jjhelmus/berryconda/releases/download/v2.0.0/Berryco && rm Berryconda3-2.0.0-Linux-armv7l.sh # Install dependencies -COPY requirements-pi.txt /freqtrade/ +COPY requirements-common.txt /freqtrade/ RUN ~/berryconda3/bin/conda install -y numpy pandas scipy \ - && ~/berryconda3/bin/pip install -r requirements-pi.txt --no-cache-dir + && ~/berryconda3/bin/pip install -r requirements-common.txt --no-cache-dir # Install and execute COPY . /freqtrade/ diff --git a/Dockerfile.technical b/Dockerfile.technical index 5339eb232..9431e72d0 100644 --- a/Dockerfile.technical +++ b/Dockerfile.technical @@ -3,4 +3,4 @@ FROM freqtradeorg/freqtrade:develop RUN apt-get update \ && apt-get -y install git \ && apt-get clean \ - && pip install git+https://github.com/berlinguyinca/technical + && pip install git+https://github.com/freqtrade/technical diff --git a/README.md b/README.md index 8f7578561..240b4f917 100644 --- a/README.md +++ b/README.md @@ -129,7 +129,6 @@ The project is currently setup in two main branches: - `master` - This branch contains the latest stable release. The bot 'should' be stable on this branch, and is generally well tested. - `feat/*` - These are feature branches, which are being worked on heavily. Please don't use these unless you want to test a specific feature. - ## A note on Binance For Binance, please add `"BNB/"` to your blacklist to avoid issues. @@ -142,7 +141,7 @@ Accounts having BNB accounts use this to pay for fees - if your first trade happ For any questions not covered by the documentation or for further information about the bot, we encourage you to join our slack channel. -- [Click here to join Slack channel](https://join.slack.com/t/highfrequencybot/shared_invite/enQtMjQ5NTM0OTYzMzY3LWMxYzE3M2MxNDdjMGM3ZTYwNzFjMGIwZGRjNTc3ZGU3MGE3NzdmZGMwNmU3NDM5ZTNmM2Y3NjRiNzk4NmM4OGE). +- [Click here to join Slack channel](https://join.slack.com/t/highfrequencybot/shared_invite/enQtNjU5ODcwNjI1MDU3LWEyODBiNzkzNzcyNzU0MWYyYzE5NjIyOTQxMzBmMGUxOTIzM2YyN2Y4NWY1YTEwZDgwYTRmMzE2NmM5ZmY2MTg). ### [Bugs / Issues](https://github.com/freqtrade/freqtrade/issues?q=is%3Aissue) @@ -173,7 +172,7 @@ to understand the requirements before sending your pull-requests. Coding is not a neccessity to contribute - maybe start with improving our documentation? Issues labeled [good first issue](https://github.com/freqtrade/freqtrade/labels/good%20first%20issue) can be good first contributions, and will help get you familiar with the codebase. -**Note** before starting any major new feature work, *please open an issue describing what you are planning to do* or talk to us on [Slack](https://join.slack.com/t/highfrequencybot/shared_invite/enQtMjQ5NTM0OTYzMzY3LWMxYzE3M2MxNDdjMGM3ZTYwNzFjMGIwZGRjNTc3ZGU3MGE3NzdmZGMwNmU3NDM5ZTNmM2Y3NjRiNzk4NmM4OGE). This will ensure that interested parties can give valuable feedback on the feature, and let others know that you are working on it. +**Note** before starting any major new feature work, *please open an issue describing what you are planning to do* or talk to us on [Slack](https://join.slack.com/t/highfrequencybot/shared_invite/enQtNjU5ODcwNjI1MDU3LWEyODBiNzkzNzcyNzU0MWYyYzE5NjIyOTQxMzBmMGUxOTIzM2YyN2Y4NWY1YTEwZDgwYTRmMzE2NmM5ZmY2MTg). This will ensure that interested parties can give valuable feedback on the feature, and let others know that you are working on it. **Important:** Always create your PR against the `develop` branch, not `master`. diff --git a/bin/freqtrade b/bin/freqtrade index e7ae7a4ca..b9e3a7008 100755 --- a/bin/freqtrade +++ b/bin/freqtrade @@ -1,7 +1,13 @@ #!/usr/bin/env python3 import sys +import warnings from freqtrade.main import main, set_loggers + set_loggers() + +warnings.warn( + "Deprecated - To continue to run the bot like this, please run `pip install -e .` again.", + DeprecationWarning) main(sys.argv[1:]) diff --git a/config_binance.json.example b/config_binance.json.example index ab57db88f..1d492fc3c 100644 --- a/config_binance.json.example +++ b/config_binance.json.example @@ -11,8 +11,8 @@ "sell": 30 }, "bid_strategy": { - "ask_last_balance": 0.0, "use_order_book": false, + "ask_last_balance": 0.0, "order_book_top": 1, "check_depth_of_market": { "enabled": false, diff --git a/config_full.json.example b/config_full.json.example index 20ba10c89..acecfb649 100644 --- a/config_full.json.example +++ b/config_full.json.example @@ -22,8 +22,8 @@ "sell": 30 }, "bid_strategy": { - "ask_last_balance": 0.0, "use_order_book": false, + "ask_last_balance": 0.0, "order_book_top": 1, "check_depth_of_market": { "enabled": false, @@ -56,8 +56,10 @@ }, "exchange": { "name": "bittrex", + "sandbox": false, "key": "your_exchange_key", "secret": "your_exchange_secret", + "password": "", "ccxt_config": {"enableRateLimit": true}, "ccxt_async_config": { "enableRateLimit": false, @@ -107,6 +109,13 @@ "token": "your_telegram_token", "chat_id": "your_telegram_chat_id" }, + "api_server": { + "enabled": false, + "listen_ip_address": "127.0.0.1", + "listen_port": 8080, + "username": "freqtrader", + "password": "SuperSecurePassword" + }, "db_url": "sqlite:///tradesv3.sqlite", "initial_state": "running", "forcebuy_enable": false, diff --git a/config_kraken.json.example b/config_kraken.json.example index 7a47b701f..ea3677b2d 100644 --- a/config_kraken.json.example +++ b/config_kraken.json.example @@ -5,15 +5,14 @@ "fiat_display_currency": "EUR", "ticker_interval" : "5m", "dry_run": true, - "db_url": "sqlite:///tradesv3.dryrun.sqlite", "trailing_stop": false, "unfilledtimeout": { "buy": 10, "sell": 30 }, "bid_strategy": { - "ask_last_balance": 0.0, "use_order_book": false, + "ask_last_balance": 0.0, "order_book_top": 1, "check_depth_of_market": { "enabled": false, @@ -60,8 +59,8 @@ }, "telegram": { "enabled": false, - "token": "", - "chat_id": "" + "token": "your_telegram_token", + "chat_id": "your_telegram_chat_id" }, "initial_state": "running", "forcebuy_enable": false, diff --git a/docs/backtesting.md b/docs/backtesting.md index a25d3c1d5..8d8ea8030 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -123,11 +123,12 @@ python scripts/download_backtest_data.py --exchange binance This will download ticker data for all the currency pairs you defined in `pairs.json`. -- To use a different folder than the exchange specific default, use `--export user_data/data/some_directory`. +- To use a different folder than the exchange specific default, use `--datadir user_data/data/some_directory`. - To change the exchange used to download the tickers, use `--exchange`. Default is `bittrex`. - To use `pairs.json` from some other folder, use `--pairs-file some_other_dir/pairs.json`. - To download ticker data for only 10 days, use `--days 10`. - Use `--timeframes` to specify which tickers to download. Default is `--timeframes 1m 5m` which will download 1-minute and 5-minute tickers. +- To use exchange, timeframe and list of pairs as defined in your configuration file, use the `-c/--config` option. With this, the script uses the whitelist defined in the config as the list of currency pairs to download data for and does not require the pairs.json file. You can combine `-c/--config` with other options. For help about backtesting usage, please refer to [Backtesting commands](#backtesting-commands). @@ -220,24 +221,8 @@ strategies, your configuration, and the crypto-currency you have set up. ### Further backtest-result analysis To further analyze your backtest results, you can [export the trades](#exporting-trades-to-file). -You can then load the trades to perform further analysis. +You can then load the trades to perform further analysis as shown in our [data analysis](data-analysis.md#backtesting) backtesting section. -A good way for this is using Jupyter (notebook or lab) - which provides an interactive environment to analyze the data. - -Freqtrade provides an easy to load the backtest results, which is `load_backtest_data` - and takes a path to the backtest-results file. - -``` python -from freqtrade.data.btanalysis import load_backtest_data -df = load_backtest_data("user_data/backtest-result.json") - -# Show value-counts per pair -df.groupby("pair")["sell_reason"].value_counts() - -``` - -This will allow you to drill deeper into your backtest results, and perform analysis which would make the regular backtest-output unreadable. - -If you have some ideas for interesting / helpful backtest data analysis ideas, please submit a PR so the community can benefit from it. ## Backtesting multiple strategies diff --git a/docs/bot-usage.md b/docs/bot-usage.md index 55988985a..b215d7b7c 100644 --- a/docs/bot-usage.md +++ b/docs/bot-usage.md @@ -26,7 +26,8 @@ optional arguments: --version show program's version number and exit -c PATH, --config PATH Specify configuration file (default: None). Multiple - --config options may be used. + --config options may be used. Can be set to '-' to + read config from stdin. -d PATH, --datadir PATH Path to backtest data. -s NAME, --strategy NAME @@ -103,7 +104,7 @@ If the bot does not find your strategy file, it will display in an error message the reason (File not found, or errors in your code). Learn more about strategy file in -[optimize your bot](bot-optimization.md). +[Strategy Customization](strategy-customization.md). ### How to use **--strategy-path**? @@ -146,9 +147,11 @@ Backtesting also uses the config specified via `-c/--config`. ``` usage: freqtrade backtesting [-h] [-i TICKER_INTERVAL] [--timerange TIMERANGE] - [--eps] [--dmmp] [-l] [-r] - [--strategy-list STRATEGY_LIST [STRATEGY_LIST ...]] - [--export EXPORT] [--export-filename PATH] + [--max_open_trades MAX_OPEN_TRADES] + [--stake_amount STAKE_AMOUNT] [-r] [--eps] [--dmmp] + [-l] + [--strategy-list STRATEGY_LIST [STRATEGY_LIST ...]] + [--export EXPORT] [--export-filename PATH] optional arguments: -h, --help show this help message and exit @@ -156,6 +159,14 @@ optional arguments: Specify ticker interval (1m, 5m, 30m, 1h, 1d). --timerange TIMERANGE Specify what timerange of data to use. + --max_open_trades MAX_OPEN_TRADES + Specify max_open_trades to use. + --stake_amount STAKE_AMOUNT + Specify stake_amount. + -r, --refresh-pairs-cached + Refresh the pairs files in tests/testdata with the + latest data from the exchange. Use it if you want to + run your optimization commands with up-to-date data. --eps, --enable-position-stacking Allow buying the same pair multiple times (position stacking). @@ -164,10 +175,6 @@ optional arguments: (same as setting `max_open_trades` to a very high number). -l, --live Use live data. - -r, --refresh-pairs-cached - Refresh the pairs files in tests/testdata with the - latest data from the exchange. Use it if you want to - run your backtesting with up-to-date data. --strategy-list STRATEGY_LIST [STRATEGY_LIST ...] Provide a commaseparated list of strategies to backtest Please note that ticker-interval needs to be @@ -188,7 +195,7 @@ optional arguments: ### How to use **--refresh-pairs-cached** parameter? The first time your run Backtesting, it will take the pairs you have -set in your config file and download data from Bittrex. +set in your config file and download data from the Exchange. If for any reason you want to update your data set, you use `--refresh-pairs-cached` to force Backtesting to update the data it has. @@ -206,8 +213,11 @@ to find optimal parameter values for your stategy. ``` usage: freqtrade hyperopt [-h] [-i TICKER_INTERVAL] [--timerange TIMERANGE] - [--customhyperopt NAME] [--eps] [--dmmp] [-e INT] - [-s {all,buy,sell,roi,stoploss} [{all,buy,sell,roi,stoploss} ...]] + [--max_open_trades MAX_OPEN_TRADES] + [--stake_amount STAKE_AMOUNT] [-r] + [--customhyperopt NAME] [--eps] [--dmmp] [-e INT] + [-s {all,buy,sell,roi,stoploss} [{all,buy,sell,roi,stoploss} ...]] + [--print-all] [-j JOBS] optional arguments: -h, --help show this help message and exit @@ -215,6 +225,14 @@ optional arguments: Specify ticker interval (1m, 5m, 30m, 1h, 1d). --timerange TIMERANGE Specify what timerange of data to use. + --max_open_trades MAX_OPEN_TRADES + Specify max_open_trades to use. + --stake_amount STAKE_AMOUNT + Specify stake_amount. + -r, --refresh-pairs-cached + Refresh the pairs files in tests/testdata with the + latest data from the exchange. Use it if you want to + run your optimization commands with up-to-date data. --customhyperopt NAME Specify hyperopt class name (default: DefaultHyperOpts). @@ -229,7 +247,13 @@ optional arguments: -s {all,buy,sell,roi,stoploss} [{all,buy,sell,roi,stoploss} ...], --spaces {all,buy,sell,roi,stoploss} [{all,buy,sell,roi,stoploss} ...] Specify which parameters to hyperopt. Space separate list. Default: all. - + --print-all Print all results, not only the best ones. + -j JOBS, --job-workers JOBS + The number of concurrently running jobs for + hyperoptimization (hyperopt worker processes). If -1 + (default), all CPUs are used, for -2, all CPUs but one + are used, etc. If 1 is given, no parallel computing + code is used at all. ``` ## Edge commands @@ -237,8 +261,10 @@ optional arguments: To know your trade expectacny and winrate against historical data, you can use Edge. ``` -usage: freqtrade edge [-h] [-i TICKER_INTERVAL] [--timerange TIMERANGE] [-r] - [--stoplosses STOPLOSS_RANGE] +usage: freqtrade edge [-h] [-i TICKER_INTERVAL] [--timerange TIMERANGE] + [--max_open_trades MAX_OPEN_TRADES] + [--stake_amount STAKE_AMOUNT] [-r] + [--stoplosses STOPLOSS_RANGE] optional arguments: -h, --help show this help message and exit @@ -246,10 +272,14 @@ optional arguments: Specify ticker interval (1m, 5m, 30m, 1h, 1d). --timerange TIMERANGE Specify what timerange of data to use. + --max_open_trades MAX_OPEN_TRADES + Specify max_open_trades to use. + --stake_amount STAKE_AMOUNT + Specify stake_amount. -r, --refresh-pairs-cached Refresh the pairs files in tests/testdata with the latest data from the exchange. Use it if you want to - run your edge with up-to-date data. + run your optimization commands with up-to-date data. --stoplosses STOPLOSS_RANGE Defines a range of stoploss against which edge will assess the strategy the format is "min,max,step" @@ -267,4 +297,4 @@ in [misc.py](https://github.com/freqtrade/freqtrade/blob/develop/freqtrade/misc. ## Next step The optimal strategy of the bot will change with time depending of the market trends. The next step is to -[optimize your bot](bot-optimization.md). +[Strategy Customization](strategy-customization.md). diff --git a/docs/configuration.md b/docs/configuration.md index 0da14d9f6..9c3b20338 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -40,10 +40,10 @@ Mandatory Parameters are marked as **Required**. | `ask_strategy.order_book_max` | 0 | Bot will scan from the top min to max Order Book Asks searching for a profitable rate. | `order_types` | None | 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). | `order_time_in_force` | None | Configure time in force for buy and sell orders. [More information below](#understand-order_time_in_force). [Strategy Override](#parameters-in-the-strategy). -| `exchange.name` | bittrex | **Required.** Name of the exchange class to use. [List below](#user-content-what-values-for-exchangename). +| `exchange.name` | | **Required.** Name of the exchange class to use. [List below](#user-content-what-values-for-exchangename). | `exchange.sandbox` | false | 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. -| `exchange.key` | key | API key to use for the exchange. Only required when you are in production mode. -| `exchange.secret` | secret | API secret to use for the exchange. Only required when you are in production mode. +| `exchange.key` | '' | API key to use for the exchange. Only required when you are in production mode. +| `exchange.secret` | '' | API secret to use for the exchange. Only required when you are in production mode. | `exchange.pair_whitelist` | [] | List of currency to use by the bot. Can be overrided with `--dynamic-whitelist` param. | `exchange.pair_blacklist` | [] | List of currency the bot must avoid. Useful when using `--dynamic-whitelist` param. | `exchange.ccxt_config` | None | 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) @@ -131,17 +131,11 @@ If it is not set in either Strategy or Configuration, a default of 1000% `{"0": ### Understand stoploss -The `stoploss` configuration parameter is loss in percentage that should trigger a sale. -For example, value `-0.10` will cause immediate sell if the -profit dips below -10% for a given trade. This parameter is optional. - -Most of the strategy files already include the optimal `stoploss` -value. This parameter is optional. If you use it in the configuration file, it will take over the -`stoploss` value from the strategy file. +Go to the [stoploss documentation](stoploss.md) for more details. ### Understand trailing stoploss -Go to the [trailing stoploss Documentation](stoploss.md) for details on trailing stoploss. +Go to the [trailing stoploss Documentation](stoploss.md#trailing-stop-loss) for details on trailing stoploss. ### Understand initial_state @@ -191,14 +185,28 @@ If this is configured, all 4 values (`buy`, `sell`, `stoploss` and `stoploss_on_exchange`) need to be present, otherwise the bot will warn about it and fail to start. The below is the default which is used if this is not configured in either strategy or configuration file. +Syntax for Strategy: + ```python -"order_types": { +order_types = { "buy": "limit", "sell": "limit", "stoploss": "market", "stoploss_on_exchange": False, "stoploss_on_exchange_interval": 60 -}, +} +``` + +Configuration: + +```json +"order_types": { + "buy": "limit", + "sell": "limit", + "stoploss": "market", + "stoploss_on_exchange": false, + "stoploss_on_exchange_interval": 60 +} ``` !!! Note @@ -287,8 +295,27 @@ This configuration enables binance, as well as rate limiting to avoid bans from !!! Note Optimal settings for rate limiting depend on the exchange and the size of the whitelist, so an ideal parameter will vary on many other settings. - We try to provide sensible defaults per exchange where possible, if you encounter bans please make sure that `"enableRateLimit"` is enabled and increase the `"rateLimit"` parameter step by step. + We try to provide sensible defaults per exchange where possible, if you encounter bans please make sure that `"enableRateLimit"` is enabled and increase the `"rateLimit"` parameter step by step. +#### Advanced FreqTrade Exchange configuration + +Advanced options can be configured using the `_ft_has_params` setting, which will override Defaults and exchange-specific behaviours. + +Available options are listed in the exchange-class as `_ft_has_default`. + +For example, to test the order type `FOK` with Kraken, and modify candle_limit to 200 (so you only get 200 candles per call): + +```json +"exchange": { + "name": "kraken", + "_ft_has_params": { + "order_time_in_force": ["gtc", "fok"], + "ohlcv_candle_limit": 200 + } +``` + +!!! Warning + Please make sure to fully understand the impacts of these settings before modifying them. ### What values can be used for fiat_display_currency? diff --git a/docs/data-analysis.md b/docs/data-analysis.md new file mode 100644 index 000000000..1940fa3e6 --- /dev/null +++ b/docs/data-analysis.md @@ -0,0 +1,42 @@ +# Analyzing bot data + +After performing backtests, or after running the bot for some time, it will be interesting to analyze the results your bot generated. + +A good way for this is using Jupyter (notebook or lab) - which provides an interactive environment to analyze the data. + +The following helpers will help you loading the data into Pandas DataFrames, and may also give you some starting points in analyzing the results. + +## Backtesting + +To analyze your backtest results, you can [export the trades](#exporting-trades-to-file). +You can then load the trades to perform further analysis. + +Freqtrade provides the `load_backtest_data()` helper function to easily load the backtest results, which takes the path to the the backtest-results file as parameter. + +``` python +from freqtrade.data.btanalysis import load_backtest_data +df = load_backtest_data("user_data/backtest-result.json") + +# Show value-counts per pair +df.groupby("pair")["sell_reason"].value_counts() + +``` + +This will allow you to drill deeper into your backtest results, and perform analysis which otherwise would make the regular backtest-output very difficult to digest due to information overload. + +If you have some ideas for interesting / helpful backtest data analysis ideas, please submit a Pull Request so the community can benefit from it. + +## Live data + +To analyze the trades your bot generated, you can load them to a DataFrame as follows: + +``` python +from freqtrade.data.btanalysis import load_trades_from_db + +df = load_trades_from_db("sqlite:///tradesv3.sqlite") + +df.groupby("pair")["sell_reason"].value_counts() + +``` + +Feel free to submit an issue or Pull Request enhancing this document if you would like to share ideas on how to best analyze the data. diff --git a/docs/developer.md b/docs/developer.md index 6fbcdc812..cf6b5d2cd 100644 --- a/docs/developer.md +++ b/docs/developer.md @@ -2,7 +2,7 @@ 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/enQtMjQ5NTM0OTYzMzY3LWMxYzE3M2MxNDdjMGM3ZTYwNzFjMGIwZGRjNTc3ZGU3MGE3NzdmZGMwNmU3NDM5ZTNmM2Y3NjRiNzk4NmM4OGE) where you can ask questions. +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/enQtNjU5ODcwNjI1MDU3LWEyODBiNzkzNzcyNzU0MWYyYzE5NjIyOTQxMzBmMGUxOTIzM2YyN2Y4NWY1YTEwZDgwYTRmMzE2NmM5ZmY2MTg) where you can ask questions. ## Documentation @@ -81,6 +81,51 @@ Please also run `self._validate_whitelist(pairs)` and to check and remove pairs This is a simple method used by `VolumePairList` - however serves as a good example. It implements caching (`@cached(TTLCache(maxsize=1, ttl=1800))`) as well as a configuration option to allow different (but similar) strategies to work with the same PairListProvider. +## 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. + +Most exchanges supported by CCXT should work out of the box. + +### Stoploss On Exchange + +Check if the new exchange supports Stoploss on Exchange orders through their API. + +Since CCXT does not provide unification for Stoploss On Exchange yet, we'll need to implement the exchange-specific parameters ourselfs. Best look at `binance.py` for an example implementation of this. You'll need to dig through the documentation of the Exchange's API on how exactly this can be done. [CCXT Issues](https://github.com/ccxt/ccxt/issues) may also provide great help, since others may have implemented something similar for their projects. + +### Incomplete candles + +While fetching OHLCV data, we're may end up getting incomplete candles (Depending on the exchange). +To demonstrate this, we'll use daily candles (`"1d"`) to keep things simple. +We query the api (`ct.fetch_ohlcv()`) for the timeframe and look at the date of the last entry. If this entry changes or shows the date of a "incomplete" candle, then we should drop this since having incomplete candles is problematic because indicators assume that only complete candles are passed to them, and will generate a lot of false buy signals. By default, we're therefore removing the last candle assuming it's incomplete. + +To check how the new exchange behaves, you can use the following snippet: + +``` python +import ccxt +from datetime import datetime +from freqtrade.data.converter import parse_ticker_dataframe +ct = ccxt.binance() +timeframe = "1d" +pair = "XLM/BTC" # Make sure to use a pair that exists on that exchange! +raw = ct.fetch_ohlcv(pair, timeframe=timeframe) + +# convert to dataframe +df1 = parse_ticker_dataframe(raw, timeframe, pair=pair, drop_incomplete=False) + +print(df1["date"].tail(1)) +print(datetime.utcnow()) +``` + +``` output +19 2019-06-08 00:00:00+00:00 +2019-06-09 12:30:27.873327 +``` + +The output will show the last entry from the Exchange as well as the current UTC date. +If the day shows the same day, then the last candle can be assumed as incomplete and should be dropped (leave the setting `"ohlcv_partial_candle"` from the exchange-class untouched / True). Otherwise, set `"ohlcv_partial_candle"` to `False` to not drop Candles (shown in the example above). + ## Creating a release This part of the documentation is aimed at maintainers, and shows how to create a release. @@ -95,9 +140,9 @@ git checkout develop git checkout -b new_release ``` -* edit `freqtrade/__init__.py` and add the desired version (for example `0.18.0`) +* Edit `freqtrade/__init__.py` and add the desired version (for example `0.18.0`) * Commit this part -* push that branch to the remote and create a PR +* push that branch to the remote and create a PR against the master branch ### create changelog from git commits @@ -108,10 +153,12 @@ git log --oneline --no-decorate --no-merges master..develop ### Create github release / tag +* Use the button "Draft a new release" in the Github UI (subsection releases) * Use the version-number specified as tag. * Use "master" as reference (this step comes after the above PR is merged). -* use the above changelog as release comment (as codeblock) +* Use the above changelog as release comment (as codeblock) ### After-release -* update version in develop to next valid version and postfix that with `-dev` (`0.18.0 -> 0.18.1-dev`) +* Update version in develop to next valid version and postfix that with `-dev` (`0.18.0 -> 0.18.1-dev`). +* Create a PR against develop to update that branch. diff --git a/docs/docker.md b/docs/docker.md new file mode 100644 index 000000000..939ab3f7d --- /dev/null +++ b/docs/docker.md @@ -0,0 +1,204 @@ +# Using FreqTrade with Docker + +## Install Docker + +Start by downloading and installing Docker CE for your platform: + +* [Mac](https://docs.docker.com/docker-for-mac/install/) +* [Windows](https://docs.docker.com/docker-for-windows/install/) +* [Linux](https://docs.docker.com/install/) + +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 + +Pull the image from docker hub. + +Branches / tags available can be checked out on [Dockerhub](https://hub.docker.com/r/freqtradeorg/freqtrade/tags/). + +```bash +docker pull freqtradeorg/freqtrade:develop +# Optionally tag the repository so the run-commands remain shorter +docker tag freqtradeorg/freqtrade:develop freqtrade +``` + +To update the image, simply run the above commands again and restart your running container. + +Should you require additional libraries, please [build the image yourself](#build-your-own-docker-image). + +### Prepare the configuration files + +Even though you will use docker, you'll still need some files from the github repository. + +#### Clone the git repository + +Linux/Mac/Windows with WSL + +```bash +git clone https://github.com/freqtrade/freqtrade.git +``` + +Windows with docker + +```bash +git clone --config core.autocrlf=input https://github.com/freqtrade/freqtrade.git +``` + +#### Copy `config.json.example` to `config.json` + +```bash +cd freqtrade +cp -n config.json.example config.json +``` + +> To understand the configuration options, please refer to the [Bot Configuration](configuration.md) page. + +#### Create your database file + +Production + +```bash +touch tradesv3.sqlite +```` + +Dry-Run + +```bash +touch tradesv3.dryrun.sqlite +``` + +!!! Note + Make sure to use the path to this file when starting the bot in docker. + +### Build your own Docker image + +Best start by pulling the official docker image from dockerhub as explained [here](#download-the-official-docker-image) to speed up building. + +To add additional libraries to your docker image, best check out [Dockerfile.technical](https://github.com/freqtrade/freqtrade/blob/develop/Dockerfile.technical) which adds the [technical](https://github.com/freqtrade/technical) module to the image. + +```bash +docker build -t freqtrade -f Dockerfile.technical . +``` + +If you are developing using Docker, use `Dockerfile.develop` to build a dev Docker image, which will also set up develop dependencies: + +```bash +docker build -f Dockerfile.develop -t freqtrade-dev . +``` + +!!! Note + For security reasons, your configuration file will not be included in the image, you will need to bind mount it. It is also advised to bind mount an SQLite database file (see the "5. Run a restartable docker image" section) to keep it between updates. + +#### Verify the Docker image + +After the build process you can verify that the image was created with: + +```bash +docker images +``` + +The output should contain the freqtrade image. + +### Run the Docker image + +You can run a one-off container that is immediately deleted upon exiting with the following command (`config.json` must be in the current working directory): + +```bash +docker run --rm -v `pwd`/config.json:/freqtrade/config.json -it freqtrade +``` + +!!! Warning + In this example, the database will be created inside the docker instance and will be lost when you will refresh your image. + +#### Adjust timezone + +By default, the container will use UTC timezone. +Should you find this irritating please add the following to your docker commands: + +##### Linux + +``` bash +-v /etc/timezone:/etc/timezone:ro + +# Complete command: +docker run --rm -v /etc/timezone:/etc/timezone:ro -v `pwd`/config.json:/freqtrade/config.json -it freqtrade +``` + +##### MacOS + +There is known issue in OSX Docker versions after 17.09.1, whereby `/etc/localtime` cannot be shared causing Docker to not start. A work-around for this is to start with the following cmd. + +```bash +docker run --rm -e TZ=`ls -la /etc/localtime | cut -d/ -f8-9` -v `pwd`/config.json:/freqtrade/config.json -it freqtrade +``` + +More information on this docker issue and work-around can be read [here](https://github.com/docker/for-mac/issues/2396). + +### Run a restartable docker image + +To run a restartable instance in the background (feel free to place your configuration and database files wherever it feels comfortable on your filesystem). + +#### Move your config file and database + +The following will assume that you place your configuration / database files to `~/.freqtrade`, which is a hidden folder in your home directory. Feel free to use a different folder and replace the folder in the upcomming commands. + +```bash +mkdir ~/.freqtrade +mv config.json ~/.freqtrade +mv tradesv3.sqlite ~/.freqtrade +``` + +#### Run the docker image + +```bash +docker run -d \ + --name freqtrade \ + -v ~/.freqtrade/config.json:/freqtrade/config.json \ + -v ~/.freqtrade/user_data/:/freqtrade/user_data \ + -v ~/.freqtrade/tradesv3.sqlite:/freqtrade/tradesv3.sqlite \ + freqtrade --db-url sqlite:///tradesv3.sqlite --strategy MyAwesomeStrategy +``` + +!!! Note + db-url defaults to `sqlite:///tradesv3.sqlite` but it defaults to `sqlite://` if `dry_run=True` is being used. + To override this behaviour use a custom db-url value: i.e.: `--db-url sqlite:///tradesv3.dryrun.sqlite` + +!!! Note + All available bot command line parameters can be added to the end of the `docker run` command. + +### Monitor your Docker instance + +You can use the following commands to monitor and manage your container: + +```bash +docker logs freqtrade +docker logs -f freqtrade +docker restart freqtrade +docker stop freqtrade +docker start freqtrade +``` + +For more information on how to operate Docker, please refer to the [official Docker documentation](https://docs.docker.com/). + +!!! Note + You do not need to rebuild the image for configuration changes, it will suffice to edit `config.json` and restart the container. + +### Backtest with docker + +The following assumes that the download/setup of the docker image have been completed successfully. +Also, backtest-data should be available at `~/.freqtrade/user_data/`. + +```bash +docker run -d \ + --name freqtrade \ + -v /etc/localtime:/etc/localtime:ro \ + -v ~/.freqtrade/config.json:/freqtrade/config.json \ + -v ~/.freqtrade/tradesv3.sqlite:/freqtrade/tradesv3.sqlite \ + -v ~/.freqtrade/user_data/:/freqtrade/user_data/ \ + freqtrade --strategy AwsomelyProfitableStrategy backtesting +``` + +Head over to the [Backtesting Documentation](backtesting.md) for more details. + +!!! Note + Additional bot command line parameters can be appended after the image name (`freqtrade` in the above example). diff --git a/docs/hyperopt.md b/docs/hyperopt.md index b4e42de16..15b02b56f 100644 --- a/docs/hyperopt.md +++ b/docs/hyperopt.md @@ -12,7 +12,7 @@ and still take a long time. ## Prepare Hyperopting Before we start digging into Hyperopt, we recommend you to take a look at -an example hyperopt file located into [user_data/hyperopts/](https://github.com/freqtrade/freqtrade/blob/develop/user_data/hyperopts/test_hyperopt.py) +an example hyperopt file located into [user_data/hyperopts/](https://github.com/freqtrade/freqtrade/blob/develop/user_data/hyperopts/sample_hyperopt.py) Configuring hyperopt is similar to writing your own strategy, and many tasks will be similar and a lot of code can be copied across from the strategy. @@ -71,6 +71,11 @@ Place the corresponding settings into the following methods The configuration and rules are the same than for buy signals. To avoid naming collisions in the search-space, please prefix all sell-spaces with `sell-`. +#### Using ticker-interval as part of the Strategy + +The Strategy exposes the ticker-interval as `self.ticker_interval`. The same value is available as class-attribute `HyperoptName.ticker_interval`. +In the case of the linked sample-value this would be `SampleHyperOpts.ticker_interval`. + ## Solving a Mystery Let's say you are curious: should you use MACD crossings or lower Bollinger @@ -122,9 +127,10 @@ So let's write the buy strategy using these values: dataframe['macd'], dataframe['macdsignal'] )) - dataframe.loc[ - reduce(lambda x, y: x & y, conditions), - 'buy'] = 1 + if conditions: + dataframe.loc[ + reduce(lambda x, y: x & y, conditions), + 'buy'] = 1 return dataframe diff --git a/docs/index.md b/docs/index.md index 9abc71747..63d6be75e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,8 +21,8 @@ Freqtrade is a cryptocurrency trading bot written in Python. We strongly recommend you to have basic coding skills and Python knowledge. Do not hesitate to read the source code and understand the mechanisms of this bot, algorithms and techniques implemented in it. - ## Features + - Based on Python 3.6+: For botting on any operating system — Windows, macOS and Linux. - Persistence: Persistence is achieved through sqlite database. - Dry-run mode: Run the bot without playing money. @@ -31,17 +31,19 @@ Freqtrade is a cryptocurrency trading bot written in Python. - Edge position sizing: Calculate your win rate, risk reward ratio, the best stoploss and adjust your position size before taking a position for each specific market. - Whitelist crypto-currencies: Select which crypto-currency you want to trade or use dynamic whitelists based on market (pair) trade volume. - Blacklist crypto-currencies: Select which crypto-currency you want to avoid. - - Manageable via Telegram: Manage the bot with Telegram. + - Manageable via Telegram or REST APi: Manage the bot with Telegram or via the builtin REST API. - Display profit/loss in fiat: Display your profit/loss in any of 33 fiat currencies supported. - Daily summary of profit/loss: Receive the daily summary of your profit/loss. - Performance status report: Receive the performance status of your current trades. - ## Requirements + ### Up to date clock + The clock on the system running the bot must be accurate, synchronized to a NTP server frequently enough to avoid problems with communication to the exchanges. ### Hardware requirements + To run this bot we recommend you a cloud instance with a minimum of: - 2GB RAM @@ -49,6 +51,7 @@ To run this bot we recommend you a cloud instance with a minimum of: - 2vCPU ### Software requirements + - Python 3.6.x - pip (pip3) - git @@ -56,12 +59,13 @@ To run this bot we recommend you a cloud instance with a minimum of: - virtualenv (Recommended) - Docker (Recommended) - ## Support + Help / Slack For any questions not covered by the documentation or for further information about the bot, we encourage you to join our Slack channel. -Click [here](https://join.slack.com/t/highfrequencybot/shared_invite/enQtMjQ5NTM0OTYzMzY3LWMxYzE3M2MxNDdjMGM3ZTYwNzFjMGIwZGRjNTc3ZGU3MGE3NzdmZGMwNmU3NDM5ZTNmM2Y3NjRiNzk4NmM4OGE) to join Slack channel. +Click [here](https://join.slack.com/t/highfrequencybot/shared_invite/enQtNjU5ODcwNjI1MDU3LWEyODBiNzkzNzcyNzU0MWYyYzE5NjIyOTQxMzBmMGUxOTIzM2YyN2Y4NWY1YTEwZDgwYTRmMzE2NmM5ZmY2MTg) to join Slack channel. ## Ready to try? + Begin by reading our installation guide [here](installation). diff --git a/docs/installation.md b/docs/installation.md index 23a6cbd23..f0c536ade 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,58 +1,21 @@ # Installation + This page explains how to prepare your environment for running the bot. ## Prerequisite + Before running your bot in production you will need to setup few -external API. In production mode, the bot required valid Bittrex API -credentials and a Telegram bot (optional but recommended). +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](#setup-your-exchange-account) -- [Backtesting commands](#setup-your-telegram-bot) ### Setup your exchange account -*To be completed, please feel free to complete this section.* -### Setup your Telegram bot -The only things you need is a working Telegram bot and its API token. -Below we explain how to create your Telegram Bot, and how to get your -Telegram user id. +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. -### 1. Create your Telegram bot - -**1.1. Start a chat with https://telegram.me/BotFather** - -**1.2. Send the message `/newbot`. ** *BotFather response:* -``` -Alright, a new bot. How are we going to call it? Please choose a name for your bot. -``` - -**1.3. Choose the public name of your bot (e.x. `Freqtrade bot`)** -*BotFather response:* -``` -Good. Now let's choose a username for your bot. It must end in `bot`. Like this, for example: TetrisBot or tetris_bot. -``` -**1.4. Choose the name id of your bot (e.x "`My_own_freqtrade_bot`")** - -**1.5. Father bot will return you the token (API key)**
-Copy it and keep it you will use it for the config parameter `token`. -*BotFather response:* -```hl_lines="4" -Done! Congratulations on your new bot. You will find it at t.me/My_own_freqtrade_bot. You can now add a description, about section and profile picture for your bot, see /help for a list of commands. By the way, when you've finished creating your cool bot, ping our Bot Support if you want a better username for it. Just make sure the bot is fully operational before you do this. - -Use this token to access the HTTP API: -521095879:AAEcEZEL7ADJ56FtG_qD0bQJSKETbXCBCi0 - -For a description of the Bot API, see this page: https://core.telegram.org/bots/api -``` -**1.6. Don't forget to start the conversation with your bot, by clicking /START button** - -### 2. Get your user id -**2.1. Talk to https://telegram.me/userinfobot** - -**2.2. Get your "Id", you will use it for the config parameter -`chat_id`.** -
## Quick start + Freqtrade provides a Linux/MacOS script to install all dependencies and help you to configure the bot. ```bash @@ -61,9 +24,10 @@ cd freqtrade git checkout develop ./setup.sh --install ``` + !!! Note Windows installation is explained [here](#windows). -
+ ## Easy Installation - Linux Script If you are on Debian, Ubuntu or MacOS a freqtrade provides a script to Install, Update, Configure, and Reset your bot. @@ -101,189 +65,6 @@ Config parameter is a `config.json` configurator. This script will ask you quest ------ -## Automatic Installation - Docker - -Start by downloading Docker for your platform: - -* [Mac](https://www.docker.com/products/docker#/mac) -* [Windows](https://www.docker.com/products/docker#/windows) -* [Linux](https://www.docker.com/products/docker#/linux) - -Once you have Docker installed, simply create the config file (e.g. `config.json`) and then create a Docker image for `freqtrade` using the Dockerfile in this repo. - -### 1. Prepare the Bot - -**1.1. Clone the git repository** - -Linux/Mac/Windows with WSL -```bash -git clone https://github.com/freqtrade/freqtrade.git -``` - -Windows with docker -```bash -git clone --config core.autocrlf=input https://github.com/freqtrade/freqtrade.git -``` - -**1.2. (Optional) Checkout the develop branch** - -```bash -git checkout develop -``` - -**1.3. Go into the new directory** - -```bash -cd freqtrade -``` - -**1.4. Copy `config.json.example` to `config.json`** - -```bash -cp -n config.json.example config.json -``` - -> To edit the config please refer to the [Bot Configuration](configuration.md) page. - -**1.5. Create your database file *(optional - the bot will create it if it is missing)** - -Production - -```bash -touch tradesv3.sqlite -```` - -Dry-Run - -```bash -touch tradesv3.dryrun.sqlite -``` - -### 2. Download or build the docker image - -Either use the prebuilt image from docker hub - or build the image yourself if you would like more control on which version is used. - -Branches / tags available can be checked out on [Dockerhub](https://hub.docker.com/r/freqtradeorg/freqtrade/tags/). - -**2.1. Download the docker image** - -Pull the image from docker hub and (optionally) change the name of the image - -```bash -docker pull freqtradeorg/freqtrade:develop -# Optionally tag the repository so the run-commands remain shorter -docker tag freqtradeorg/freqtrade:develop freqtrade -``` - -To update the image, simply run the above commands again and restart your running container. - -**2.2. Build the Docker image** - -```bash -cd freqtrade -docker build -t freqtrade . -``` - -If you are developing using Docker, use `Dockerfile.develop` to build a dev Docker image, which will also set up develop dependencies: - -```bash -docker build -f ./Dockerfile.develop -t freqtrade-dev . -``` - -For security reasons, your configuration file will not be included in the image, you will need to bind mount it. It is also advised to bind mount an SQLite database file (see the "5. Run a restartable docker image" section) to keep it between updates. - -### 3. Verify the Docker image - -After the build process you can verify that the image was created with: - -```bash -docker images -``` - -### 4. Run the Docker image - -You can run a one-off container that is immediately deleted upon exiting with the following command (`config.json` must be in the current working directory): - -```bash -docker run --rm -v /etc/localtime:/etc/localtime:ro -v `pwd`/config.json:/freqtrade/config.json -it freqtrade -``` - -There is known issue in OSX Docker versions after 17.09.1, whereby /etc/localtime cannot be shared causing Docker to not start. A work-around for this is to start with the following cmd. - -```bash -docker run --rm -e TZ=`ls -la /etc/localtime | cut -d/ -f8-9` -v `pwd`/config.json:/freqtrade/config.json -it freqtrade -``` - -More information on this docker issue and work-around can be read [here](https://github.com/docker/for-mac/issues/2396). - -In this example, the database will be created inside the docker instance and will be lost when you will refresh your image. - -### 5. Run a restartable docker image - -To run a restartable instance in the background (feel free to place your configuration and database files wherever it feels comfortable on your filesystem). - -**5.1. Move your config file and database** - -```bash -mkdir ~/.freqtrade -mv config.json ~/.freqtrade -mv tradesv3.sqlite ~/.freqtrade -``` - -**5.2. Run the docker image** - -```bash -docker run -d \ - --name freqtrade \ - -v /etc/localtime:/etc/localtime:ro \ - -v ~/.freqtrade/config.json:/freqtrade/config.json \ - -v ~/.freqtrade/tradesv3.sqlite:/freqtrade/tradesv3.sqlite \ - freqtrade --db-url sqlite:///tradesv3.sqlite -``` - -!!! Note - db-url defaults to `sqlite:///tradesv3.sqlite` but it defaults to `sqlite://` if `dry_run=True` is being used. - To override this behaviour use a custom db-url value: i.e.: `--db-url sqlite:///tradesv3.dryrun.sqlite` - -### 6. Monitor your Docker instance - -You can then use the following commands to monitor and manage your container: - -```bash -docker logs freqtrade -docker logs -f freqtrade -docker restart freqtrade -docker stop freqtrade -docker start freqtrade -``` - -For more information on how to operate Docker, please refer to the [official Docker documentation](https://docs.docker.com/). - -!!! Note - You do not need to rebuild the image for configuration changes, it will suffice to edit `config.json` and restart the container. - -### 7. Backtest with docker - -The following assumes that the above steps (1-4) have been completed successfully. -Also, backtest-data should be available at `~/.freqtrade/user_data/`. - -```bash -docker run -d \ - --name freqtrade \ - -v /etc/localtime:/etc/localtime:ro \ - -v ~/.freqtrade/config.json:/freqtrade/config.json \ - -v ~/.freqtrade/tradesv3.sqlite:/freqtrade/tradesv3.sqlite \ - -v ~/.freqtrade/user_data/:/freqtrade/user_data/ \ - freqtrade --strategy AwsomelyProfitableStrategy backtesting -``` - -Head over to the [Backtesting Documentation](backtesting.md) for more details. - -!!! Note - Additional parameters can be appended after the image name (`freqtrade` in the above example). - ------- - ## Custom Installation We've included/collected install instructions for Ubuntu 16.04, MacOS, and Windows. These are guidelines and your success may vary with other distros. @@ -326,7 +107,7 @@ conda activate freqtrade conda install scipy pandas numpy sudo apt install libffi-dev -python3 -m pip install -r requirements-pi.txt +python3 -m pip install -r requirements-common.txt python3 -m pip install -e . ``` @@ -409,7 +190,7 @@ If this is the first time you run the bot, ensure you are running it in Dry-run python3.6 freqtrade -c config.json ``` -*Note*: If you run the bot on a server, you should consider using [Docker](#automatic-installation---docker) a terminal multiplexer like `screen` or [`tmux`](https://en.wikipedia.org/wiki/Tmux) to avoid that the bot is stopped on logout. +*Note*: If you run the bot on a server, you should consider using [Docker](docker.md) or a terminal multiplexer like `screen` or [`tmux`](https://en.wikipedia.org/wiki/Tmux) to avoid that the bot is stopped on logout. #### 7. [Optional] Configure `freqtrade` as a `systemd` service @@ -437,14 +218,13 @@ The `freqtrade.service.watchdog` file contains an example of the service unit co as the watchdog. !!! Note - The sd_notify communication between the bot and the systemd service manager will not work if the bot runs in a - Docker container. + The sd_notify communication between the bot and the systemd service manager will not work if the bot runs in a Docker container. ------ ## Windows -We recommend that Windows users use [Docker](#docker) as this will work much easier and smoother (also more secure). +We recommend that Windows users use [Docker](docker.md) as this will work much easier and smoother (also more secure). If that is not possible, try using the Windows Linux subsystem (WSL) - for which the Ubuntu instructions should work. If that is not available on your system, feel free to try the instructions below, which led to success for some. @@ -488,7 +268,7 @@ error: Microsoft Visual C++ 14.0 is required. Get it with "Microsoft Visual C++ Unfortunately, many packages requiring compilation don't provide a pre-build wheel. It is therefore mandatory to have a C/C++ compiler installed and available for your python environment to use. -The easiest way is to download install Microsoft Visual Studio Community [here](https://visualstudio.microsoft.com/downloads/) and make sure to install "Common Tools for Visual C++" to enable building c code on Windows. Unfortunately, this is a heavy download / dependency (~4Gb) so you might want to consider WSL or docker first. +The easiest way is to download install Microsoft Visual Studio Community [here](https://visualstudio.microsoft.com/downloads/) and make sure to install "Common Tools for Visual C++" to enable building c code on Windows. Unfortunately, this is a heavy download / dependency (~4Gb) so you might want to consider WSL or [docker](docker.md) first. --- diff --git a/docs/plotting.md b/docs/plotting.md index 60c642ab3..b8e041d61 100644 --- a/docs/plotting.md +++ b/docs/plotting.md @@ -1,63 +1,83 @@ # Plotting -This page explains how to plot prices, indicator, profits. + +This page explains how to plot prices, indicators and profits. ## Installation Plotting scripts use Plotly library. Install/upgrade it with: +``` bash +pip install -U -r requirements-plot.txt ``` -pip install --upgrade plotly -``` - -At least version 2.3.0 is required. ## Plot price and indicators + Usage for the price plotter: -``` -script/plot_dataframe.py [-h] [-p pairs] [--live] +``` bash +python3 script/plot_dataframe.py [-h] [-p pairs] [--live] ``` Example -``` -python scripts/plot_dataframe.py -p BTC/ETH + +``` bash +python3 scripts/plot_dataframe.py -p BTC/ETH ``` -The `-p` pairs argument, can be used to specify -pairs you would like to plot. +The `-p` pairs argument can be used to specify pairs you would like to plot. -**Advanced use** +Specify custom indicators. +Use `--indicators1` for the main plot and `--indicators2` for the subplot below (if values are in a different range than prices). + +``` bash +python3 scripts/plot_dataframe.py -p BTC/ETH --indicators1 sma,ema --indicators2 macd +``` + +### Advanced use To plot multiple pairs, separate them with a comma: -``` -python scripts/plot_dataframe.py -p BTC/ETH,XRP/ETH + +``` bash +python3 scripts/plot_dataframe.py -p BTC/ETH,XRP/ETH ``` To plot the current live price use the `--live` flag: -``` -python scripts/plot_dataframe.py -p BTC/ETH --live + +``` bash +python3 scripts/plot_dataframe.py -p BTC/ETH --live ``` To plot a timerange (to zoom in): + +``` bash +python3 scripts/plot_dataframe.py -p BTC/ETH --timerange=100-200 ``` -python scripts/plot_dataframe.py -p BTC/ETH --timerange=100-200 -``` + Timerange doesn't work with live data. To plot trades stored in a database use `--db-url` argument: -``` -python scripts/plot_dataframe.py --db-url sqlite:///tradesv3.dry_run.sqlite -p BTC/ETH + +``` bash +python3 scripts/plot_dataframe.py --db-url sqlite:///tradesv3.dry_run.sqlite -p BTC/ETH --trade-source DB ``` -To plot a test strategy the strategy should have first be backtested. -The results may then be plotted with the -s argument: +To plot trades from a backtesting result, use `--export-filename ` + +``` bash +python3 scripts/plot_dataframe.py --export-filename user_data/backtest_data/backtest-result.json -p BTC/ETH ``` -python scripts/plot_dataframe.py -s Strategy_Name -p BTC/ETH --datadir user_data/data// + +To plot a custom strategy the strategy should have first be backtested. +The results may then be plotted with the -s argument: + +``` bash +python3 scripts/plot_dataframe.py -s Strategy_Name -p BTC/ETH --datadir user_data/data// ``` ## Plot profit -The profit plotter show a picture with three plots: +The profit plotter shows a picture with three plots: + 1) Average closing price for all pairs 2) The summarized profit made by backtesting. Note that this is not the real-world profit, but @@ -67,7 +87,7 @@ The profit plotter show a picture with three plots: The first graph is good to get a grip of how the overall market progresses. -The second graph will show how you algorithm works or doesnt. +The second graph will show how your algorithm works or doesn't. Perhaps you want an algorithm that steadily makes small profits, or one that acts less seldom, but makes big swings. @@ -76,13 +96,14 @@ that makes profit spikes. Usage for the profit plotter: -``` -script/plot_profit.py [-h] [-p pair] [--datadir directory] [--ticker_interval num] +``` bash +python3 script/plot_profit.py [-h] [-p pair] [--datadir directory] [--ticker_interval num] ``` The `-p` pair argument, can be used to plot a single pair Example -``` + +``` bash python3 scripts/plot_profit.py --datadir ../freqtrade/freqtrade/tests/testdata-20171221/ -p LTC/BTC ``` diff --git a/docs/rest-api.md b/docs/rest-api.md new file mode 100644 index 000000000..0508f83e4 --- /dev/null +++ b/docs/rest-api.md @@ -0,0 +1,193 @@ +# REST API Usage + +## Configuration + +Enable the rest API by adding the api_server section to your configuration and setting `api_server.enabled` to `true`. + +Sample configuration: + +``` json + "api_server": { + "enabled": true, + "listen_ip_address": "127.0.0.1", + "listen_port": 8080, + "username": "Freqtrader", + "password": "SuperSecret1!" + }, +``` + +!!! Danger: Security warning + By default, the configuration listens on localhost only (so it's not reachable from other systems). We strongly recommend to not expose this API to the internet and choose a strong, unique password, since others will potentially be able to control your bot. + +!!! Danger: Password selection + Please make sure to select a very strong, unique password to protect your bot from unauthorized access. + +You can then access the API by going to `http://127.0.0.1:8080/api/v1/version` to check if the API is running correctly. + +To generate a secure password, either use a password manager, or use the below code snipped. + +``` python +import secrets +secrets.token_hex() +``` + +### Configuration with docker + +If you run your bot using docker, you'll need to have the bot listen to incomming connections. The security is then handled by docker. + +``` json + "api_server": { + "enabled": true, + "listen_ip_address": "0.0.0.0", + "listen_port": 8080 + }, +``` + +Add the following to your docker command: + +``` bash + -p 127.0.0.1:8080:8080 +``` + +A complete sample-command may then look as follows: + +```bash +docker run -d \ + --name freqtrade \ + -v ~/.freqtrade/config.json:/freqtrade/config.json \ + -v ~/.freqtrade/user_data/:/freqtrade/user_data \ + -v ~/.freqtrade/tradesv3.sqlite:/freqtrade/tradesv3.sqlite \ + -p 127.0.0.1:8080:8080 \ + freqtrade --db-url sqlite:///tradesv3.sqlite --strategy MyAwesomeStrategy +``` + +!!! Danger "Security warning" + By using `-p 8080:8080` the API is available to everyone connecting to the server under the correct port, so others may be able to control your bot. + +## 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. + +``` bash +python3 scripts/rest_client.py [optional parameters] +``` + +By default, the script assumes `127.0.0.1` (localhost) and port `8080` to be used, however you can specify a configuration file to override this behaviour. + +### Minimalistic client config + +``` json +{ + "api_server": { + "enabled": true, + "listen_ip_address": "0.0.0.0", + "listen_port": 8080 + } +} +``` + +``` bash +python3 scripts/rest_client.py --config rest_config.json [optional parameters] +``` + +## Available commands + +| Command | Default | Description | +|----------|---------|-------------| +| `start` | | Starts the trader +| `stop` | | Stops the trader +| `stopbuy` | | Stops the trader from opening new trades. Gracefully closes open trades according to their rules. +| `reload_conf` | | Reloads the configuration file +| `status` | | Lists all open trades +| `status table` | | List all open trades in a table format +| `count` | | Displays number of trades used and available +| `profit` | | Display a summary of your profit/loss from close trades and some stats about your performance +| `forcesell ` | | Instantly sells the given trade (Ignoring `minimum_roi`). +| `forcesell all` | | Instantly sells all open trades (Ignoring `minimum_roi`). +| `forcebuy [rate]` | | Instantly buys the given pair. Rate is optional. (`forcebuy_enable` must be set to True) +| `performance` | | Show performance of each finished trade grouped by pair +| `balance` | | Show account balance per currency +| `daily ` | 7 | Shows profit or loss per day, over the last n days +| `whitelist` | | Show the current whitelist +| `blacklist [pair]` | | Show the current blacklist, or adds a pair to the blacklist. +| `edge` | | Show validated pairs by Edge if it is enabled. +| `version` | | Show version + +Possible commands can be listed from the rest-client script using the `help` command. + +``` bash +python3 scripts/rest_client.py help +``` + +``` output +Possible commands: +balance + Get the account balance + :returns: json object + +blacklist + Show the current blacklist + :param add: List of coins to add (example: "BNB/BTC") + :returns: json object + +count + Returns the amount of open trades + :returns: json object + +daily + Returns the amount of open trades + :returns: json object + +edge + Returns information about edge + :returns: json object + +forcebuy + Buy an asset + :param pair: Pair to buy (ETH/BTC) + :param price: Optional - price to buy + :returns: json object of the trade + +forcesell + Force-sell a trade + :param tradeid: Id of the trade (can be received via status command) + :returns: json object + +performance + Returns the performance of the different coins + :returns: json object + +profit + Returns the profit summary + :returns: json object + +reload_conf + Reload configuration + :returns: json object + +start + Start the bot if it's in stopped state. + :returns: json object + +status + Get the status of open trades + :returns: json object + +stop + Stop the bot. Use start to restart + :returns: json object + +stopbuy + Stop buying (but handle sells gracefully). + use reload_conf to reset + :returns: json object + +version + Returns the version of the bot + :returns: json object containing the version + +whitelist + Show the current whitelist + :returns: json object +``` diff --git a/docs/stoploss.md b/docs/stoploss.md index cbe4fd3c4..f5e2f8df6 100644 --- a/docs/stoploss.md +++ b/docs/stoploss.md @@ -1,4 +1,13 @@ -# Stop Loss support +# Stop Loss + +The `stoploss` configuration parameter is loss in percentage that should trigger a sale. +For example, value `-0.10` will cause immediate sell if the profit dips below -10% for a given trade. This parameter is optional. + +Most of the strategy files already include the optimal `stoploss` +value. This parameter is optional. If you use it in the configuration file, it will take over the +`stoploss` value from the strategy file. + +## Stop Loss support At this stage the bot contains the following stoploss support modes: @@ -16,13 +25,12 @@ In case of stoploss on exchange there is another parameter called `stoploss_on_e !!! Note Stoploss on exchange is only supported for Binance as of now. - ## Static Stop Loss This is very simple, basically you define a stop loss of x in your strategy file or alternative in the configuration, which will overwrite the strategy definition. This will basically try to sell your asset, the second the loss exceeds the defined loss. -## Trail Stop Loss +## Trailing Stop Loss The initial value for this stop loss, is defined in your strategy or configuration. Just as you would define your Stop Loss normally. To enable this Feauture all you have to do is to define the configuration element: @@ -63,3 +71,13 @@ The 0.01 would translate to a 1% stop loss, once you hit 1.1% profit. You should also make sure to have this value (`trailing_stop_positive_offset`) lower than your minimal ROI, otherwise minimal ROI will apply first and sell your trade. If `"trailing_only_offset_is_reached": true` then the trailing stoploss is only activated once the offset is reached. Until then, the stoploss remains at the configured`stoploss`. + +## Changing stoploss on open trades + +A stoploss on an open trade can be changed by changing the value in the configuration or strategy and use the `/reload_conf` command (alternatively, completely stopping and restarting the bot also works). + +The new stoploss value will be applied to open trades (and corresponding log-messages will be generated). + +### Limitations + +Stoploss values cannot be changed if `trailing_stop` is enabled and the stoploss has already been adjusted, or if [Edge](edge.md) is enabled (since Edge would recalculate stoploss based on the current market situation). diff --git a/docs/bot-optimization.md b/docs/strategy-customization.md similarity index 87% rename from docs/bot-optimization.md rename to docs/strategy-customization.md index 9e754c213..57c646aed 100644 --- a/docs/bot-optimization.md +++ b/docs/strategy-customization.md @@ -53,6 +53,12 @@ file as reference.** It is therefore best to use vectorized operations (across the whole dataframe, not loops) and avoid index referencing (`df.iloc[-1]`), but instead use `df.shift()` to get to the previous candle. +!!! Warning Using future data + Since backtesting passes the full time interval to the `populate_*()` methods, the strategy author + needs to take care to avoid having the strategy utilize data from the future. + Samples for usage of future data are `dataframe.shift(-1)`, `dataframe.resample("1h")` (this uses the left border of the interval, so moves data from an hour to the start of the hour). + They all use data which is not available during regular operations, so these strategies will perform well during backtesting, but will fail / perform badly in dry-runs. + ### Customize Indicators Buy and sell strategies need indicators. You can add more indicators by extending the list contained in the method `populate_indicators()` from your strategy file. @@ -212,9 +218,12 @@ stoploss = -0.10 ``` This would signify a stoploss of -10%. + +For the full documentation on stoploss features, look at the dedicated [stoploss page](stoploss.md). + If your exchange supports it, it's recommended to also set `"stoploss_on_exchange"` in the order dict, so your stoploss is on the exchange and cannot be missed for network-problems (or other problems). -For more information on order_types please look [here](https://github.com/freqtrade/freqtrade/blob/develop/docs/configuration.md#understand-order_types). +For more information on order_types please look [here](configuration.md#understand-order_types). ### Ticker interval @@ -292,6 +301,18 @@ if self.dp: !!! Warning Warning in hyperopt This option cannot currently be used during hyperopt. +#### Orderbook + +``` python +if self.dp: + if self.dp.runmode in ('live', 'dry_run'): + ob = self.dp.orderbook(metadata['pair'], 1) + dataframe['best_bid'] = ob['bids'][0][0] + dataframe['best_ask'] = ob['asks'][0][0] +``` +!Warning The order book is not part of the historic data which means backtesting and hyperopt will not work if this + method is used. + #### Available Pairs ``` python @@ -300,6 +321,7 @@ if self.dp: print(f"available {pair}, {ticker}") ``` + #### Get data for non-tradeable pairs Data for additional, informative pairs (reference pairs) can be beneficial for some strategies. @@ -345,6 +367,30 @@ if self.wallets: - `get_used(asset)` - currently tied up balance (open orders) - `get_total(asset)` - total available balance - sum of the 2 above +### Print created dataframe + +To inspect the created dataframe, you can issue a print-statement in either `populate_buy_trend()` or `populate_sell_trend()`. +You may also want to print the pair so it's clear what data is currently shown. + +``` python +def populate_buy_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe.loc[ + ( + #>> whatever condition<<< + ), + 'buy'] = 1 + + # Print the Analyzed pair + print(f"result for {metadata['pair']}") + + # Inspect the last 5 rows + print(dataframe.tail()) + + return dataframe +``` + +Printing more than a few rows is also possible (simply use `print(dataframe)` instead of `print(dataframe.tail())`), however not recommended, as that will be very verbose (~500 lines per pair every 5 seconds). + ### Where is the default strategy? The default buy strategy is located in the file @@ -364,7 +410,7 @@ To get additional Ideas for strategies, head over to our [strategy repository](h Feel free to use any of them as inspiration for your own strategies. We're happy to accept Pull Requests containing new Strategies to that repo. -We also got a *strategy-sharing* channel in our [Slack community](https://join.slack.com/t/highfrequencybot/shared_invite/enQtMjQ5NTM0OTYzMzY3LWMxYzE3M2MxNDdjMGM3ZTYwNzFjMGIwZGRjNTc3ZGU3MGE3NzdmZGMwNmU3NDM5ZTNmM2Y3NjRiNzk4NmM4OGE) which is a great place to get and/or share ideas. +We also got a *strategy-sharing* channel in our [Slack community](https://join.slack.com/t/highfrequencybot/shared_invite/enQtNjU5ODcwNjI1MDU3LWEyODBiNzkzNzcyNzU0MWYyYzE5NjIyOTQxMzBmMGUxOTIzM2YyN2Y4NWY1YTEwZDgwYTRmMzE2NmM5ZmY2MTg) which is a great place to get and/or share ideas. ## Next step diff --git a/docs/telegram-usage.md b/docs/telegram-usage.md index 9d6877318..e06d4fdfc 100644 --- a/docs/telegram-usage.md +++ b/docs/telegram-usage.md @@ -1,10 +1,45 @@ # Telegram usage -## Prerequisite +## Setup your Telegram bot -To control your bot with Telegram, you need first to -[set up a Telegram bot](installation.md) -and add your Telegram API keys into your config file. +Below we explain how to create your Telegram Bot, and how to get your +Telegram user id. + +### 1. Create your Telegram bot + +Start a chat with the [Telegram BotFather](https://telegram.me/BotFather) + +Send the message `/newbot`. + +*BotFather response:* + +> Alright, a new bot. How are we going to call it? Please choose a name for your bot. + +Choose the public name of your bot (e.x. `Freqtrade bot`) + +*BotFather response:* + +> Good. Now let's choose a username for your bot. It must end in `bot`. Like this, for example: TetrisBot or tetris_bot. + +Choose the name id of your bot and send it to the BotFather (e.g. "`My_own_freqtrade_bot`") + +*BotFather response:* + +> Done! Congratulations on your new bot. You will find it at `t.me/yourbots_name_bot`. You can now add a description, about section and profile picture for your bot, see /help for a list of commands. By the way, when you've finished creating your cool bot, ping our Bot Support if you want a better username for it. Just make sure the bot is fully operational before you do this. + +> Use this token to access the HTTP API: `22222222:APITOKEN` + +> For a description of the Bot API, see this page: https://core.telegram.org/bots/api Father bot will return you the token (API key) + +Copy the API Token (`22222222:APITOKEN` in the above example) and keep use it for the config parameter `token`. + +Don't forget to start the conversation with your bot, by clicking `/START` button + +### 2. Get your user id + +Talk to the [userinfobot](https://telegram.me/userinfobot) + +Get your "Id", you will use it for the config parameter `chat_id`. ## Telegram commands @@ -116,7 +151,7 @@ Return a summary of your profit/loss and performance. ### /forcebuy -> **BITTREX**: Buying ETH/BTC with limit `0.03400000` (`1.000000 ETH`, `225.290 USD`) +> **BITTREX:** Buying ETH/BTC with limit `0.03400000` (`1.000000 ETH`, `225.290 USD`) Note that for this to work, `forcebuy_enable` needs to be set to true. diff --git a/docs/webhook-config.md b/docs/webhook-config.md index 811b57f9b..112f8a77e 100644 --- a/docs/webhook-config.md +++ b/docs/webhook-config.md @@ -43,6 +43,7 @@ Possible parameters are: * `stake_amount` * `stake_currency` * `fiat_currency` +* `order_type` ### Webhooksell @@ -61,6 +62,7 @@ Possible parameters are: * `stake_currency` * `fiat_currency` * `sell_reason` +* `order_type` ### Webhookstatus diff --git a/freqtrade/__init__.py b/freqtrade/__init__.py index f28809f33..a5ae200d4 100644 --- a/freqtrade/__init__.py +++ b/freqtrade/__init__.py @@ -1,15 +1,15 @@ """ FreqTrade bot """ -__version__ = '0.18.5' +__version__ = '2019.6' -class DependencyException(BaseException): +class DependencyException(Exception): """ - Indicates that a assumed dependency is not met. + Indicates that an assumed dependency is not met. This could happen when there is currently not enough money on the account. """ -class OperationalException(BaseException): +class OperationalException(Exception): """ Requires manual intervention. This happens when an exchange returns an unexpected error during runtime @@ -17,7 +17,7 @@ class OperationalException(BaseException): """ -class InvalidOrderException(BaseException): +class InvalidOrderException(Exception): """ This is returned when the order is not valid. Example: If stoploss on exchange order is hit, then trying to cancel the order @@ -25,7 +25,7 @@ class InvalidOrderException(BaseException): """ -class TemporaryError(BaseException): +class TemporaryError(Exception): """ Temporary network or exchange related error. This could happen when an exchange is congested, unavailable, or the user diff --git a/freqtrade/__main__.py b/freqtrade/__main__.py index 7d271dfd1..97ed9ae67 100644 --- a/freqtrade/__main__.py +++ b/freqtrade/__main__.py @@ -6,10 +6,7 @@ To launch Freqtrade as a module > python -m freqtrade (with Python >= 3.6) """ -import sys - from freqtrade import main if __name__ == '__main__': - main.set_loggers() - main.main(sys.argv[1:]) + main.main() diff --git a/freqtrade/arguments.py b/freqtrade/arguments.py index b0acb4122..1ec32d1f0 100644 --- a/freqtrade/arguments.py +++ b/freqtrade/arguments.py @@ -27,13 +27,14 @@ class Arguments(object): Arguments Class. Manage the arguments received by the cli """ - def __init__(self, args: List[str], description: str) -> None: + def __init__(self, args: Optional[List[str]], description: str) -> None: self.args = args self.parsed_arg: Optional[argparse.Namespace] = None self.parser = argparse.ArgumentParser(description=description) def _load_args(self) -> None: - self.common_args_parser() + self.common_options() + self.main_options() self._build_subcommands() def get_parsed_arg(self) -> argparse.Namespace: @@ -47,7 +48,7 @@ class Arguments(object): return self.parsed_arg - def parse_args(self) -> argparse.Namespace: + def parse_args(self, no_default_config: bool = False) -> argparse.Namespace: """ Parses given arguments and returns an argparse Namespace instance. """ @@ -55,97 +56,140 @@ class Arguments(object): # Workaround issue in argparse with action='append' and default value # (see https://bugs.python.org/issue16399) - if parsed_arg.config is None: + if not no_default_config and parsed_arg.config is None: parsed_arg.config = [constants.DEFAULT_CONFIG] return parsed_arg - def common_args_parser(self) -> None: + def common_options(self) -> None: """ - Parses given common arguments and returns them as a parsed object. + Parses arguments that are common for the main Freqtrade, all subcommands and scripts. """ - self.parser.add_argument( + parser = self.parser + + parser.add_argument( '-v', '--verbose', help='Verbose mode (-vv for more, -vvv to get all messages).', action='count', dest='loglevel', default=0, ) - self.parser.add_argument( + parser.add_argument( '--logfile', - help='Log to the file specified', + help='Log to the file specified.', dest='logfile', - type=str, - metavar='FILE' + metavar='FILE', ) - self.parser.add_argument( + parser.add_argument( '--version', action='version', version=f'%(prog)s {__version__}' ) - self.parser.add_argument( + parser.add_argument( '-c', '--config', - help='Specify configuration file (default: %(default)s). ' - 'Multiple --config options may be used.', + help=f'Specify configuration file (default: `{constants.DEFAULT_CONFIG}`). ' + f'Multiple --config options may be used. ' + f'Can be set to `-` to read config from stdin.', dest='config', action='append', - type=str, metavar='PATH', ) - self.parser.add_argument( + parser.add_argument( '-d', '--datadir', help='Path to backtest data.', dest='datadir', - default=None, - type=str, metavar='PATH', ) - self.parser.add_argument( + + def main_options(self) -> None: + """ + Parses arguments for the main Freqtrade. + """ + parser = self.parser + + parser.add_argument( '-s', '--strategy', - help='Specify strategy class name (default: %(default)s).', + help='Specify strategy class name (default: `%(default)s`).', dest='strategy', default='DefaultStrategy', - type=str, metavar='NAME', ) - self.parser.add_argument( + parser.add_argument( '--strategy-path', help='Specify additional strategy lookup path.', dest='strategy_path', - type=str, metavar='PATH', ) - self.parser.add_argument( + parser.add_argument( '--dynamic-whitelist', - help='Dynamically generate and update whitelist' - ' based on 24h BaseVolume (default: %(const)s).' - ' DEPRECATED.', + help='Dynamically generate and update whitelist ' + 'based on 24h BaseVolume (default: %(const)s). ' + 'DEPRECATED.', dest='dynamic_whitelist', const=constants.DYNAMIC_WHITELIST, type=int, metavar='INT', nargs='?', ) - self.parser.add_argument( + parser.add_argument( '--db-url', - help='Override trades database URL, this is useful if dry_run is enabled' - ' or in custom deployments (default: %(default)s).', + help=f'Override trades database URL, this is useful in custom deployments ' + f'(default: `{constants.DEFAULT_DB_PROD_URL}` for Live Run mode, ' + f'`{constants.DEFAULT_DB_DRYRUN_URL}` for Dry Run).', dest='db_url', - type=str, metavar='PATH', ) - self.parser.add_argument( + parser.add_argument( '--sd-notify', help='Notify systemd service manager.', action='store_true', dest='sd_notify', ) - @staticmethod - def backtesting_options(parser: argparse.ArgumentParser) -> None: + def common_optimize_options(self, subparser: argparse.ArgumentParser = None) -> None: """ - Parses given arguments for Backtesting scripts. + Parses arguments common for Backtesting, Edge and Hyperopt modules. + :param parser: """ + parser = subparser or self.parser + + parser.add_argument( + '-i', '--ticker-interval', + help='Specify ticker interval (`1m`, `5m`, `30m`, `1h`, `1d`).', + dest='ticker_interval', + ) + parser.add_argument( + '--timerange', + help='Specify what timerange of data to use.', + dest='timerange', + ) + parser.add_argument( + '--max_open_trades', + help='Specify max_open_trades to use.', + type=int, + dest='max_open_trades', + ) + parser.add_argument( + '--stake_amount', + help='Specify stake_amount.', + type=float, + dest='stake_amount', + ) + parser.add_argument( + '-r', '--refresh-pairs-cached', + help='Refresh the pairs files in tests/testdata with the latest data from the ' + 'exchange. Use it if you want to run your optimization commands with ' + 'up-to-date data.', + action='store_true', + dest='refresh_pairs', + ) + + def backtesting_options(self, subparser: argparse.ArgumentParser = None) -> None: + """ + Parses given arguments for Backtesting module. + """ + parser = subparser or self.parser + parser.add_argument( '--eps', '--enable-position-stacking', help='Allow buying the same pair multiple times (position stacking).', @@ -167,113 +211,57 @@ class Arguments(object): action='store_true', dest='live', ) - parser.add_argument( - '-r', '--refresh-pairs-cached', - help='Refresh the pairs files in tests/testdata with the latest data from the ' - 'exchange. Use it if you want to run your backtesting with up-to-date data.', - action='store_true', - dest='refresh_pairs', - ) parser.add_argument( '--strategy-list', - help='Provide a commaseparated list of strategies to backtest ' + help='Provide a comma-separated list of strategies to backtest. ' 'Please note that ticker-interval needs to be set either in config ' - 'or via command line. When using this together with --export trades, ' + 'or via command line. When using this together with `--export trades`, ' 'the strategy-name is injected into the filename ' - '(so backtest-data.json becomes backtest-data-DefaultStrategy.json', + '(so `backtest-data.json` becomes `backtest-data-DefaultStrategy.json`', nargs='+', dest='strategy_list', ) parser.add_argument( '--export', help='Export backtest results, argument are: trades. ' - 'Example --export=trades', - type=str, - default=None, + 'Example: `--export=trades`', dest='export', ) parser.add_argument( '--export-filename', - help='Save backtest results to this filename \ - requires --export to be set as well\ - Example --export-filename=user_data/backtest_data/backtest_today.json\ - (default: %(default)s)', - type=str, + help='Save backtest results to the file with this filename (default: `%(default)s`). ' + 'Requires `--export` to be set as well. ' + 'Example: `--export-filename=user_data/backtest_data/backtest_today.json`', default=os.path.join('user_data', 'backtest_data', 'backtest-result.json'), dest='exportfilename', metavar='PATH', ) - @staticmethod - def edge_options(parser: argparse.ArgumentParser) -> None: + def edge_options(self, subparser: argparse.ArgumentParser = None) -> None: """ - Parses given arguments for Backtesting scripts. + Parses given arguments for Edge module. """ - parser.add_argument( - '-r', '--refresh-pairs-cached', - help='Refresh the pairs files in tests/testdata with the latest data from the ' - 'exchange. Use it if you want to run your edge with up-to-date data.', - action='store_true', - dest='refresh_pairs', - ) + parser = subparser or self.parser + parser.add_argument( '--stoplosses', - help='Defines a range of stoploss against which edge will assess the strategy ' - 'the format is "min,max,step" (without any space).' - 'example: --stoplosses=-0.01,-0.1,-0.001', - type=str, + help='Defines a range of stoploss values against which edge will assess the strategy. ' + 'The format is "min,max,step" (without any space). ' + 'Example: `--stoplosses=-0.01,-0.1,-0.001`', dest='stoploss_range', ) - @staticmethod - def optimizer_shared_options(parser: argparse.ArgumentParser) -> None: + def hyperopt_options(self, subparser: argparse.ArgumentParser = None) -> None: """ - Parses given common arguments for Backtesting and Hyperopt scripts. - :param parser: - :return: + Parses given arguments for Hyperopt module. """ - parser.add_argument( - '-i', '--ticker-interval', - help='Specify ticker interval (1m, 5m, 30m, 1h, 1d).', - dest='ticker_interval', - type=str, - ) + parser = subparser or self.parser - parser.add_argument( - '--timerange', - help='Specify what timerange of data to use.', - default=None, - type=str, - dest='timerange', - ) - - parser.add_argument( - '--max_open_trades', - help='Specify max_open_trades to use.', - default=None, - type=int, - dest='max_open_trades', - ) - - parser.add_argument( - '--stake_amount', - help='Specify stake_amount.', - default=None, - type=float, - dest='stake_amount', - ) - - @staticmethod - def hyperopt_options(parser: argparse.ArgumentParser) -> None: - """ - Parses given arguments for Hyperopt scripts. - """ parser.add_argument( '--customhyperopt', - help='Specify hyperopt class name (default: %(default)s).', + help='Specify hyperopt class name (default: `%(default)s`).', dest='hyperopt', default=constants.DEFAULT_HYPEROPT, - type=str, metavar='NAME', ) parser.add_argument( @@ -283,7 +271,6 @@ class Arguments(object): dest='position_stacking', default=False ) - parser.add_argument( '--dmmp', '--disable-max-market-positions', help='Disable applying `max_open_trades` during backtest ' @@ -302,41 +289,97 @@ class Arguments(object): ) parser.add_argument( '-s', '--spaces', - help='Specify which parameters to hyperopt. Space separate list. \ - Default: %(default)s.', + help='Specify which parameters to hyperopt. Space-separated list. ' + 'Default: `%(default)s`.', choices=['all', 'buy', 'sell', 'roi', 'stoploss'], default='all', nargs='+', dest='spaces', ) + parser.add_argument( + '--print-all', + help='Print all results, not only the best ones.', + action='store_true', + dest='print_all', + default=False + ) + parser.add_argument( + '-j', '--job-workers', + help='The number of concurrently running jobs for hyperoptimization ' + '(hyperopt worker processes). ' + 'If -1 (default), all CPUs are used, for -2, all CPUs but one are used, etc. ' + 'If 1 is given, no parallel computing code is used at all.', + dest='hyperopt_jobs', + default=-1, + type=int, + metavar='JOBS', + ) + parser.add_argument( + '--random-state', + help='Set random state to some positive integer for reproducible hyperopt results.', + dest='hyperopt_random_state', + type=Arguments.check_int_positive, + metavar='INT', + ) + parser.add_argument( + '--min-trades', + help="Set minimal desired number of trades for evaluations in the hyperopt " + "optimization path (default: 1).", + dest='hyperopt_min_trades', + default=1, + type=Arguments.check_int_positive, + metavar='INT', + ) + + def list_exchanges_options(self, subparser: argparse.ArgumentParser = None) -> None: + """ + Parses given arguments for the list-exchanges command. + """ + parser = subparser or self.parser + + parser.add_argument( + '-1', '--one-column', + help='Print exchanges in one column.', + action='store_true', + dest='print_one_column', + ) def _build_subcommands(self) -> None: """ - Builds and attaches all subcommands + Builds and attaches all subcommands. :return: None """ - from freqtrade.optimize import backtesting, hyperopt, edge_cli + from freqtrade.optimize import start_backtesting, start_hyperopt, start_edge + from freqtrade.utils import start_list_exchanges subparsers = self.parser.add_subparsers(dest='subparser') # Add backtesting subcommand backtesting_cmd = subparsers.add_parser('backtesting', help='Backtesting module.') - backtesting_cmd.set_defaults(func=backtesting.start) - self.optimizer_shared_options(backtesting_cmd) + backtesting_cmd.set_defaults(func=start_backtesting) + self.common_optimize_options(backtesting_cmd) self.backtesting_options(backtesting_cmd) # Add edge subcommand edge_cmd = subparsers.add_parser('edge', help='Edge module.') - edge_cmd.set_defaults(func=edge_cli.start) - self.optimizer_shared_options(edge_cmd) + edge_cmd.set_defaults(func=start_edge) + self.common_optimize_options(edge_cmd) self.edge_options(edge_cmd) # Add hyperopt subcommand hyperopt_cmd = subparsers.add_parser('hyperopt', help='Hyperopt module.') - hyperopt_cmd.set_defaults(func=hyperopt.start) - self.optimizer_shared_options(hyperopt_cmd) + hyperopt_cmd.set_defaults(func=start_hyperopt) + self.common_optimize_options(hyperopt_cmd) self.hyperopt_options(hyperopt_cmd) + # Add list-exchanges subcommand + list_exchanges_cmd = subparsers.add_parser( + 'list-exchanges', + help='Print available exchanges.' + ) + list_exchanges_cmd.set_defaults(func=start_list_exchanges) + self.list_exchanges_options(list_exchanges_cmd) + @staticmethod def parse_timerange(text: Optional[str]) -> TimeRange: """ @@ -379,78 +422,105 @@ class Arguments(object): return TimeRange(stype[0], stype[1], start, stop) raise Exception('Incorrect syntax for timerange "%s"' % text) - def scripts_options(self) -> None: + @staticmethod + def check_int_positive(value: str) -> int: + try: + uint = int(value) + if uint <= 0: + raise ValueError + except ValueError: + raise argparse.ArgumentTypeError( + f"{value} is invalid for this parameter, should be a positive integer value" + ) + return uint + + def common_scripts_options(self, subparser: argparse.ArgumentParser = None) -> None: """ - Parses given arguments for scripts. + Parses arguments common for scripts. """ - self.parser.add_argument( + parser = subparser or self.parser + + parser.add_argument( '-p', '--pairs', - help='Show profits for only this pairs. Pairs are comma-separated.', + help='Show profits for only these pairs. Pairs are comma-separated.', dest='pairs', - default=None ) - def testdata_dl_options(self) -> None: + def download_data_options(self) -> None: """ - Parses given arguments for testdata download + Parses given arguments for testdata download script """ - self.parser.add_argument( + parser = self.parser + + parser.add_argument( '--pairs-file', help='File containing a list of pairs to download.', dest='pairs_file', - default=None, - metavar='PATH', + metavar='FILE', ) - - self.parser.add_argument( - '--export', - help='Export files to given dir.', - dest='export', - default=None, - metavar='PATH', - ) - - self.parser.add_argument( - '-c', '--config', - help='Specify configuration file (default: %(default)s). ' - 'Multiple --config options may be used.', - dest='config', - action='append', - type=str, - metavar='PATH', - ) - - self.parser.add_argument( + parser.add_argument( '--days', help='Download data for given number of days.', dest='days', - type=int, + type=Arguments.check_int_positive, metavar='INT', - default=None ) - - self.parser.add_argument( + parser.add_argument( '--exchange', - help='Exchange name (default: %(default)s). Only valid if no config is provided.', + help=f'Exchange name (default: `{constants.DEFAULT_EXCHANGE}`). ' + f'Only valid if no config is provided.', dest='exchange', - type=str, - default='bittrex' ) - - self.parser.add_argument( + parser.add_argument( '-t', '--timeframes', - help='Specify which tickers to download. Space separated list. \ - Default: %(default)s.', + help=f'Specify which tickers to download. Space-separated list. ' + f'Default: `{constants.DEFAULT_DOWNLOAD_TICKER_INTERVALS}`.', choices=['1m', '3m', '5m', '15m', '30m', '1h', '2h', '4h', '6h', '8h', '12h', '1d', '3d', '1w'], - default=['1m', '5m'], nargs='+', dest='timeframes', ) - - self.parser.add_argument( + parser.add_argument( '--erase', help='Clean all existing data for the selected exchange/pairs/timeframes.', dest='erase', action='store_true' ) + + def plot_dataframe_options(self) -> None: + """ + Parses given arguments for plot dataframe script + """ + parser = self.parser + + parser.add_argument( + '--indicators1', + help='Set indicators from your strategy you want in the first row of the graph. ' + 'Comma-separated list. Example: `ema3,ema5`. Default: `%(default)s`.', + default='sma,ema3,ema5', + dest='indicators1', + ) + + parser.add_argument( + '--indicators2', + help='Set indicators from your strategy you want in the third row of the graph. ' + 'Comma-separated list. Example: `fastd,fastk`. Default: `%(default)s`.', + default='macd,macdsignal', + dest='indicators2', + ) + parser.add_argument( + '--plot-limit', + help='Specify tick limit for plotting. Notice: too high values cause huge files. ' + 'Default: %(default)s.', + dest='plot_limit', + default=750, + type=int, + ) + parser.add_argument( + '--trade-source', + help='Specify the source for trades (Can be DB or file (backtest file)) ' + 'Default: %(default)s', + dest='trade_source', + default="file", + choices=["DB", "file"] + ) diff --git a/freqtrade/configuration.py b/freqtrade/configuration.py index 7f0f3c34a..d74b712c3 100644 --- a/freqtrade/configuration.py +++ b/freqtrade/configuration.py @@ -7,13 +7,14 @@ import os import sys from argparse import Namespace from logging.handlers import RotatingFileHandler -from typing import Any, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional -import ccxt -from jsonschema import Draft4Validator, validate +from jsonschema import Draft4Validator, validators from jsonschema.exceptions import ValidationError, best_match from freqtrade import OperationalException, constants +from freqtrade.exchange import (is_exchange_bad, is_exchange_available, + is_exchange_officially_supported, available_exchanges) from freqtrade.misc import deep_merge_dicts from freqtrade.state import RunMode @@ -33,6 +34,31 @@ def set_loggers(log_level: int = 0) -> None: logging.getLogger('telegram').setLevel(logging.INFO) +def _extend_validator(validator_class): + """ + Extended validator for the Freqtrade configuration JSON Schema. + Currently it only handles defaults for subschemas. + """ + validate_properties = validator_class.VALIDATORS['properties'] + + def set_defaults(validator, properties, instance, schema): + for prop, subschema in properties.items(): + if 'default' in subschema: + instance.setdefault(prop, subschema['default']) + + for error in validate_properties( + validator, properties, instance, schema, + ): + yield error + + return validators.extend( + validator_class, {'properties': set_defaults} + ) + + +FreqtradeValidator = _extend_validator(Draft4Validator) + + class Configuration(object): """ Class to read and init the bot configuration @@ -53,6 +79,7 @@ class Configuration(object): # Now expecting a list of config filenames here, not a string for path in self.args.config: logger.info('Using config: %s ...', path) + # Merge config options, overwriting old values config = deep_merge_dicts(self._load_config_file(path), config) @@ -73,14 +100,11 @@ class Configuration(object): # Load Common configuration config = self._load_common_config(config) - # Load Backtesting - config = self._load_backtesting_config(config) + # Load Optimize configurations + config = self._load_optimize_config(config) - # Load Edge - config = self._load_edge_config(config) - - # Load Hyperopt - config = self._load_hyperopt_config(config) + # Add plotting options if available + config = self._load_plot_config(config) # Set runmode if not self.runmode: @@ -98,7 +122,8 @@ class Configuration(object): :return: configuration as dictionary """ try: - with open(path) as file: + # Read config from stdin if requested in the options + with open(path) if path != '-' else sys.stdin as file: conf = json.load(file) except FileNotFoundError: raise OperationalException( @@ -107,12 +132,11 @@ class Configuration(object): return conf - def _load_common_config(self, config: Dict[str, Any]) -> Dict[str, Any]: + def _load_logging_config(self, config: Dict[str, Any]) -> None: """ - Extract information for sys.argv and load common configuration - :return: configuration as dictionary + Extract information for sys.argv and load logging configuration: + the --loglevel, --logfile options """ - # Log level if 'loglevel' in self.args and self.args.loglevel: config.update({'verbosity': self.args.loglevel}) @@ -138,6 +162,13 @@ class Configuration(object): set_loggers(config['verbosity']) logger.info('Verbosity set to %s', config['verbosity']) + def _load_common_config(self, config: Dict[str, Any]) -> Dict[str, Any]: + """ + Extract information for sys.argv and load common configuration + :return: configuration as dictionary + """ + self._load_logging_config(config) + # Support for sd_notify if self.args.sd_notify: config['internals'].update({'sd_notify': True}) @@ -194,30 +225,53 @@ class Configuration(object): logger.info(f'Created data directory: {datadir}') return datadir - def _load_backtesting_config(self, config: Dict[str, Any]) -> Dict[str, Any]: # noqa: C901 + def _args_to_config(self, config: Dict[str, Any], argname: str, + logstring: str, logfun: Optional[Callable] = None) -> None: """ - Extract information for sys.argv and load Backtesting configuration + :param config: Configuration dictionary + :param argname: Argumentname in self.args - will be copied to config dict. + :param logstring: Logging String + :param logfun: logfun is applied to the configuration entry before passing + that entry to the log string using .format(). + sample: logfun=len (prints the length of the found + configuration instead of the content) + """ + if argname in self.args and getattr(self.args, argname): + + config.update({argname: getattr(self.args, argname)}) + if logfun: + logger.info(logstring.format(logfun(config[argname]))) + else: + logger.info(logstring.format(config[argname])) + + def _load_datadir_config(self, config: Dict[str, Any]) -> None: + """ + Extract information for sys.argv and load datadir configuration: + the --datadir option + """ + if 'datadir' in self.args and self.args.datadir: + config.update({'datadir': self._create_datadir(config, self.args.datadir)}) + else: + config.update({'datadir': self._create_datadir(config, None)}) + logger.info('Using data folder: %s ...', config.get('datadir')) + + def _load_optimize_config(self, config: Dict[str, Any]) -> Dict[str, Any]: + """ + Extract information for sys.argv and load Optimize configuration :return: configuration as dictionary """ - # If -i/--ticker-interval is used we override the configuration parameter - # (that will override the strategy configuration) - if 'ticker_interval' in self.args and self.args.ticker_interval: - config.update({'ticker_interval': self.args.ticker_interval}) - logger.info('Parameter -i/--ticker-interval detected ...') - logger.info('Using ticker_interval: %s ...', config.get('ticker_interval')) + # This will override the strategy configuration + self._args_to_config(config, argname='ticker_interval', + logstring='Parameter -i/--ticker-interval detected ... ' + 'Using ticker_interval: {} ...') - # If -l/--live is used we add it to the configuration - if 'live' in self.args and self.args.live: - config.update({'live': True}) - logger.info('Parameter -l/--live detected ...') + self._args_to_config(config, argname='live', + logstring='Parameter -l/--live detected ...') - # If --enable-position-stacking is used we add it to the configuration - if 'position_stacking' in self.args and self.args.position_stacking: - config.update({'position_stacking': True}) - logger.info('Parameter --enable-position-stacking detected ...') + self._args_to_config(config, argname='position_stacking', + logstring='Parameter --enable-position-stacking detected ...') - # If --disable-max-market-positions or --max_open_trades is used we update configuration if 'use_max_market_positions' in self.args and not self.args.use_max_market_positions: config.update({'use_max_market_positions': False}) logger.info('Parameter --disable-max-market-positions detected ...') @@ -229,61 +283,31 @@ class Configuration(object): else: logger.info('Using max_open_trades: %s ...', config.get('max_open_trades')) - # If --stake_amount is used we update configuration - if 'stake_amount' in self.args and self.args.stake_amount: - config.update({'stake_amount': self.args.stake_amount}) - logger.info('Parameter --stake_amount detected, overriding stake_amount to: %s ...', - config.get('stake_amount')) + self._args_to_config(config, argname='stake_amount', + logstring='Parameter --stake_amount detected, ' + 'overriding stake_amount to: {} ...') - # If --timerange is used we add it to the configuration - if 'timerange' in self.args and self.args.timerange: - config.update({'timerange': self.args.timerange}) - logger.info('Parameter --timerange detected: %s ...', self.args.timerange) + self._args_to_config(config, argname='timerange', + logstring='Parameter --timerange detected: {} ...') - # If --datadir is used we add it to the configuration - if 'datadir' in self.args and self.args.datadir: - config.update({'datadir': self._create_datadir(config, self.args.datadir)}) - else: - config.update({'datadir': self._create_datadir(config, None)}) - logger.info('Using data folder: %s ...', config.get('datadir')) + self._load_datadir_config(config) - # If -r/--refresh-pairs-cached is used we add it to the configuration - if 'refresh_pairs' in self.args and self.args.refresh_pairs: - config.update({'refresh_pairs': True}) - logger.info('Parameter -r/--refresh-pairs-cached detected ...') + self._args_to_config(config, argname='refresh_pairs', + logstring='Parameter -r/--refresh-pairs-cached detected ...') - if 'strategy_list' in self.args and self.args.strategy_list: - config.update({'strategy_list': self.args.strategy_list}) - logger.info('Using strategy list of %s Strategies', len(self.args.strategy_list)) + self._args_to_config(config, argname='strategy_list', + logstring='Using strategy list of {} Strategies', logfun=len) - if 'ticker_interval' in self.args and self.args.ticker_interval: - config.update({'ticker_interval': self.args.ticker_interval}) - logger.info('Overriding ticker interval with Command line argument') + self._args_to_config(config, argname='ticker_interval', + logstring='Overriding ticker interval with Command line argument') - # If --export is used we add it to the configuration - if 'export' in self.args and self.args.export: - config.update({'export': self.args.export}) - logger.info('Parameter --export detected: %s ...', self.args.export) + self._args_to_config(config, argname='export', + logstring='Parameter --export detected: {} ...') - # If --export-filename is used we add it to the configuration - if 'export' in config and 'exportfilename' in self.args and self.args.exportfilename: - config.update({'exportfilename': self.args.exportfilename}) - logger.info('Storing backtest results to %s ...', self.args.exportfilename) + self._args_to_config(config, argname='exportfilename', + logstring='Storing backtest results to {} ...') - return config - - def _load_edge_config(self, config: Dict[str, Any]) -> Dict[str, Any]: - """ - Extract information for sys.argv and load Edge configuration - :return: configuration as dictionary - """ - - # If --timerange is used we add it to the configuration - if 'timerange' in self.args and self.args.timerange: - config.update({'timerange': self.args.timerange}) - logger.info('Parameter --timerange detected: %s ...', self.args.timerange) - - # If --timerange is used we add it to the configuration + # Edge section: if 'stoploss_range' in self.args and self.args.stoploss_range: txt_range = eval(self.args.stoploss_range) config['edge'].update({'stoploss_range_min': txt_range[0]}) @@ -291,34 +315,51 @@ class Configuration(object): config['edge'].update({'stoploss_range_step': txt_range[2]}) logger.info('Parameter --stoplosses detected: %s ...', self.args.stoploss_range) - # If -r/--refresh-pairs-cached is used we add it to the configuration - if 'refresh_pairs' in self.args and self.args.refresh_pairs: - config.update({'refresh_pairs': True}) - logger.info('Parameter -r/--refresh-pairs-cached detected ...') + # Hyperopt section + self._args_to_config(config, argname='hyperopt', + logstring='Using Hyperopt file {}') + + self._args_to_config(config, argname='epochs', + logstring='Parameter --epochs detected ... ' + 'Will run Hyperopt with for {} epochs ...' + ) + + self._args_to_config(config, argname='spaces', + logstring='Parameter -s/--spaces detected: {}') + + self._args_to_config(config, argname='print_all', + logstring='Parameter --print-all detected ...') + + self._args_to_config(config, argname='hyperopt_jobs', + logstring='Parameter -j/--job-workers detected: {}') + + self._args_to_config(config, argname='hyperopt_random_state', + logstring='Parameter --random-state detected: {}') + + self._args_to_config(config, argname='hyperopt_min_trades', + logstring='Parameter --min-trades detected: {}') return config - def _load_hyperopt_config(self, config: Dict[str, Any]) -> Dict[str, Any]: + def _load_plot_config(self, config: Dict[str, Any]) -> Dict[str, Any]: """ - Extract information for sys.argv and load Hyperopt configuration + Extract information for sys.argv Plotting configuration :return: configuration as dictionary """ - if "hyperopt" in self.args: - # Add the hyperopt file to use - config.update({'hyperopt': self.args.hyperopt}) + self._args_to_config(config, argname='pairs', + logstring='Using pairs {}') - # If --epochs is used we add it to the configuration - if 'epochs' in self.args and self.args.epochs: - config.update({'epochs': self.args.epochs}) - logger.info('Parameter --epochs detected ...') - logger.info('Will run Hyperopt with for %s epochs ...', config.get('epochs')) + self._args_to_config(config, argname='indicators1', + logstring='Using indicators1: {}') - # If --spaces is used we add it to the configuration - if 'spaces' in self.args and self.args.spaces: - config.update({'spaces': self.args.spaces}) - logger.info('Parameter -s/--spaces detected: %s', config.get('spaces')) + self._args_to_config(config, argname='indicators2', + logstring='Using indicators2: {}') + self._args_to_config(config, argname='plot_limit', + logstring='Limiting plot to: {}') + self._args_to_config(config, argname='trade_source', + logstring='Using trades from: {}') return config def _validate_config_schema(self, conf: Dict[str, Any]) -> Dict[str, Any]: @@ -328,7 +369,7 @@ class Configuration(object): :return: Returns the config if valid, otherwise throw an exception """ try: - validate(conf, constants.CONF_SCHEMA, Draft4Validator) + FreqtradeValidator(constants.CONF_SCHEMA).validate(conf) return conf except ValidationError as exception: logger.critical( @@ -378,21 +419,40 @@ class Configuration(object): return self.config - def check_exchange(self, config: Dict[str, Any]) -> bool: + def check_exchange(self, config: Dict[str, Any], check_for_bad: bool = True) -> bool: """ Check if the exchange name in the config file is supported by Freqtrade - :return: True or raised an exception if the exchange if not supported + :param check_for_bad: if True, check the exchange against the list of known 'bad' + exchanges + :return: False if exchange is 'bad', i.e. is known to work with the bot with + critical issues or does not work at all, crashes, etc. True otherwise. + raises an exception if the exchange if not supported by ccxt + and thus is not known for the Freqtrade at all. """ + logger.info("Checking exchange...") + exchange = config.get('exchange', {}).get('name').lower() - if exchange not in ccxt.exchanges: - - exception_msg = f'Exchange "{exchange}" not supported.\n' \ - f'The following exchanges are supported: {", ".join(ccxt.exchanges)}' - - logger.critical(exception_msg) + if not is_exchange_available(exchange): raise OperationalException( - exception_msg + f'Exchange "{exchange}" is not supported by ccxt ' + f'and therefore not available for the bot.\n' + f'The following exchanges are supported by ccxt: ' + f'{", ".join(available_exchanges())}' ) - logger.debug('Exchange "%s" supported', exchange) + if check_for_bad and is_exchange_bad(exchange): + logger.warning(f'Exchange "{exchange}" is known to not work with the bot yet. ' + f'Use it only for development and testing purposes.') + return False + + if is_exchange_officially_supported(exchange): + logger.info(f'Exchange "{exchange}" is officially supported ' + f'by the Freqtrade development team.') + else: + logger.warning(f'Exchange "{exchange}" is supported by ccxt ' + f'and therefore available for the bot but not officially supported ' + f'by the Freqtrade development team. ' + f'It may work flawlessly (please report back) or have serious issues. ' + f'Use it at your own discretion.') + return True diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 5243eeb4a..7a487fcc7 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -4,6 +4,7 @@ bot constants """ DEFAULT_CONFIG = 'config.json' +DEFAULT_EXCHANGE = 'bittrex' DYNAMIC_WHITELIST = 20 # pairs PROCESS_THROTTLE_SECS = 5 # sec DEFAULT_TICKER_INTERVAL = 5 # min @@ -21,6 +22,7 @@ ORDERTYPE_POSSIBILITIES = ['limit', 'market'] ORDERTIF_POSSIBILITIES = ['gtc', 'fok', 'ioc'] AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList'] DRY_RUN_WALLET = 999.9 +DEFAULT_DOWNLOAD_TICKER_INTERVALS = '1m 5m' TICKER_INTERVALS = [ '1m', '3m', '5m', '15m', '30m', @@ -156,6 +158,21 @@ CONF_SCHEMA = { 'webhookstatus': {'type': 'object'}, }, }, + 'api_server': { + 'type': 'object', + 'properties': { + 'enabled': {'type': 'boolean'}, + 'listen_ip_address': {'format': 'ipv4'}, + 'listen_port': { + 'type': 'integer', + "minimum": 1024, + "maximum": 65535 + }, + 'username': {'type': 'string'}, + 'password': {'type': 'string'}, + }, + 'required': ['enabled', 'listen_ip_address', 'listen_port', 'username', 'password'] + }, 'db_url': {'type': 'string'}, 'initial_state': {'type': 'string', 'enum': ['running', 'stopped']}, 'forcebuy_enable': {'type': 'boolean'}, @@ -173,10 +190,10 @@ CONF_SCHEMA = { 'type': 'object', 'properties': { 'name': {'type': 'string'}, - 'sandbox': {'type': 'boolean'}, - 'key': {'type': 'string'}, - 'secret': {'type': 'string'}, - 'password': {'type': 'string'}, + 'sandbox': {'type': 'boolean', 'default': False}, + 'key': {'type': 'string', 'default': ''}, + 'secret': {'type': 'string', 'default': ''}, + 'password': {'type': 'string', 'default': ''}, 'uid': {'type': 'string'}, 'pair_whitelist': { 'type': 'array', @@ -199,7 +216,7 @@ CONF_SCHEMA = { 'ccxt_config': {'type': 'object'}, 'ccxt_async_config': {'type': 'object'} }, - 'required': ['name', 'key', 'secret', 'pair_whitelist'] + 'required': ['name', 'pair_whitelist'] }, 'edge': { 'type': 'object', diff --git a/freqtrade/data/btanalysis.py b/freqtrade/data/btanalysis.py index 6fce4361b..5a0dee042 100644 --- a/freqtrade/data/btanalysis.py +++ b/freqtrade/data/btanalysis.py @@ -1,12 +1,18 @@ """ Helpers when analyzing backtest data """ +import logging from pathlib import Path import numpy as np import pandas as pd +import pytz +from freqtrade import persistence from freqtrade.misc import json_load +from freqtrade.persistence import Trade + +logger = logging.getLogger(__name__) # must align with columns in backtest.py BT_DATA_COLUMNS = ["pair", "profitperc", "open_time", "close_time", "index", "duration", @@ -17,7 +23,7 @@ def load_backtest_data(filename) -> pd.DataFrame: """ Load backtest data file. :param filename: pathlib.Path object, or string pointing to the file. - :return a dataframe with the analysis results + :return: a dataframe with the analysis results """ if isinstance(filename, str): filename = Path(filename) @@ -65,3 +71,41 @@ def evaluate_result_multi(results: pd.DataFrame, freq: str, max_open_trades: int df2 = df2.set_index('date') df_final = df2.resample(freq)[['pair']].count() return df_final[df_final['pair'] > max_open_trades] + + +def load_trades_from_db(db_url: str) -> pd.DataFrame: + """ + Load trades from a DB (using dburl) + :param db_url: Sqlite url (default format sqlite:///tradesv3.dry-run.sqlite) + :return: Dataframe containing Trades + """ + trades: pd.DataFrame = pd.DataFrame([], columns=BT_DATA_COLUMNS) + persistence.init(db_url, clean_open_orders=False) + columns = ["pair", "profit", "open_time", "close_time", + "open_rate", "close_rate", "duration", "sell_reason", + "max_rate", "min_rate"] + + trades = pd.DataFrame([(t.pair, t.calc_profit(), + t.open_date.replace(tzinfo=pytz.UTC), + t.close_date.replace(tzinfo=pytz.UTC) if t.close_date else None, + t.open_rate, t.close_rate, + t.close_date.timestamp() - t.open_date.timestamp() + if t.close_date else None, + t.sell_reason, + t.max_rate, + t.min_rate, + ) + for t in Trade.query.all()], + columns=columns) + + return trades + + +def extract_trades_of_period(dataframe: pd.DataFrame, trades: pd.DataFrame) -> pd.DataFrame: + """ + Compare trades and backtested pair DataFrames to get trades performed on backtested period + :return: the DataFrame of a trades of period + """ + trades = trades.loc[(trades['open_time'] >= dataframe.iloc[0]['date']) & + (trades['close_time'] <= dataframe.iloc[-1]['date'])] + return trades diff --git a/freqtrade/data/converter.py b/freqtrade/data/converter.py index 28749293b..b530b3bce 100644 --- a/freqtrade/data/converter.py +++ b/freqtrade/data/converter.py @@ -2,22 +2,25 @@ Functions to convert data from one format to another """ import logging + import pandas as pd from pandas import DataFrame, to_datetime -from freqtrade.misc import timeframe_to_minutes logger = logging.getLogger(__name__) -def parse_ticker_dataframe(ticker: list, ticker_interval: str, - fill_missing: bool = True) -> DataFrame: +def parse_ticker_dataframe(ticker: list, ticker_interval: str, pair: str, *, + fill_missing: bool = True, + drop_incomplete: bool = True) -> DataFrame: """ Converts a ticker-list (format ccxt.fetch_ohlcv) to a Dataframe :param ticker: ticker list, as returned by exchange.async_get_candle_history :param ticker_interval: ticker_interval (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 """ logger.debug("Parsing tickerlist to dataframe") @@ -43,21 +46,25 @@ def parse_ticker_dataframe(ticker: list, ticker_interval: str, 'close': 'last', 'volume': 'max', }) - frame.drop(frame.tail(1).index, inplace=True) # eliminate partial candle - logger.debug('Dropping last candle') + # eliminate partial candle + if drop_incomplete: + frame.drop(frame.tail(1).index, inplace=True) + logger.debug('Dropping last candle') if fill_missing: - return ohlcv_fill_up_missing_data(frame, ticker_interval) + return ohlcv_fill_up_missing_data(frame, ticker_interval, pair) else: return frame -def ohlcv_fill_up_missing_data(dataframe: DataFrame, ticker_interval: str) -> DataFrame: +def ohlcv_fill_up_missing_data(dataframe: DataFrame, ticker_interval: str, pair: str) -> DataFrame: """ Fills up missing data with 0 volume rows, using the previous close as price for "open", "high" "low" and "close", volume is set to 0 """ + from freqtrade.exchange import timeframe_to_minutes + ohlc_dict = { 'open': 'first', 'high': 'max', @@ -78,7 +85,10 @@ def ohlcv_fill_up_missing_data(dataframe: DataFrame, ticker_interval: str) -> Da 'low': df['close'], }) df.reset_index(inplace=True) - logger.debug(f"Missing data fillup: before: {len(dataframe)} - after: {len(df)}") + len_before = len(dataframe) + len_after = len(df) + if len_before != len_after: + logger.info(f"Missing data fillup for {pair}: before: {len_before} - after: {len_after}") return df diff --git a/freqtrade/data/dataprovider.py b/freqtrade/data/dataprovider.py index df4accf93..2852cbcb0 100644 --- a/freqtrade/data/dataprovider.py +++ b/freqtrade/data/dataprovider.py @@ -85,8 +85,7 @@ class DataProvider(object): """ return latest orderbook data """ - # TODO: Implement me - pass + return self._exchange.get_order_book(pair, max) @property def runmode(self) -> RunMode: diff --git a/freqtrade/data/history.py b/freqtrade/data/history.py index 594c85b5f..2a0d9b15e 100644 --- a/freqtrade/data/history.py +++ b/freqtrade/data/history.py @@ -1,23 +1,24 @@ """ Handle historic data (ohlcv). -includes: + +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 datetime import datetime from pathlib import Path -from typing import Optional, List, Dict, Tuple, Any +from typing import Any, Dict, List, Optional, Tuple import arrow from pandas import DataFrame -from freqtrade import misc, OperationalException +from freqtrade import OperationalException, misc from freqtrade.arguments import TimeRange from freqtrade.data.converter import parse_ticker_dataframe -from freqtrade.exchange import Exchange -from freqtrade.misc import timeframe_to_minutes - +from freqtrade.exchange import Exchange, timeframe_to_minutes logger = logging.getLogger(__name__) @@ -62,14 +63,10 @@ def load_tickerdata_file( timerange: Optional[TimeRange] = None) -> Optional[list]: """ Load a pair from file, either .json.gz or .json - :return tickerlist or None if unsuccesful + :return: tickerlist or None if unsuccesful """ - path = make_testdata_path(datadir) - pair_s = pair.replace('/', '_') - file = path.joinpath(f'{pair_s}-{ticker_interval}.json') - - pairdata = misc.file_load_json(file) - + filename = pair_data_filename(datadir, pair, ticker_interval) + pairdata = misc.file_load_json(filename) if not pairdata: return None @@ -84,20 +81,25 @@ def load_pair_history(pair: str, timerange: TimeRange = TimeRange(None, None, 0, 0), refresh_pairs: bool = False, exchange: Optional[Exchange] = None, - fill_up_missing: bool = True + fill_up_missing: bool = True, + drop_incomplete: bool = True ) -> DataFrame: """ Loads cached ticker history for the given pair. + :param pair: Pair to load data for + :param ticker_interval: Ticker-interval (e.g. "5m") + :param datadir: Path to the data storage location. + :param timerange: Limit data to be loaded to this timerange + :param refresh_pairs: Refresh pairs from exchange. + (Note: Requires exchange to be passed as well.) + :param exchange: Exchange object (needed when using "refresh_pairs") + :param fill_up_missing: Fill missing values with "No action"-candles + :param drop_incomplete: Drop last candle assuming it may be incomplete. :return: DataFrame with ohlcv data """ - # If the user force the refresh of pairs + # The user forced the refresh of pairs if refresh_pairs: - if not exchange: - raise OperationalException("Exchange needs to be initialized when " - "calling load_data with refresh_pairs=True") - - logger.info('Download data for pair and store them in %s', datadir) download_pair_history(datadir=datadir, exchange=exchange, pair=pair, @@ -114,11 +116,15 @@ def load_pair_history(pair: str, 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')) - return parse_ticker_dataframe(pairdata, ticker_interval, fill_up_missing) + return parse_ticker_dataframe(pairdata, ticker_interval, pair=pair, + fill_missing=fill_up_missing, + drop_incomplete=drop_incomplete) else: - logger.warning('No data for pair: "%s", Interval: %s. ' - 'Use --refresh-pairs-cached to download the data', - pair, ticker_interval) + logger.warning( + f'No history data for pair: "{pair}", interval: {ticker_interval}. ' + 'Use --refresh-pairs-cached option or download_backtest_data.py ' + 'script to download the data' + ) return None @@ -128,21 +134,34 @@ def load_data(datadir: Optional[Path], refresh_pairs: bool = False, exchange: Optional[Exchange] = None, timerange: TimeRange = TimeRange(None, None, 0, 0), - fill_up_missing: bool = True) -> Dict[str, DataFrame]: + fill_up_missing: bool = True, + live: bool = False + ) -> Dict[str, DataFrame]: """ Loads ticker history data for a list of pairs the given parameters :return: dict(:) """ - result = {} + result: Dict[str, DataFrame] = {} + if live: + if exchange: + logger.info('Live: Downloading data for all defined pairs ...') + exchange.refresh_latest_ohlcv([(pair, ticker_interval) for pair in pairs]) + result = {key[0]: value for key, value in exchange._klines.items() if value is not None} + else: + raise OperationalException( + "Exchange needs to be initialized when using live data." + ) + else: + logger.info('Using local backtesting data ...') - for pair in pairs: - hist = load_pair_history(pair=pair, ticker_interval=ticker_interval, - datadir=datadir, timerange=timerange, - refresh_pairs=refresh_pairs, - exchange=exchange, - fill_up_missing=fill_up_missing) - if hist is not None: - result[pair] = hist + for pair in pairs: + hist = load_pair_history(pair=pair, ticker_interval=ticker_interval, + datadir=datadir, timerange=timerange, + refresh_pairs=refresh_pairs, + exchange=exchange, + fill_up_missing=fill_up_missing) + if hist is not None: + result[pair] = hist return result @@ -151,6 +170,13 @@ def make_testdata_path(datadir: Optional[Path]) -> Path: return datadir or (Path(__file__).parent.parent / "tests" / "testdata").resolve() +def pair_data_filename(datadir: Optional[Path], pair: str, ticker_interval: str) -> Path: + path = make_testdata_path(datadir) + pair_s = pair.replace("/", "_") + filename = path.joinpath(f'{pair_s}-{ticker_interval}.json') + return filename + + def load_cached_data_for_updating(filename: Path, ticker_interval: str, timerange: Optional[TimeRange]) -> Tuple[List[Any], Optional[int]]: @@ -190,7 +216,7 @@ def load_cached_data_for_updating(filename: Path, ticker_interval: str, def download_pair_history(datadir: Optional[Path], - exchange: Exchange, + exchange: Optional[Exchange], pair: str, ticker_interval: str = '5m', timerange: Optional[TimeRange] = None) -> bool: @@ -201,18 +227,24 @@ def download_pair_history(datadir: Optional[Path], the full data will be redownloaded Based on @Rybolov work: https://github.com/rybolov/freqtrade-data + :param pair: pair to download :param ticker_interval: ticker interval :param timerange: range of time to download :return: bool with success state - """ - try: - path = make_testdata_path(datadir) - filepair = pair.replace("/", "_") - filename = path.joinpath(f'{filepair}-{ticker_interval}.json') + if not exchange: + raise OperationalException( + "Exchange needs to be initialized when downloading pair history data" + ) - logger.info('Download the pair: "%s", Interval: %s', pair, ticker_interval) + try: + filename = pair_data_filename(datadir, pair, ticker_interval) + + logger.info( + f'Download history data for pair: "{pair}", interval: {ticker_interval} ' + f'and store in {datadir}.' + ) data, since_ms = load_cached_data_for_updating(filename, ticker_interval, timerange) @@ -231,7 +263,46 @@ def download_pair_history(datadir: Optional[Path], misc.file_dump_json(filename, data) return True - except BaseException: - logger.info('Failed to download the pair: "%s", Interval: %s', - pair, ticker_interval) + + except Exception as e: + logger.error( + f'Failed to download history data for pair: "{pair}", interval: {ticker_interval}. ' + f'Error: {e}' + ) return False + + +def get_timeframe(data: Dict[str, DataFrame]) -> Tuple[arrow.Arrow, arrow.Arrow]: + """ + Get the maximum timeframe for the given backtest data + :param data: dictionary with preprocessed backtesting data + :return: tuple containing min_date, max_date + """ + timeframe = [ + (arrow.get(frame['date'].min()), arrow.get(frame['date'].max())) + for frame in data.values() + ] + return min(timeframe, key=operator.itemgetter(0))[0], \ + max(timeframe, key=operator.itemgetter(1))[1] + + +def validate_backtest_data(data: DataFrame, pair: str, min_date: datetime, + max_date: datetime, ticker_interval_mins: int) -> bool: + """ + Validates preprocessed backtesting data for missing values and shows warnings about it that. + + :param data: preprocessed backtesting data (as DataFrame) + :param pair: pair used for log output. + :param min_date: start-date of the data + :param max_date: end-date of the data + :param ticker_interval_mins: ticker interval in minutes + """ + # total difference in minutes / interval-minutes + expected_frames = int((max_date - min_date).total_seconds() // 60 // ticker_interval_mins) + found_missing = False + dflen = len(data) + if dflen < expected_frames: + found_missing = True + logger.warning("%s has missing frames: expected %s, got %s, that's %s missing values", + pair, expected_frames, dflen, expected_frames - dflen) + return found_missing diff --git a/freqtrade/edge/__init__.py b/freqtrade/edge/__init__.py index 4801c6cb3..4bc8bb493 100644 --- a/freqtrade/edge/__init__.py +++ b/freqtrade/edge/__init__.py @@ -13,7 +13,6 @@ from freqtrade import constants, OperationalException from freqtrade.arguments import Arguments from freqtrade.arguments import TimeRange from freqtrade.data import history -from freqtrade.optimize import get_timeframe from freqtrade.strategy.interface import SellType @@ -47,11 +46,6 @@ class Edge(): self.config = config self.exchange = exchange self.strategy = strategy - self.ticker_interval = self.strategy.ticker_interval - self.tickerdata_to_dataframe = self.strategy.tickerdata_to_dataframe - self.get_timeframe = get_timeframe - self.advise_sell = self.strategy.advise_sell - self.advise_buy = self.strategy.advise_buy self.edge_config = self.config.get('edge', {}) self._cached_pairs: Dict[str, Any] = {} # Keeps a list of pairs @@ -102,7 +96,7 @@ class Edge(): data = history.load_data( datadir=Path(self.config['datadir']) if self.config.get('datadir') else None, pairs=pairs, - ticker_interval=self.ticker_interval, + ticker_interval=self.strategy.ticker_interval, refresh_pairs=self._refresh_pairs, exchange=self.exchange, timerange=self._timerange @@ -114,10 +108,10 @@ class Edge(): logger.critical("No data found. Edge is stopped ...") return False - preprocessed = self.tickerdata_to_dataframe(data) + preprocessed = self.strategy.tickerdata_to_dataframe(data) # Print timeframe - min_date, max_date = self.get_timeframe(preprocessed) + min_date, max_date = history.get_timeframe(preprocessed) logger.info( 'Measuring data from %s up to %s (%s days) ...', min_date.isoformat(), @@ -132,13 +126,14 @@ class Edge(): pair_data = pair_data.sort_values(by=['date']) pair_data = pair_data.reset_index(drop=True) - ticker_data = self.advise_sell( - self.advise_buy(pair_data, {'pair': pair}), {'pair': pair})[headers].copy() + ticker_data = self.strategy.advise_sell( + self.strategy.advise_buy(pair_data, {'pair': pair}), {'pair': pair})[headers].copy() trades += self._find_trades_for_stoploss_range(ticker_data, pair, self._stoploss_range) # If no trade found then exit if len(trades) == 0: + logger.info("No trades found.") return False # Fill missing, calculable columns, profit, duration , abs etc. diff --git a/freqtrade/exchange/__init__.py b/freqtrade/exchange/__init__.py index f6db04da6..5c58320f6 100644 --- a/freqtrade/exchange/__init__.py +++ b/freqtrade/exchange/__init__.py @@ -1,3 +1,10 @@ from freqtrade.exchange.exchange import Exchange # noqa: F401 +from freqtrade.exchange.exchange import (is_exchange_bad, # noqa: F401 + is_exchange_available, + is_exchange_officially_supported, + available_exchanges) +from freqtrade.exchange.exchange import (timeframe_to_seconds, # noqa: F401 + timeframe_to_minutes, + timeframe_to_msecs) from freqtrade.exchange.kraken import Kraken # noqa: F401 from freqtrade.exchange.binance import Binance # noqa: F401 diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 57a002384..a65294091 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -1,23 +1,25 @@ # pragma pylint: disable=W0603 -""" Cryptocurrency Exchanges support """ -import logging +""" +Cryptocurrency Exchanges support +""" +import asyncio import inspect -from random import randint -from typing import List, Dict, Tuple, Any, Optional +import logging +from copy import deepcopy from datetime import datetime -from math import floor, ceil +from math import ceil, floor +from random import randint +from typing import Any, Dict, List, Optional, Tuple import arrow -import asyncio import ccxt import ccxt.async_support as ccxt_async from pandas import DataFrame -from freqtrade import (constants, DependencyException, OperationalException, - TemporaryError, InvalidOrderException) +from freqtrade import (DependencyException, InvalidOrderException, + OperationalException, TemporaryError, constants) from freqtrade.data.converter import parse_ticker_dataframe -from freqtrade.misc import timeframe_to_seconds, timeframe_to_msecs - +from freqtrade.misc import deep_merge_dicts logger = logging.getLogger(__name__) @@ -67,12 +69,15 @@ class Exchange(object): _params: Dict = {} # Dict to specify which options each exchange implements - # TODO: this should be merged with attributes from subclasses - # To avoid having to copy/paste this to all subclasses. - _ft_has: Dict = { + # This defines defaults, which can be selectively overridden by subclasses using _ft_has + # or by specifying them in the configuration. + _ft_has_default: Dict = { "stoploss_on_exchange": False, "order_time_in_force": ["gtc"], + "ohlcv_candle_limit": 500, + "ohlcv_partial_candle": True, } + _ft_has: Dict = {} def __init__(self, config: dict) -> None: """ @@ -99,6 +104,19 @@ class Exchange(object): logger.info('Instance is running with dry_run enabled') exchange_config = config['exchange'] + + # Deep merge ft_has with default ft_has options + self._ft_has = deep_merge_dicts(self._ft_has, deepcopy(self._ft_has_default)) + if exchange_config.get("_ft_has_params"): + self._ft_has = deep_merge_dicts(exchange_config.get("_ft_has_params"), + self._ft_has) + logger.info("Overriding exchange._ft_has with config params, result: %s", self._ft_has) + + # Assign this directly for easy access + self._ohlcv_candle_limit = self._ft_has['ohlcv_candle_limit'] + self._ohlcv_partial_candle = self._ft_has['ohlcv_partial_candle'] + + # Initialize ccxt objects self._api: ccxt.Exchange = self._init_ccxt( exchange_config, ccxt_kwargs=exchange_config.get('ccxt_config')) self._api_async: ccxt_async.Exchange = self._init_ccxt( @@ -138,8 +156,8 @@ class Exchange(object): # Find matching class for the given exchange name name = exchange_config['name'] - if name not in ccxt_module.exchanges: - raise OperationalException(f'Exchange {name} is not supported') + if not is_exchange_available(name, ccxt_module): + raise OperationalException(f'Exchange {name} is not supported by ccxt') ex_config = { 'apiKey': exchange_config.get('key'), @@ -221,8 +239,11 @@ class Exchange(object): > arrow.utcnow().timestamp): return None logger.debug("Performing scheduled market reload..") - self._api.load_markets(reload=True) - self._last_markets_refresh = arrow.utcnow().timestamp + try: + self._api.load_markets(reload=True) + self._last_markets_refresh = arrow.utcnow().timestamp + except ccxt.BaseError: + logger.exception("Could not reload markets.") def validate_pairs(self, pairs: List[str]) -> None: """ @@ -502,11 +523,13 @@ class Exchange(object): async def _async_get_history(self, pair: str, ticker_interval: str, since_ms: int) -> List: - # Assume exchange returns 500 candles - _LIMIT = 500 - one_call = timeframe_to_msecs(ticker_interval) * _LIMIT - logger.debug("one_call: %s msecs", one_call) + one_call = timeframe_to_msecs(ticker_interval) * self._ohlcv_candle_limit + logger.debug( + "one_call: %s msecs (%s)", + one_call, + arrow.utcnow().shift(seconds=one_call // 1000).humanize(only_distance=True) + ) input_coroutines = [self._async_get_candle_history( pair, ticker_interval, since) for since in range(since_ms, arrow.utcnow().timestamp * 1000, one_call)] @@ -537,7 +560,10 @@ class Exchange(object): or self._now_is_time_to_refresh(pair, ticker_interval)): input_coroutines.append(self._async_get_candle_history(pair, ticker_interval)) else: - logger.debug("Using cached ohlcv data for %s, %s ...", pair, ticker_interval) + logger.debug( + "Using cached ohlcv data for pair %s, interval %s ...", + pair, ticker_interval + ) tickers = asyncio.get_event_loop().run_until_complete( asyncio.gather(*input_coroutines, return_exceptions=True)) @@ -555,7 +581,8 @@ class Exchange(object): self._pairs_last_refresh_time[(pair, ticker_interval)] = ticks[-1][0] // 1000 # keeping parsed dataframe in cache self._klines[(pair, ticker_interval)] = parse_ticker_dataframe( - ticks, ticker_interval, fill_missing=True) + ticks, ticker_interval, pair=pair, fill_missing=True, + drop_incomplete=self._ohlcv_partial_candle) return tickers def _now_is_time_to_refresh(self, pair: str, ticker_interval: str) -> bool: @@ -574,7 +601,11 @@ class Exchange(object): """ try: # fetch ohlcv asynchronously - logger.debug("fetching %s, %s since %s ...", pair, ticker_interval, since_ms) + s = '(' + arrow.get(since_ms // 1000).isoformat() + ') ' if since_ms is not None else '' + logger.debug( + "Fetching pair %s, interval %s, since %s %s...", + pair, ticker_interval, since_ms, s + ) data = await self._api_async.fetch_ohlcv(pair, timeframe=ticker_interval, since=since_ms) @@ -589,7 +620,7 @@ class Exchange(object): except IndexError: logger.exception("Error loading %s. Result was %s.", pair, data) return pair, ticker_interval, [] - logger.debug("done fetching %s, %s ...", pair, ticker_interval) + logger.debug("Done fetching pair %s, interval %s ...", pair, ticker_interval) return pair, ticker_interval, data except ccxt.NotSupported as e: @@ -689,3 +720,42 @@ class Exchange(object): f'Could not get fee info due to {e.__class__.__name__}. Message: {e}') except ccxt.BaseError as e: raise OperationalException(e) + + +def is_exchange_bad(exchange: str) -> bool: + return exchange in ['bitmex'] + + +def is_exchange_available(exchange: str, ccxt_module=None) -> bool: + return exchange in available_exchanges(ccxt_module) + + +def is_exchange_officially_supported(exchange: str) -> bool: + return exchange in ['bittrex', 'binance'] + + +def available_exchanges(ccxt_module=None) -> List[str]: + return ccxt_module.exchanges if ccxt_module is not None else ccxt.exchanges + + +def timeframe_to_seconds(ticker_interval: str) -> int: + """ + Translates the timeframe interval value written in the human readable + form ('1m', '5m', '1h', '1d', '1w', etc.) to the number + of seconds for one timeframe interval. + """ + return ccxt.Exchange.parse_timeframe(ticker_interval) + + +def timeframe_to_minutes(ticker_interval: str) -> int: + """ + Same as above, but returns minutes. + """ + return ccxt.Exchange.parse_timeframe(ticker_interval) // 60 + + +def timeframe_to_msecs(ticker_interval: str) -> int: + """ + Same as above, but returns milliseconds. + """ + return ccxt.Exchange.parse_timeframe(ticker_interval) * 1000 diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index a9676a64e..b6fc005dd 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -16,7 +16,7 @@ from freqtrade import (DependencyException, OperationalException, InvalidOrderEx from freqtrade.data.converter import order_book_to_dataframe from freqtrade.data.dataprovider import DataProvider from freqtrade.edge import Edge -from freqtrade.misc import timeframe_to_minutes +from freqtrade.exchange import timeframe_to_minutes from freqtrade.persistence import Trade from freqtrade.rpc import RPCManager, RPCMessageType from freqtrade.resolvers import ExchangeResolver, StrategyResolver, PairListResolver @@ -53,8 +53,7 @@ class FreqtradeBot(object): self.rpc: RPCManager = RPCManager(self) - exchange_name = self.config.get('exchange', {}).get('name', 'bittrex').title() - self.exchange = ExchangeResolver(exchange_name, self.config).exchange + self.exchange = ExchangeResolver(self.config['exchange']['name'], self.config).exchange self.wallets = Wallets(self.config, self.exchange) self.dataprovider = DataProvider(self.config, self.exchange) @@ -73,7 +72,8 @@ class FreqtradeBot(object): self.active_pair_whitelist: List[str] = self.config['exchange']['pair_whitelist'] - persistence.init(self.config) + persistence.init(self.config.get('db_url', None), + clean_open_orders=self.config.get('dry_run', False)) # Set initial bot state from config initial_state = self.config.get('initial_state') @@ -89,6 +89,16 @@ class FreqtradeBot(object): self.rpc.cleanup() persistence.cleanup() + def startup(self) -> None: + """ + Called on startup and after reloading the bot - triggers notifications and + performs startup tasks + """ + self.rpc.startup_messages(self.config, self.pairlists) + if not self.edge: + # Adjust stoploss if it was changed + Trade.stoploss_reinitialization(self.strategy.stoploss) + def process(self) -> bool: """ Queries the persistence layer for open trades and handles them, @@ -194,19 +204,19 @@ class FreqtradeBot(object): else: stake_amount = self.config['stake_amount'] - avaliable_amount = self.wallets.get_free(self.config['stake_currency']) + available_amount = self.wallets.get_free(self.config['stake_currency']) if stake_amount == constants.UNLIMITED_STAKE_AMOUNT: open_trades = len(Trade.get_open_trades()) if open_trades >= self.config['max_open_trades']: logger.warning('Can\'t open a new trade: max number of trades is reached') return None - return avaliable_amount / (self.config['max_open_trades'] - open_trades) + return available_amount / (self.config['max_open_trades'] - open_trades) # Check if stake_amount is fulfilled - if avaliable_amount < stake_amount: + if available_amount < stake_amount: raise DependencyException( - f"Available balance({avaliable_amount} {self.config['stake_currency']}) is " + f"Available balance({available_amount} {self.config['stake_currency']}) is " f"lower than stake amount({stake_amount} {self.config['stake_currency']})" ) @@ -334,8 +344,8 @@ class FreqtradeBot(object): return False amount = stake_amount / buy_limit_requested - - order = self.exchange.buy(pair=pair, ordertype=self.strategy.order_types['buy'], + order_type = self.strategy.order_types['buy'] + order = self.exchange.buy(pair=pair, ordertype=order_type, amount=amount, rate=buy_limit_requested, time_in_force=time_in_force) order_id = order['id'] @@ -345,7 +355,6 @@ class FreqtradeBot(object): buy_limit_filled_price = buy_limit_requested if order_status == 'expired' or order_status == 'rejected': - order_type = self.strategy.order_types['buy'] order_tif = self.strategy.order_time_in_force['buy'] # return false if the order is not filled @@ -379,6 +388,7 @@ class FreqtradeBot(object): 'exchange': self.exchange.name.capitalize(), 'pair': pair_s, 'limit': buy_limit_filled_price, + 'order_type': order_type, 'stake_amount': stake_amount, 'stake_currency': stake_currency, 'fiat_currency': fiat_currency @@ -460,7 +470,7 @@ class FreqtradeBot(object): def get_real_amount(self, trade: Trade, order: Dict) -> float: """ Get real amount for the trade - Necessary for self.exchanges which charge fees in base currency (e.g. binance) + Necessary for exchanges which charge fees in base currency (e.g. binance) """ order_amount = order['amount'] # Only run for closed orders @@ -522,6 +532,10 @@ class FreqtradeBot(object): trade.update(order) + # Updating wallets when order is closed + if not trade.is_open: + self.wallets.update() + def get_sell_rate(self, pair: str, refresh: bool) -> float: """ Get sell rate - either using get-ticker bid or first bid based on orderbook @@ -676,13 +690,22 @@ class FreqtradeBot(object): # cancelling the current stoploss on exchange first logger.info('Trailing stoploss: cancelling current stoploss on exchange (id:{%s})' 'in order to add another one ...', order['id']) - if self.exchange.cancel_order(order['id'], trade.pair): + try: + self.exchange.cancel_order(order['id'], trade.pair) + except InvalidOrderException: + logger.exception(f"Could not cancel stoploss order {order['id']} " + f"for pair {trade.pair}") + + try: # creating the new one stoploss_order_id = self.exchange.stoploss_limit( pair=trade.pair, amount=trade.amount, stop_price=trade.stop_loss, rate=trade.stop_loss * 0.99 )['id'] trade.stoploss_order_id = str(stoploss_order_id) + except DependencyException: + logger.exception(f"Could create trailing stoploss order " + f"for pair {trade.pair}.") def check_sell(self, trade: Trade, sell_rate: float, buy: bool, sell: bool) -> bool: if self.edge: @@ -828,7 +851,10 @@ class FreqtradeBot(object): # First cancelling stoploss on exchange ... if self.strategy.order_types.get('stoploss_on_exchange') and trade.stoploss_order_id: - self.exchange.cancel_order(trade.stoploss_order_id, trade.pair) + try: + self.exchange.cancel_order(trade.stoploss_order_id, trade.pair) + except InvalidOrderException: + logger.exception(f"Could not cancel stoploss order {trade.stoploss_order_id}") # Execute sell and update trade record order_id = self.exchange.sell(pair=str(trade.pair), @@ -860,6 +886,7 @@ class FreqtradeBot(object): 'pair': trade.pair, 'gain': gain, 'limit': trade.close_rate_requested, + 'order_type': self.strategy.order_types['sell'], 'amount': trade.amount, 'open_rate': trade.open_rate, 'current_rate': current_rate, diff --git a/freqtrade/main.py b/freqtrade/main.py index 877e2921d..6f073f5d4 100755 --- a/freqtrade/main.py +++ b/freqtrade/main.py @@ -3,10 +3,16 @@ Main Freqtrade bot script. Read the documentation to know what cli arguments you need. """ -import logging + import sys +# check min. python version +if sys.version_info < (3, 6): + sys.exit("Freqtrade requires Python version >= 3.6") + +# flake8: noqa E402 +import logging from argparse import Namespace -from typing import List +from typing import Any, List from freqtrade import OperationalException from freqtrade.arguments import Arguments @@ -17,37 +23,43 @@ from freqtrade.worker import Worker logger = logging.getLogger('freqtrade') -def main(sysargv: List[str]) -> None: +def main(sysargv: List[str] = None) -> None: """ This function will initiate the bot and start the trading loop. :return: None """ - arguments = Arguments( - sysargv, - 'Free, open source crypto trading bot' - ) - args: Namespace = arguments.get_parsed_arg() - - # A subcommand has been issued. - # Means if Backtesting or Hyperopt have been called we exit the bot - if hasattr(args, 'func'): - args.func(args) - return + return_code: Any = 1 worker = None - return_code = 1 try: - # Load and run worker - worker = Worker(args) - worker.run() + set_loggers() + arguments = Arguments( + sysargv, + 'Free, open source crypto trading bot' + ) + args: Namespace = arguments.get_parsed_arg() + + # A subcommand has been issued. + # Means if Backtesting or Hyperopt have been called we exit the bot + if hasattr(args, 'func'): + args.func(args) + # TODO: fetch return_code as returned by the command function here + return_code = 0 + else: + # Load and run worker + worker = Worker(args) + worker.run() + + except SystemExit as e: + return_code = e except KeyboardInterrupt: logger.info('SIGINT received, aborting ...') return_code = 0 except OperationalException as e: logger.error(str(e)) return_code = 2 - except BaseException: + except Exception: logger.exception('Fatal exception!') finally: if worker: @@ -56,5 +68,4 @@ def main(sysargv: List[str]) -> None: if __name__ == '__main__': - set_loggers() - main(sys.argv[1:]) + main() diff --git a/freqtrade/misc.py b/freqtrade/misc.py index d066878be..460e20e91 100644 --- a/freqtrade/misc.py +++ b/freqtrade/misc.py @@ -1,18 +1,17 @@ """ Various tool function for Freqtrade and scripts """ - import gzip import logging import re from datetime import datetime from typing import Dict -from ccxt import Exchange import numpy as np from pandas import DataFrame import rapidjson + logger = logging.getLogger(__name__) @@ -118,6 +117,8 @@ def format_ms_time(date: int) -> str: def deep_merge_dicts(source, destination): """ + Values from Source override destination, destination is returned (and modified!!) + Sample: >>> a = { 'first' : { 'rows' : { 'pass' : 'dog', 'number' : '1' } } } >>> b = { 'first' : { 'rows' : { 'fail' : 'cat', 'number' : '5' } } } >>> merge(b, a) == { 'first' : { 'rows' : { 'pass' : 'dog', 'fail' : 'cat', 'number' : '5' } } } @@ -132,26 +133,3 @@ def deep_merge_dicts(source, destination): destination[key] = value return destination - - -def timeframe_to_seconds(ticker_interval: str) -> int: - """ - Translates the timeframe interval value written in the human readable - form ('1m', '5m', '1h', '1d', '1w', etc.) to the number - of seconds for one timeframe interval. - """ - return Exchange.parse_timeframe(ticker_interval) - - -def timeframe_to_minutes(ticker_interval: str) -> int: - """ - Same as above, but returns minutes. - """ - return Exchange.parse_timeframe(ticker_interval) // 60 - - -def timeframe_to_msecs(ticker_interval: str) -> int: - """ - Same as above, but returns milliseconds. - """ - return Exchange.parse_timeframe(ticker_interval) * 1000 diff --git a/freqtrade/optimize/__init__.py b/freqtrade/optimize/__init__.py index 19b8dd90a..8b548eefe 100644 --- a/freqtrade/optimize/__init__.py +++ b/freqtrade/optimize/__init__.py @@ -1,49 +1,111 @@ -# pragma pylint: disable=missing-docstring - import logging -from datetime import datetime -from typing import Dict, Tuple -import operator +from argparse import Namespace +from typing import Any, Dict -import arrow -from pandas import DataFrame +from filelock import FileLock, Timeout + +from freqtrade import DependencyException, constants +from freqtrade.state import RunMode +from freqtrade.utils import setup_utils_configuration -from freqtrade.optimize.default_hyperopt import DefaultHyperOpts # noqa: F401 logger = logging.getLogger(__name__) -def get_timeframe(data: Dict[str, DataFrame]) -> Tuple[arrow.Arrow, arrow.Arrow]: +def setup_configuration(args: Namespace, method: RunMode) -> Dict[str, Any]: """ - Get the maximum timeframe for the given backtest data - :param data: dictionary with preprocessed backtesting data - :return: tuple containing min_date, max_date + Prepare the configuration for the Hyperopt module + :param args: Cli args from Arguments() + :return: Configuration """ - timeframe = [ - (arrow.get(frame['date'].min()), arrow.get(frame['date'].max())) - for frame in data.values() - ] - return min(timeframe, key=operator.itemgetter(0))[0], \ - max(timeframe, key=operator.itemgetter(1))[1] + config = setup_utils_configuration(args, method) + + if method == RunMode.BACKTEST: + if config['stake_amount'] == constants.UNLIMITED_STAKE_AMOUNT: + raise DependencyException('stake amount could not be "%s" for backtesting' % + constants.UNLIMITED_STAKE_AMOUNT) + + if method == RunMode.HYPEROPT: + # Special cases for Hyperopt + if config.get('strategy') and config.get('strategy') != 'DefaultStrategy': + logger.error("Please don't use --strategy for hyperopt.") + logger.error( + "Read the documentation at " + "https://github.com/freqtrade/freqtrade/blob/develop/docs/hyperopt.md " + "to understand how to configure hyperopt.") + raise DependencyException("--strategy configured but not supported for hyperopt") + + return config -def validate_backtest_data(data: Dict[str, DataFrame], min_date: datetime, - max_date: datetime, ticker_interval_mins: int) -> bool: +def start_backtesting(args: Namespace) -> None: """ - Validates preprocessed backtesting data for missing values and shows warnings about it that. + Start Backtesting script + :param args: Cli args from Arguments() + :return: None + """ + # Import here to avoid loading backtesting module when it's not used + from freqtrade.optimize.backtesting import Backtesting - :param data: dictionary with preprocessed backtesting data - :param min_date: start-date of the data - :param max_date: end-date of the data - :param ticker_interval_mins: ticker interval in minutes + # Initialize configuration + config = setup_configuration(args, RunMode.BACKTEST) + + logger.info('Starting freqtrade in Backtesting mode') + + # Initialize backtesting object + backtesting = Backtesting(config) + backtesting.start() + + +def start_hyperopt(args: Namespace) -> None: """ - # total difference in minutes / interval-minutes - expected_frames = int((max_date - min_date).total_seconds() // 60 // ticker_interval_mins) - found_missing = False - for pair, df in data.items(): - dflen = len(df) - if dflen < expected_frames: - found_missing = True - logger.warning("%s has missing frames: expected %s, got %s, that's %s missing values", - pair, expected_frames, dflen, expected_frames - dflen) - return found_missing + Start hyperopt script + :param args: Cli args from Arguments() + :return: None + """ + # Import here to avoid loading hyperopt module when it's not used + from freqtrade.optimize.hyperopt import Hyperopt, HYPEROPT_LOCKFILE + + # Initialize configuration + config = setup_configuration(args, RunMode.HYPEROPT) + + logger.info('Starting freqtrade in Hyperopt mode') + + lock = FileLock(HYPEROPT_LOCKFILE) + + try: + with lock.acquire(timeout=1): + + # Remove noisy log messages + logging.getLogger('hyperopt.tpe').setLevel(logging.WARNING) + logging.getLogger('filelock').setLevel(logging.WARNING) + + # Initialize backtesting object + hyperopt = Hyperopt(config) + hyperopt.start() + + except Timeout: + logger.info("Another running instance of freqtrade Hyperopt detected.") + logger.info("Simultaneous execution of multiple Hyperopt commands is not supported. " + "Hyperopt module is resource hungry. Please run your Hyperopts sequentially " + "or on separate machines.") + logger.info("Quitting now.") + # TODO: return False here in order to help freqtrade to exit + # with non-zero exit code... + # Same in Edge and Backtesting start() functions. + + +def start_edge(args: Namespace) -> None: + """ + Start Edge script + :param args: Cli args from Arguments() + :return: None + """ + from freqtrade.optimize.edge_cli import EdgeCli + # Initialize configuration + config = setup_configuration(args, RunMode.EDGE) + logger.info('Starting freqtrade in Edge mode') + + # Initialize Edge object + edge_cli = EdgeCli(config) + edge_cli.start() diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 3e4d642cb..923119591 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -4,7 +4,6 @@ This module contains the backtesting logic """ import logging -from argparse import Namespace from copy import deepcopy from datetime import datetime, timedelta from pathlib import Path @@ -13,17 +12,15 @@ from typing import Any, Dict, List, NamedTuple, Optional from pandas import DataFrame from tabulate import tabulate -from freqtrade import optimize -from freqtrade import DependencyException, constants from freqtrade.arguments import Arguments -from freqtrade.configuration import Configuration from freqtrade.data import history from freqtrade.data.dataprovider import DataProvider -from freqtrade.misc import file_dump_json, timeframe_to_minutes +from freqtrade.exchange import timeframe_to_minutes +from freqtrade.misc import file_dump_json from freqtrade.persistence import Trade from freqtrade.resolvers import ExchangeResolver, StrategyResolver from freqtrade.state import RunMode -from freqtrade.strategy.interface import SellType, IStrategy +from freqtrade.strategy.interface import IStrategy, SellType logger = logging.getLogger(__name__) @@ -66,8 +63,7 @@ class Backtesting(object): self.config['dry_run'] = True self.strategylist: List[IStrategy] = [] - exchange_name = self.config.get('exchange', {}).get('name', 'bittrex').title() - self.exchange = ExchangeResolver(exchange_name, self.config).exchange + self.exchange = ExchangeResolver(self.config['exchange']['name'], self.config).exchange self.fee = self.exchange.get_fee() if self.config.get('runmode') != RunMode.HYPEROPT: @@ -75,18 +71,16 @@ class Backtesting(object): IStrategy.dp = self.dataprovider if self.config.get('strategy_list', None): - # Force one interval - self.ticker_interval = str(self.config.get('ticker_interval')) - self.ticker_interval_mins = timeframe_to_minutes(self.ticker_interval) for strat in list(self.config['strategy_list']): stratconf = deepcopy(self.config) stratconf['strategy'] = strat self.strategylist.append(StrategyResolver(stratconf).strategy) else: - # only one strategy + # No strategy list specified, only one strategy self.strategylist.append(StrategyResolver(self.config).strategy) - # Load one strategy + + # Load one (first) strategy self._set_strategy(self.strategylist[0]) def _set_strategy(self, strategy): @@ -97,7 +91,6 @@ class Backtesting(object): self.ticker_interval = self.config.get('ticker_interval') self.ticker_interval_mins = timeframe_to_minutes(self.ticker_interval) - self.tickerdata_to_dataframe = strategy.tickerdata_to_dataframe self.advise_buy = strategy.advise_buy self.advise_sell = strategy.advise_sell # Set stoploss_on_exchange to false for backtesting, @@ -238,10 +231,9 @@ class Backtesting(object): def _get_sell_trade_entry( self, pair: str, buy_row: DataFrame, - partial_ticker: List, trade_count_lock: Dict, args: Dict) -> Optional[BacktestResult]: + partial_ticker: List, trade_count_lock: Dict, + stake_amount: float, max_open_trades: int) -> Optional[BacktestResult]: - stake_amount = args['stake_amount'] - max_open_trades = args.get('max_open_trades', 0) trade = Trade( open_rate=buy_row.open, open_date=buy_row.date, @@ -257,8 +249,7 @@ class Backtesting(object): # Increase trade_count_lock for every iteration trade_count_lock[sell_row.date] = trade_count_lock.get(sell_row.date, 0) + 1 - buy_signal = sell_row.buy - sell = self.strategy.should_sell(trade, sell_row.open, sell_row.date, buy_signal, + sell = self.strategy.should_sell(trade, sell_row.open, sell_row.date, sell_row.buy, sell_row.sell, low=sell_row.low, high=sell_row.high) if sell.sell_flag: @@ -331,6 +322,7 @@ class Backtesting(object): :return: DataFrame """ processed = args['processed'] + stake_amount = args['stake_amount'] max_open_trades = args.get('max_open_trades', 0) position_stacking = args.get('position_stacking', False) start_date = args['start_date'] @@ -357,7 +349,7 @@ class Backtesting(object): row = ticker[pair][indexes[pair]] except IndexError: # missing Data for one pair at the end. - # Warnings for this are shown by `validate_backtest_data` + # Warnings for this are shown during data loading continue # Waits until the time-counter reaches the start of the data for this pair. @@ -381,7 +373,8 @@ class Backtesting(object): trade_count_lock[row.date] = trade_count_lock.get(row.date, 0) + 1 trade_entry = self._get_sell_trade_entry(pair, row, ticker[pair][indexes[pair]:], - trade_count_lock, args) + trade_count_lock, stake_amount, + max_open_trades) if trade_entry: lock_pair_until[pair] = trade_entry.close_time @@ -404,24 +397,17 @@ class Backtesting(object): logger.info('Using stake_currency: %s ...', self.config['stake_currency']) logger.info('Using stake_amount: %s ...', self.config['stake_amount']) - if self.config.get('live'): - logger.info('Downloading data for all pairs in whitelist ...') - self.exchange.refresh_latest_ohlcv([(pair, self.ticker_interval) for pair in pairs]) - data = {key[0]: value for key, value in self.exchange._klines.items()} - - else: - logger.info('Using local backtesting data (using whitelist in given config) ...') - - timerange = Arguments.parse_timerange(None if self.config.get( - 'timerange') is None else str(self.config.get('timerange'))) - data = history.load_data( - datadir=Path(self.config['datadir']) if self.config.get('datadir') else None, - pairs=pairs, - ticker_interval=self.ticker_interval, - refresh_pairs=self.config.get('refresh_pairs', False), - exchange=self.exchange, - timerange=timerange - ) + timerange = Arguments.parse_timerange(None if self.config.get( + 'timerange') is None else str(self.config.get('timerange'))) + data = history.load_data( + datadir=Path(self.config['datadir']) if self.config.get('datadir') else None, + pairs=pairs, + ticker_interval=self.ticker_interval, + refresh_pairs=self.config.get('refresh_pairs', False), + exchange=self.exchange, + timerange=timerange, + live=self.config.get('live', False) + ) if not data: logger.critical("No data found. Terminating.") @@ -434,20 +420,19 @@ class Backtesting(object): max_open_trades = 0 all_results = {} + min_date, max_date = history.get_timeframe(data) + + logger.info( + 'Backtesting with data from %s up to %s (%s days)..', + min_date.isoformat(), + max_date.isoformat(), + (max_date - min_date).days + ) + for strat in self.strategylist: logger.info("Running backtesting for Strategy %s", strat.get_strategy_name()) self._set_strategy(strat) - min_date, max_date = optimize.get_timeframe(data) - # Validate dataframe for missing values (mainly at start and end, as fillup is called) - optimize.validate_backtest_data(data, min_date, max_date, - timeframe_to_minutes(self.ticker_interval)) - logger.info( - 'Measuring data from %s up to %s (%s days)..', - min_date.isoformat(), - max_date.isoformat(), - (max_date - min_date).days - ) # need to reprocess data every time to populate signals preprocessed = self.strategy.tickerdata_to_dataframe(data) @@ -484,38 +469,3 @@ class Backtesting(object): print(' Strategy Summary '.center(133, '=')) print(self._generate_text_table_strategy(all_results)) print('\nFor more details, please look at the detail tables above') - - -def setup_configuration(args: Namespace) -> Dict[str, Any]: - """ - Prepare the configuration for the backtesting - :param args: Cli args from Arguments() - :return: Configuration - """ - configuration = Configuration(args, RunMode.BACKTEST) - config = configuration.get_config() - - # Ensure we do not use Exchange credentials - config['exchange']['key'] = '' - config['exchange']['secret'] = '' - - if config['stake_amount'] == constants.UNLIMITED_STAKE_AMOUNT: - raise DependencyException('stake amount could not be "%s" for backtesting' % - constants.UNLIMITED_STAKE_AMOUNT) - - return config - - -def start(args: Namespace) -> None: - """ - Start Backtesting script - :param args: Cli args from Arguments() - :return: None - """ - # Initialize configuration - config = setup_configuration(args) - logger.info('Starting freqtrade in Backtesting mode') - - # Initialize backtesting object - backtesting = Backtesting(config) - backtesting.start() diff --git a/freqtrade/optimize/default_hyperopt.py b/freqtrade/optimize/default_hyperopt.py index 721848d2e..7f1cb2435 100644 --- a/freqtrade/optimize/default_hyperopt.py +++ b/freqtrade/optimize/default_hyperopt.py @@ -70,9 +70,10 @@ class DefaultHyperOpts(IHyperOpt): dataframe['close'], dataframe['sar'] )) - dataframe.loc[ - reduce(lambda x, y: x & y, conditions), - 'buy'] = 1 + if conditions: + dataframe.loc[ + reduce(lambda x, y: x & y, conditions), + 'buy'] = 1 return dataframe @@ -129,9 +130,10 @@ class DefaultHyperOpts(IHyperOpt): dataframe['sar'], dataframe['close'] )) - dataframe.loc[ - reduce(lambda x, y: x & y, conditions), - 'sell'] = 1 + if conditions: + dataframe.loc[ + reduce(lambda x, y: x & y, conditions), + 'sell'] = 1 return dataframe diff --git a/freqtrade/optimize/edge_cli.py b/freqtrade/optimize/edge_cli.py index 9b628cf2e..231493e4d 100644 --- a/freqtrade/optimize/edge_cli.py +++ b/freqtrade/optimize/edge_cli.py @@ -4,16 +4,14 @@ This module contains the edge backtesting interface """ import logging -from argparse import Namespace from typing import Dict, Any from tabulate import tabulate +from freqtrade import constants from freqtrade.edge import Edge from freqtrade.arguments import Arguments -from freqtrade.configuration import Configuration from freqtrade.exchange import Exchange from freqtrade.resolvers import StrategyResolver -from freqtrade.state import RunMode logger = logging.getLogger(__name__) @@ -35,6 +33,7 @@ class EdgeCli(object): self.config['exchange']['secret'] = '' self.config['exchange']['password'] = '' self.config['exchange']['uid'] = '' + self.config['stake_amount'] = constants.UNLIMITED_STAKE_AMOUNT self.config['dry_run'] = True self.exchange = Exchange(self.config) self.strategy = StrategyResolver(self.config).strategy @@ -73,37 +72,7 @@ class EdgeCli(object): floatfmt=floatfmt, tablefmt="pipe") def start(self) -> None: - self.edge.calculate() - print('') # blank like for readability - print(self._generate_edge_table(self.edge._cached_pairs)) - - -def setup_configuration(args: Namespace) -> Dict[str, Any]: - """ - Prepare the configuration for edge backtesting - :param args: Cli args from Arguments() - :return: Configuration - """ - configuration = Configuration(args, RunMode.EDGECLI) - config = configuration.get_config() - - # Ensure we do not use Exchange credentials - config['exchange']['key'] = '' - config['exchange']['secret'] = '' - - return config - - -def start(args: Namespace) -> None: - """ - Start Edge script - :param args: Cli args from Arguments() - :return: None - """ - # Initialize configuration - config = setup_configuration(args) - logger.info('Starting freqtrade in Edge mode') - - # Initialize Edge object - edge_cli = EdgeCli(config) - edge_cli.start() + result = self.edge.calculate() + if result: + print('') # blank line for readability + print(self._generate_edge_table(self.edge._cached_pairs)) diff --git a/freqtrade/optimize/hyperopt.py b/freqtrade/optimize/hyperopt.py index f6d39f11c..7fd9bf5d9 100644 --- a/freqtrade/optimize/hyperopt.py +++ b/freqtrade/optimize/hyperopt.py @@ -5,33 +5,33 @@ This module contains the hyperopt logic """ import logging -import multiprocessing import os import sys -from argparse import Namespace from math import exp from operator import itemgetter from pathlib import Path from pprint import pprint from typing import Any, Dict, List -from joblib import Parallel, delayed, dump, load, wrap_non_picklable_objects +from joblib import Parallel, delayed, dump, load, wrap_non_picklable_objects, cpu_count from pandas import DataFrame from skopt import Optimizer from skopt.space import Dimension from freqtrade.arguments import Arguments -from freqtrade.configuration import Configuration -from freqtrade.data.history import load_data -from freqtrade.optimize import get_timeframe +from freqtrade.data.history import load_data, get_timeframe from freqtrade.optimize.backtesting import Backtesting -from freqtrade.state import RunMode -from freqtrade.resolvers import HyperOptResolver +from freqtrade.resolvers.hyperopt_resolver import HyperOptResolver + logger = logging.getLogger(__name__) + +INITIAL_POINTS = 30 MAX_LOSS = 100000 # just a big enough number to be bad result in loss optimization TICKERDATA_PICKLE = os.path.join('user_data', 'hyperopt_tickerdata.pkl') +TRIALSDATA_PICKLE = os.path.join('user_data', 'hyperopt_results.pickle') +HYPEROPT_LOCKFILE = os.path.join('user_data', 'hyperopt.lock') class Hyperopt(Backtesting): @@ -44,7 +44,6 @@ class Hyperopt(Backtesting): """ def __init__(self, config: Dict[str, Any]) -> None: super().__init__(config) - self.config = config self.custom_hyperopt = HyperOptResolver(self.config).hyperopt # set TARGET_TRADES to suit your number concurrent trades so its realistic @@ -57,13 +56,15 @@ class Hyperopt(Backtesting): # if eval ends with higher value, we consider it a failed eval self.max_accepted_trade_duration = 300 - # this is expexted avg profit * expected trade count - # for example 3.5%, 1100 trades, self.expected_max_profit = 3.85 - # check that the reported Σ% values do not exceed this! + # This is assumed to be expected avg profit * expected trade count. + # For example, for 0.35% avg per trade (or 0.0035 as ratio) and 1100 trades, + # self.expected_max_profit = 3.85 + # Check that the reported Σ% values do not exceed this! + # Note, this is ratio. 3.85 stated above means 385Σ%. self.expected_max_profit = 3.0 # Previous evaluations - self.trials_file = os.path.join('user_data', 'hyperopt_results.pickle') + self.trials_file = TRIALSDATA_PICKLE self.trials: List = [] def get_args(self, params): @@ -115,14 +116,20 @@ class Hyperopt(Backtesting): """ Log results if it is better than any previous evaluation """ - if results['loss'] < self.current_best_loss: - current = results['current_tries'] + print_all = self.config.get('print_all', False) + if print_all or results['loss'] < self.current_best_loss: + # Output human-friendly index here (starting from 1) + current = results['current_tries'] + 1 total = results['total_tries'] res = results['result'] loss = results['loss'] self.current_best_loss = results['loss'] - log_msg = f'\n{current:5d}/{total}: {res}. Loss {loss:.5f}' - print(log_msg) + log_msg = f'{current:5d}/{total}: {res} Objective: {loss:.5f}' + log_msg = f'*{log_msg}' if results['initial_point'] else f' {log_msg}' + if print_all: + print(log_msg) + else: + print('\n' + log_msg) else: print('.', end='') sys.stdout.flush() @@ -199,7 +206,11 @@ class Hyperopt(Backtesting): trade_count = len(results.index) trade_duration = results.trade_duration.mean() - if trade_count == 0: + # If this evaluation contains too short amount of trades to be + # interesting -- consider it as 'bad' (assigned max. loss value) + # in order to cast this hyperspace point away from optimization + # path. We do not want to optimize 'hodl' strategies. + if trade_count < self.config['hyperopt_min_trades']: return { 'loss': MAX_LOSS, 'params': params, @@ -222,20 +233,21 @@ class Hyperopt(Backtesting): avg_profit = results.profit_percent.mean() * 100.0 total_profit = results.profit_abs.sum() stake_cur = self.config['stake_currency'] - profit = results.profit_percent.sum() + profit = results.profit_percent.sum() * 100.0 duration = results.trade_duration.mean() return (f'{trades:6d} trades. Avg profit {avg_profit: 5.2f}%. ' f'Total profit {total_profit: 11.8f} {stake_cur} ' - f'({profit:.4f}Σ%). Avg duration {duration:5.1f} mins.') + f'({profit: 7.2f}Σ%). Avg duration {duration:5.1f} mins.') def get_optimizer(self, cpu_count) -> Optimizer: return Optimizer( self.hyperopt_space(), base_estimator="ET", acq_optimizer="auto", - n_initial_points=30, - acq_optimizer_kwargs={'n_jobs': cpu_count} + n_initial_points=INITIAL_POINTS, + acq_optimizer_kwargs={'n_jobs': cpu_count}, + random_state=self.config.get('hyperopt_random_state', None) ) def run_optimizer_parallel(self, parallel, asked) -> List: @@ -258,69 +270,68 @@ class Hyperopt(Backtesting): datadir=Path(self.config['datadir']) if self.config.get('datadir') else None, pairs=self.config['exchange']['pair_whitelist'], ticker_interval=self.ticker_interval, + refresh_pairs=self.config.get('refresh_pairs', False), + exchange=self.exchange, timerange=timerange ) + if not data: + logger.critical("No data found. Terminating.") + return + + min_date, max_date = get_timeframe(data) + + logger.info( + 'Hyperopting with data from %s up to %s (%s days)..', + min_date.isoformat(), + max_date.isoformat(), + (max_date - min_date).days + ) + if self.has_space('buy') or self.has_space('sell'): self.strategy.advise_indicators = \ self.custom_hyperopt.populate_indicators # type: ignore - dump(self.strategy.tickerdata_to_dataframe(data), TICKERDATA_PICKLE) + + preprocessed = self.strategy.tickerdata_to_dataframe(data) + + dump(preprocessed, TICKERDATA_PICKLE) + + # We don't need exchange instance anymore while running hyperopt self.exchange = None # type: ignore + self.load_previous_results() - cpus = multiprocessing.cpu_count() + cpus = cpu_count() logger.info(f'Found {cpus} CPU cores. Let\'s make them scream!') + config_jobs = self.config.get('hyperopt_jobs', -1) + logger.info(f'Number of parallel jobs set as: {config_jobs}') - opt = self.get_optimizer(cpus) - EVALS = max(self.total_tries // cpus, 1) + opt = self.get_optimizer(config_jobs) try: - with Parallel(n_jobs=cpus) as parallel: + with Parallel(n_jobs=config_jobs) as parallel: + jobs = parallel._effective_n_jobs() + logger.info(f'Effective number of parallel workers used: {jobs}') + EVALS = max(self.total_tries // jobs, 1) for i in range(EVALS): - asked = opt.ask(n_points=cpus) + asked = opt.ask(n_points=jobs) f_val = self.run_optimizer_parallel(parallel, asked) opt.tell(asked, [i['loss'] for i in f_val]) self.trials += f_val - for j in range(cpus): + for j in range(jobs): + current = i * jobs + j self.log_results({ 'loss': f_val[j]['loss'], - 'current_tries': i * cpus + j, + 'current_tries': current, + 'initial_point': current < INITIAL_POINTS, 'total_tries': self.total_tries, 'result': f_val[j]['result'], }) + logger.debug(f"Optimizer params: {f_val[j]['params']}") + for j in range(jobs): + logger.debug(f"Optimizer state: Xi: {opt.Xi[-j-1]}, yi: {opt.yi[-j-1]}") except KeyboardInterrupt: print('User interrupted..') self.save_trials() self.log_trials_result() - - -def start(args: Namespace) -> None: - """ - Start Backtesting script - :param args: Cli args from Arguments() - :return: None - """ - - # Remove noisy log messages - logging.getLogger('hyperopt.tpe').setLevel(logging.WARNING) - - # Initialize configuration - # Monkey patch the configuration with hyperopt_conf.py - configuration = Configuration(args, RunMode.HYPEROPT) - logger.info('Starting freqtrade in Hyperopt mode') - config = configuration.load_config() - - config['exchange']['key'] = '' - config['exchange']['secret'] = '' - - if config.get('strategy') and config.get('strategy') != 'DefaultStrategy': - logger.error("Please don't use --strategy for hyperopt.") - logger.error( - "Read the documentation at " - "https://github.com/freqtrade/freqtrade/blob/develop/docs/hyperopt.md " - "to understand how to configure hyperopt.") - raise ValueError("--strategy configured but not supported for hyperopt") - # Initialize backtesting object - hyperopt = Hyperopt(config) - hyperopt.start() diff --git a/freqtrade/optimize/hyperopt_interface.py b/freqtrade/optimize/hyperopt_interface.py index 622de3015..08823ece0 100644 --- a/freqtrade/optimize/hyperopt_interface.py +++ b/freqtrade/optimize/hyperopt_interface.py @@ -20,6 +20,7 @@ class IHyperOpt(ABC): stoploss -> float: optimal stoploss designed for the strategy ticker_interval -> int: value of the ticker interval to use for the strategy """ + ticker_interval: str @staticmethod @abstractmethod diff --git a/freqtrade/persistence.py b/freqtrade/persistence.py index 5a18a922a..c844bbc4c 100644 --- a/freqtrade/persistence.py +++ b/freqtrade/persistence.py @@ -25,15 +25,16 @@ _DECL_BASE: Any = declarative_base() _SQL_DOCS_URL = 'http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls' -def init(config: Dict) -> None: +def init(db_url: str, clean_open_orders: bool = False) -> None: """ Initializes this module with the given config, registers all known command handlers and starts polling for message updates - :param config: config to use + :param db_url: Database to use + :param clean_open_orders: Remove open orders from the database. + Useful for dry-run or if all orders have been reset on the exchange. :return: None """ - db_url = config.get('db_url', None) kwargs = {} # Take care of thread ownership if in-memory db @@ -57,7 +58,7 @@ def init(config: Dict) -> None: check_migrate(engine) # Clean dry_run DB if the db is not in-memory - if config.get('dry_run', False) and db_url != 'sqlite://': + if clean_open_orders and db_url != 'sqlite://': clean_dry_run_db() @@ -213,11 +214,31 @@ class Trade(_DECL_BASE): return (f'Trade(id={self.id}, pair={self.pair}, amount={self.amount:.8f}, ' f'open_rate={self.open_rate:.8f}, open_since={open_since})') + def to_json(self) -> Dict[str, Any]: + return { + 'trade_id': self.id, + 'pair': self.pair, + 'open_date_hum': arrow.get(self.open_date).humanize(), + 'open_date': self.open_date.strftime("%Y-%m-%d %H:%M:%S"), + 'close_date_hum': (arrow.get(self.close_date).humanize() + if self.close_date else None), + 'close_date': (self.close_date.strftime("%Y-%m-%d %H:%M:%S") + if self.close_date else None), + 'open_rate': self.open_rate, + 'close_rate': self.close_rate, + 'amount': round(self.amount, 8), + 'stake_amount': round(self.stake_amount, 8), + 'stop_loss': self.stop_loss, + 'stop_loss_pct': (self.stop_loss_pct * 100) if self.stop_loss_pct else None, + 'initial_stop_loss': self.initial_stop_loss, + 'initial_stop_loss_pct': (self.initial_stop_loss_pct * 100 + if self.initial_stop_loss_pct else None), + } + def adjust_min_max_rates(self, current_price: float): """ Adjust the max_rate and min_rate. """ - logger.debug("Adjusting min/max rates") 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) @@ -401,3 +422,22 @@ class Trade(_DECL_BASE): Query trades from persistence layer """ return Trade.query.filter(Trade.is_open.is_(True)).all() + + @staticmethod + def stoploss_reinitialization(desired_stoploss): + """ + Adjust initial Stoploss to desired stoploss for all open trades. + """ + for trade in Trade.get_open_trades(): + logger.info("Found open trade: %s", trade) + + # skip case if trailing-stop changed the stoploss already. + if (trade.stop_loss == trade.initial_stop_loss + and trade.initial_stop_loss_pct != desired_stoploss): + # Stoploss value got changed + + logger.info(f"Stoploss for {trade} needs adjustment.") + # Force reset of stoploss + trade.stop_loss = None + trade.adjust_stop_loss(trade.open_rate, desired_stoploss) + logger.info(f"new stoploss: {trade.stop_loss}, ") diff --git a/freqtrade/plot/__init__.py b/freqtrade/plot/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/freqtrade/plot/plotting.py b/freqtrade/plot/plotting.py new file mode 100644 index 000000000..c058f7fb2 --- /dev/null +++ b/freqtrade/plot/plotting.py @@ -0,0 +1,223 @@ +import logging +from typing import List + +import pandas as pd +from pathlib import Path + +logger = logging.getLogger(__name__) + + +try: + from plotly import tools + from plotly.offline import plot + import plotly.graph_objs as go +except ImportError: + logger.exception("Module plotly not found \n Please install using `pip install plotly`") + exit(1) + + +def generate_row(fig, row, indicators: List[str], data: pd.DataFrame) -> tools.make_subplots: + """ + Generator all the indicator selected by the user for a specific row + :param fig: Plot figure to append to + :param row: row number for this plot + :param indicators: List of indicators present in the dataframe + :param data: candlestick DataFrame + """ + for indicator in indicators: + if indicator in data: + # TODO: Figure out why scattergl causes problems + scattergl = go.Scatter( + x=data['date'], + y=data[indicator].values, + mode='lines', + name=indicator + ) + fig.append_trace(scattergl, row, 1) + else: + logger.info( + 'Indicator "%s" ignored. Reason: This indicator is not found ' + 'in your strategy.', + indicator + ) + + return fig + + +def plot_trades(fig, trades: pd.DataFrame): + """ + Plot trades to "fig" + """ + # Trades can be empty + if trades is not None and len(trades) > 0: + trade_buys = go.Scatter( + x=trades["open_time"], + y=trades["open_rate"], + mode='markers', + name='trade_buy', + marker=dict( + symbol='square-open', + size=11, + line=dict(width=2), + color='green' + ) + ) + # Create description for sell summarizing the trade + desc = trades.apply(lambda row: f"{round(row['profitperc'], 3)}%, {row['sell_reason']}, " + f"{row['duration']}min", + axis=1) + trade_sells = go.Scatter( + x=trades["close_time"], + y=trades["close_rate"], + text=desc, + mode='markers', + name='trade_sell', + marker=dict( + symbol='square-open', + size=11, + line=dict(width=2), + color='red' + ) + ) + fig.append_trace(trade_buys, 1, 1) + fig.append_trace(trade_sells, 1, 1) + else: + logger.warning("No trades found.") + return fig + + +def generate_graph( + pair: str, + data: pd.DataFrame, + trades: pd.DataFrame = None, + indicators1: List[str] = [], + indicators2: List[str] = [], +) -> go.Figure: + """ + Generate the graph from the data generated by Backtesting or from DB + Volume will always be ploted in row2, so Row 1 and 3 are to our disposal for custom indicators + :param pair: Pair to Display on the graph + :param data: OHLCV DataFrame containing indicators and buy/sell signals + :param trades: All trades created + :param indicators1: List containing Main plot indicators + :param indicators2: List containing Sub plot indicators + :return: None + """ + + # Define the graph + fig = tools.make_subplots( + rows=3, + cols=1, + shared_xaxes=True, + row_width=[1, 1, 4], + vertical_spacing=0.0001, + ) + fig['layout'].update(title=pair) + fig['layout']['yaxis1'].update(title='Price') + fig['layout']['yaxis2'].update(title='Volume') + fig['layout']['yaxis3'].update(title='Other') + fig['layout']['xaxis']['rangeslider'].update(visible=False) + + # Common information + candles = go.Candlestick( + x=data.date, + open=data.open, + high=data.high, + low=data.low, + close=data.close, + name='Price' + ) + fig.append_trace(candles, 1, 1) + + if 'buy' in data.columns: + df_buy = data[data['buy'] == 1] + if len(df_buy) > 0: + buys = go.Scatter( + x=df_buy.date, + y=df_buy.close, + mode='markers', + name='buy', + marker=dict( + symbol='triangle-up-dot', + size=9, + line=dict(width=1), + color='green', + ) + ) + fig.append_trace(buys, 1, 1) + else: + logger.warning("No buy-signals found.") + + if 'sell' in data.columns: + df_sell = data[data['sell'] == 1] + if len(df_sell) > 0: + sells = go.Scatter( + x=df_sell.date, + y=df_sell.close, + mode='markers', + name='sell', + marker=dict( + symbol='triangle-down-dot', + size=9, + line=dict(width=1), + color='red', + ) + ) + fig.append_trace(sells, 1, 1) + else: + logger.warning("No sell-signals found.") + + if 'bb_lowerband' in data and 'bb_upperband' in data: + bb_lower = go.Scattergl( + x=data.date, + y=data.bb_lowerband, + name='BB lower', + line={'color': 'rgba(255,255,255,0)'}, + ) + bb_upper = go.Scattergl( + x=data.date, + y=data.bb_upperband, + name='BB upper', + fill="tonexty", + fillcolor="rgba(0,176,246,0.2)", + line={'color': 'rgba(255,255,255,0)'}, + ) + fig.append_trace(bb_lower, 1, 1) + fig.append_trace(bb_upper, 1, 1) + + # Add indicators to main plot + fig = generate_row(fig=fig, row=1, indicators=indicators1, data=data) + + fig = plot_trades(fig, trades) + + # Volume goes to row 2 + volume = go.Bar( + x=data['date'], + y=data['volume'], + name='Volume' + ) + fig.append_trace(volume, 2, 1) + + # Add indicators to seperate row + fig = generate_row(fig=fig, row=3, indicators=indicators2, data=data) + + return fig + + +def generate_plot_file(fig, pair, ticker_interval) -> None: + """ + Generate a plot html file from pre populated fig plotly object + :param fig: Plotly Figure to plot + :param pair: Pair to plot (used as filename and Plot title) + :param ticker_interval: Used as part of the filename + :return: None + """ + logger.info('Generate plot file for %s', pair) + + pair_name = pair.replace("/", "_") + file_name = 'freqtrade-plot-' + pair_name + '-' + ticker_interval + '.html' + + Path("user_data/plots").mkdir(parents=True, exist_ok=True) + + plot(fig, filename=str(Path('user_data/plots').joinpath(file_name)), + auto_open=False) diff --git a/freqtrade/resolvers/__init__.py b/freqtrade/resolvers/__init__.py index 5cf6c616a..8f79349fe 100644 --- a/freqtrade/resolvers/__init__.py +++ b/freqtrade/resolvers/__init__.py @@ -1,5 +1,6 @@ from freqtrade.resolvers.iresolver import IResolver # noqa: F401 from freqtrade.resolvers.exchange_resolver import ExchangeResolver # noqa: F401 -from freqtrade.resolvers.hyperopt_resolver import HyperOptResolver # noqa: F401 +# Don't import HyperoptResolver to avoid loading the whole Optimize tree +# from freqtrade.resolvers.hyperopt_resolver import HyperOptResolver # noqa: F401 from freqtrade.resolvers.pairlist_resolver import PairListResolver # noqa: F401 from freqtrade.resolvers.strategy_resolver import StrategyResolver # noqa: F401 diff --git a/freqtrade/resolvers/exchange_resolver.py b/freqtrade/resolvers/exchange_resolver.py index 8d1845c71..25a86dd0e 100644 --- a/freqtrade/resolvers/exchange_resolver.py +++ b/freqtrade/resolvers/exchange_resolver.py @@ -22,6 +22,7 @@ class ExchangeResolver(IResolver): Load the custom class from config parameter :param config: configuration dictionary """ + exchange_name = exchange_name.title() try: self.exchange = self._load_exchange(exchange_name, kwargs={'config': config}) except ImportError: diff --git a/freqtrade/resolvers/hyperopt_resolver.py b/freqtrade/resolvers/hyperopt_resolver.py index e7683bc78..9333bb09a 100644 --- a/freqtrade/resolvers/hyperopt_resolver.py +++ b/freqtrade/resolvers/hyperopt_resolver.py @@ -32,6 +32,9 @@ class HyperOptResolver(IResolver): hyperopt_name = config.get('hyperopt') or DEFAULT_HYPEROPT self.hyperopt = self._load_hyperopt(hyperopt_name, extra_dir=config.get('hyperopt_path')) + # Assign ticker_interval to be used in hyperopt + self.hyperopt.__class__.ticker_interval = str(config['ticker_interval']) + if not hasattr(self.hyperopt, 'populate_buy_trend'): logger.warning("Custom Hyperopt does not provide populate_buy_trend. " "Using populate_buy_trend from DefaultStrategy.") diff --git a/freqtrade/rpc/api_server.py b/freqtrade/rpc/api_server.py new file mode 100644 index 000000000..711202b27 --- /dev/null +++ b/freqtrade/rpc/api_server.py @@ -0,0 +1,375 @@ +import logging +import threading +from datetime import date, datetime +from ipaddress import IPv4Address +from typing import Dict + +from arrow import Arrow +from flask import Flask, jsonify, request +from flask.json import JSONEncoder +from werkzeug.serving import make_server + +from freqtrade.__init__ import __version__ +from freqtrade.rpc.rpc import RPC, RPCException + +logger = logging.getLogger(__name__) + +BASE_URI = "/api/v1" + + +class ArrowJSONEncoder(JSONEncoder): + def default(self, obj): + try: + if isinstance(obj, Arrow): + return obj.for_json() + elif isinstance(obj, date): + return obj.strftime("%Y-%m-%d") + elif isinstance(obj, datetime): + return obj.strftime("%Y-%m-%d %H:%M:%S") + iterable = iter(obj) + except TypeError: + pass + else: + return list(iterable) + return JSONEncoder.default(self, obj) + + +class ApiServer(RPC): + """ + This class runs api server and provides rpc.rpc functionality to it + + This class starts a none blocking thread the api server runs within + """ + + def rpc_catch_errors(func): + + def func_wrapper(self, *args, **kwargs): + + try: + return func(self, *args, **kwargs) + except RPCException as e: + logger.exception("API Error calling %s: %s", func.__name__, e) + return self.rest_error(f"Error querying {func.__name__}: {e}") + + return func_wrapper + + def check_auth(self, username, password): + return (username == self._config['api_server'].get('username') and + password == self._config['api_server'].get('password')) + + def require_login(func): + + def func_wrapper(self, *args, **kwargs): + + auth = request.authorization + if auth and self.check_auth(auth.username, auth.password): + return func(self, *args, **kwargs) + else: + return jsonify({"error": "Unauthorized"}), 401 + + return func_wrapper + + def __init__(self, freqtrade) -> None: + """ + Init the api server, and init the super class RPC + :param freqtrade: Instance of a freqtrade bot + :return: None + """ + super().__init__(freqtrade) + + self._config = freqtrade.config + self.app = Flask(__name__) + self.app.json_encoder = ArrowJSONEncoder + + # Register application handling + self.register_rest_rpc_urls() + + thread = threading.Thread(target=self.run, daemon=True) + thread.start() + + def cleanup(self) -> None: + logger.info("Stopping API Server") + self.srv.shutdown() + + def run(self): + """ + Method that runs flask app in its own thread forever. + Section to handle configuration and running of the Rest server + also to check and warn if not bound to a loopback, warn on security risk. + """ + rest_ip = self._config['api_server']['listen_ip_address'] + rest_port = self._config['api_server']['listen_port'] + + logger.info(f'Starting HTTP Server at {rest_ip}:{rest_port}') + if not IPv4Address(rest_ip).is_loopback: + logger.warning("SECURITY WARNING - Local Rest Server listening to external connections") + logger.warning("SECURITY WARNING - This is insecure please set to your loopback," + "e.g 127.0.0.1 in config.json") + + if not self._config['api_server'].get('password'): + logger.warning("SECURITY WARNING - No password for local REST Server defined. " + "Please make sure that this is intentional!") + + # Run the Server + logger.info('Starting Local Rest Server.') + try: + self.srv = make_server(rest_ip, rest_port, self.app) + self.srv.serve_forever() + except Exception: + logger.exception("Api server failed to start.") + logger.info('Local Rest Server started.') + + def send_msg(self, msg: Dict[str, str]) -> None: + """ + We don't push to endpoints at the moment. + Take a look at webhooks for that functionality. + """ + pass + + def rest_dump(self, return_value): + """ Helper function to jsonify object for a webserver """ + return jsonify(return_value) + + def rest_error(self, error_msg): + return jsonify({"error": error_msg}), 502 + + def register_rest_rpc_urls(self): + """ + Registers flask app URLs that are calls to functonality in rpc.rpc. + + First two arguments passed are /URL and 'Label' + Label can be used as a shortcut when refactoring + :return: + """ + self.app.register_error_handler(404, self.page_not_found) + + # Actions to control the bot + self.app.add_url_rule(f'{BASE_URI}/start', 'start', + view_func=self._start, methods=['POST']) + self.app.add_url_rule(f'{BASE_URI}/stop', 'stop', view_func=self._stop, methods=['POST']) + self.app.add_url_rule(f'{BASE_URI}/stopbuy', 'stopbuy', + view_func=self._stopbuy, methods=['POST']) + self.app.add_url_rule(f'{BASE_URI}/reload_conf', 'reload_conf', + view_func=self._reload_conf, methods=['POST']) + # Info commands + self.app.add_url_rule(f'{BASE_URI}/balance', 'balance', + view_func=self._balance, methods=['GET']) + self.app.add_url_rule(f'{BASE_URI}/count', 'count', view_func=self._count, methods=['GET']) + self.app.add_url_rule(f'{BASE_URI}/daily', 'daily', view_func=self._daily, methods=['GET']) + self.app.add_url_rule(f'{BASE_URI}/edge', 'edge', view_func=self._edge, methods=['GET']) + self.app.add_url_rule(f'{BASE_URI}/profit', 'profit', + view_func=self._profit, methods=['GET']) + self.app.add_url_rule(f'{BASE_URI}/performance', 'performance', + view_func=self._performance, methods=['GET']) + self.app.add_url_rule(f'{BASE_URI}/status', 'status', + view_func=self._status, methods=['GET']) + self.app.add_url_rule(f'{BASE_URI}/version', 'version', + view_func=self._version, methods=['GET']) + + # Combined actions and infos + self.app.add_url_rule(f'{BASE_URI}/blacklist', 'blacklist', view_func=self._blacklist, + methods=['GET', 'POST']) + self.app.add_url_rule(f'{BASE_URI}/whitelist', 'whitelist', view_func=self._whitelist, + methods=['GET']) + self.app.add_url_rule(f'{BASE_URI}/forcebuy', 'forcebuy', + view_func=self._forcebuy, methods=['POST']) + self.app.add_url_rule(f'{BASE_URI}/forcesell', 'forcesell', view_func=self._forcesell, + methods=['POST']) + + # TODO: Implement the following + # help (?) + + @require_login + def page_not_found(self, error): + """ + Return "404 not found", 404. + """ + return self.rest_dump({ + 'status': 'error', + 'reason': f"There's no API call for {request.base_url}.", + 'code': 404 + }), 404 + + @require_login + @rpc_catch_errors + def _start(self): + """ + Handler for /start. + Starts TradeThread in bot if stopped. + """ + msg = self._rpc_start() + return self.rest_dump(msg) + + @require_login + @rpc_catch_errors + def _stop(self): + """ + Handler for /stop. + Stops TradeThread in bot if running + """ + msg = self._rpc_stop() + return self.rest_dump(msg) + + @require_login + @rpc_catch_errors + def _stopbuy(self): + """ + Handler for /stopbuy. + Sets max_open_trades to 0 and gracefully sells all open trades + """ + msg = self._rpc_stopbuy() + return self.rest_dump(msg) + + @require_login + @rpc_catch_errors + def _version(self): + """ + Prints the bot's version + """ + return self.rest_dump({"version": __version__}) + + @require_login + @rpc_catch_errors + def _reload_conf(self): + """ + Handler for /reload_conf. + Triggers a config file reload + """ + msg = self._rpc_reload_conf() + return self.rest_dump(msg) + + @require_login + @rpc_catch_errors + def _count(self): + """ + Handler for /count. + Returns the number of trades running + """ + msg = self._rpc_count() + return self.rest_dump(msg) + + @require_login + @rpc_catch_errors + def _daily(self): + """ + Returns the last X days trading stats summary. + + :return: stats + """ + timescale = request.args.get('timescale', 7) + timescale = int(timescale) + + stats = self._rpc_daily_profit(timescale, + self._config['stake_currency'], + self._config['fiat_display_currency'] + ) + + return self.rest_dump(stats) + + @require_login + @rpc_catch_errors + def _edge(self): + """ + Returns information related to Edge. + :return: edge stats + """ + stats = self._rpc_edge() + + return self.rest_dump(stats) + + @require_login + @rpc_catch_errors + def _profit(self): + """ + Handler for /profit. + + Returns a cumulative profit statistics + :return: stats + """ + logger.info("LocalRPC - Profit Command Called") + + stats = self._rpc_trade_statistics(self._config['stake_currency'], + self._config['fiat_display_currency'] + ) + + return self.rest_dump(stats) + + @require_login + @rpc_catch_errors + def _performance(self): + """ + Handler for /performance. + + Returns a cumulative performance statistics + :return: stats + """ + logger.info("LocalRPC - performance Command Called") + + stats = self._rpc_performance() + + return self.rest_dump(stats) + + @require_login + @rpc_catch_errors + def _status(self): + """ + Handler for /status. + + Returns the current status of the trades in json format + """ + results = self._rpc_trade_status() + return self.rest_dump(results) + + @require_login + @rpc_catch_errors + def _balance(self): + """ + Handler for /balance. + + Returns the current status of the trades in json format + """ + results = self._rpc_balance(self._config.get('fiat_display_currency', '')) + return self.rest_dump(results) + + @require_login + @rpc_catch_errors + def _whitelist(self): + """ + Handler for /whitelist. + """ + results = self._rpc_whitelist() + return self.rest_dump(results) + + @require_login + @rpc_catch_errors + def _blacklist(self): + """ + Handler for /blacklist. + """ + add = request.json.get("blacklist", None) if request.method == 'POST' else None + results = self._rpc_blacklist(add) + return self.rest_dump(results) + + @require_login + @rpc_catch_errors + def _forcebuy(self): + """ + Handler for /forcebuy. + """ + asset = request.json.get("pair") + price = request.json.get("price", None) + trade = self._rpc_forcebuy(asset, price) + if trade: + return self.rest_dump(trade.to_json()) + else: + return self.rest_dump({"status": f"Error buying pair {asset}."}) + + @require_login + @rpc_catch_errors + def _forcesell(self): + """ + Handler for /forcesell. + """ + tradeid = request.json.get("tradeid") + results = self._rpc_forcesell(tradeid) + return self.rest_dump(results) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index aac419fe1..048ebec63 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -48,6 +48,11 @@ class RPCException(Exception): def __str__(self): return self.message + def __json__(self): + return { + 'msg': self.message + } + class RPC(object): """ @@ -100,28 +105,17 @@ class RPC(object): current_profit = trade.calc_profit_percent(current_rate) fmt_close_profit = (f'{round(trade.close_profit * 100, 2):.2f}%' if trade.close_profit else None) - results.append(dict( - trade_id=trade.id, - pair=trade.pair, + trade_dict = trade.to_json() + trade_dict.update(dict( base_currency=self._freqtrade.config['stake_currency'], - date=arrow.get(trade.open_date), - open_rate=trade.open_rate, - close_rate=trade.close_rate, - current_rate=current_rate, - amount=round(trade.amount, 8), - stake_amount=round(trade.stake_amount, 8), close_profit=fmt_close_profit, + current_rate=current_rate, current_profit=round(current_profit * 100, 2), - stop_loss=trade.stop_loss, - stop_loss_pct=(trade.stop_loss_pct * 100) - if trade.stop_loss_pct else None, - initial_stop_loss=trade.initial_stop_loss, - initial_stop_loss_pct=(trade.initial_stop_loss_pct * 100) - if trade.initial_stop_loss_pct else None, open_order='({} {} rem={:.8f})'.format( order['type'], order['side'], order['remaining'] ) if order else None, )) + results.append(trade_dict) return results def _rpc_status_table(self) -> DataFrame: @@ -287,11 +281,12 @@ class RPC(object): rate = 1.0 else: try: - if coin == 'USDT': - rate = 1.0 / self._freqtrade.get_sell_rate('BTC/USDT', False) + if coin in('USDT', 'USD', 'EUR'): + rate = 1.0 / self._freqtrade.get_sell_rate('BTC/' + coin, False) else: rate = self._freqtrade.get_sell_rate(coin + '/BTC', False) except (TemporaryError, DependencyException): + logger.warning(f" Could not get rate for pair {coin}.") continue est_btc: float = rate * balance['total'] total = total + est_btc @@ -346,7 +341,7 @@ class RPC(object): return {'status': 'No more buy will occur from now. Run /reload_conf to reset.'} - def _rpc_forcesell(self, trade_id) -> None: + def _rpc_forcesell(self, trade_id) -> Dict[str, str]: """ Handler for forcesell . Sells the given trade at current price @@ -386,7 +381,7 @@ class RPC(object): for trade in Trade.get_open_trades(): _exec_forcesell(trade) Trade.session.flush() - return + return {'result': 'Created sell orders for all open trades.'} # Query for trade trade = Trade.query.filter( @@ -401,6 +396,7 @@ class RPC(object): _exec_forcesell(trade) Trade.session.flush() + return {'result': f'Created sell order for trade {trade_id}.'} def _rpc_forcebuy(self, pair: str, price: Optional[float]) -> Optional[Trade]: """ @@ -474,7 +470,7 @@ class RPC(object): } return res - def _rpc_blacklist(self, add: List[str]) -> Dict: + def _rpc_blacklist(self, add: List[str] = None) -> Dict: """ Returns the currently active blacklist""" if add: stake_currency = self._freqtrade.config.get('stake_currency') diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index 7f0d0a5d4..fad532aa0 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -29,6 +29,12 @@ class RPCManager(object): from freqtrade.rpc.webhook import Webhook self.registered_modules.append(Webhook(freqtrade)) + # Enable local rest api server for cmd line control + if freqtrade.config.get('api_server', {}).get('enabled', False): + logger.info('Enabling rpc.api_server') + from freqtrade.rpc.api_server import ApiServer + self.registered_modules.append(ApiServer(freqtrade)) + def cleanup(self) -> None: """ Stops all enabled rpc modules """ logger.info('Cleaning up rpc modules ...') diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index c61193d29..3eb060074 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -132,7 +132,7 @@ class Telegram(RPC): msg['stake_amount_fiat'] = 0 message = ("*{exchange}:* Buying {pair}\n" - "with limit `{limit:.8f}\n" + "at rate `{limit:.8f}\n" "({stake_amount:.6f} {stake_currency}").format(**msg) if msg.get('fiat_currency', None): @@ -144,7 +144,7 @@ class Telegram(RPC): msg['profit_percent'] = round(msg['profit_percent'] * 100, 2) message = ("*{exchange}:* Selling {pair}\n" - "*Limit:* `{limit:.8f}`\n" + "*Rate:* `{limit:.8f}`\n" "*Amount:* `{amount:.8f}`\n" "*Open Rate:* `{open_rate:.8f}`\n" "*Current Rate:* `{current_rate:.8f}`\n" @@ -193,14 +193,11 @@ class Telegram(RPC): try: results = self._rpc_trade_status() - # pre format data - for result in results: - result['date'] = result['date'].humanize() messages = [] for r in results: lines = [ - "*Trade ID:* `{trade_id}` `(since {date})`", + "*Trade ID:* `{trade_id}` `(since {open_date_hum})`", "*Current Pair:* {pair}", "*Amount:* `{amount} ({stake_amount} {base_currency})`", "*Open Rate:* `{open_rate:.8f}`", @@ -413,7 +410,9 @@ class Telegram(RPC): trade_id = update.message.text.replace('/forcesell', '').strip() try: - self._rpc_forcesell(trade_id) + msg = self._rpc_forcesell(trade_id) + self._send_msg('Forcesell Result: `{result}`'.format(**msg), bot=bot) + except RPCException as e: self._send_msg(str(e), bot=bot) diff --git a/freqtrade/state.py b/freqtrade/state.py index b69c26cb5..ce2683a77 100644 --- a/freqtrade/state.py +++ b/freqtrade/state.py @@ -18,11 +18,11 @@ class State(Enum): class RunMode(Enum): """ Bot running mode (backtest, hyperopt, ...) - can be "live", "dry-run", "backtest", "edgecli", "hyperopt". + can be "live", "dry-run", "backtest", "edge", "hyperopt". """ LIVE = "live" DRY_RUN = "dry_run" BACKTEST = "backtest" - EDGECLI = "edgecli" + EDGE = "edge" HYPEROPT = "hyperopt" OTHER = "other" # Used for plotting scripts and test diff --git a/freqtrade/strategy/__init__.py b/freqtrade/strategy/__init__.py index b29e26ef9..c62bfe5dc 100644 --- a/freqtrade/strategy/__init__.py +++ b/freqtrade/strategy/__init__.py @@ -6,6 +6,7 @@ from freqtrade.strategy.interface import IStrategy # Import Default-Strategy to have hyperopt correctly resolve from freqtrade.strategy.default_strategy import DefaultStrategy # noqa: F401 + logger = logging.getLogger(__name__) @@ -16,7 +17,6 @@ def import_strategy(strategy: IStrategy, config: dict) -> IStrategy: """ # Copy all attributes from base class and class - comb = {**strategy.__class__.__dict__, **strategy.__dict__} # Delete '_abc_impl' from dict as deepcopy fails on 3.7 with @@ -26,6 +26,7 @@ def import_strategy(strategy: IStrategy, config: dict) -> IStrategy: del comb['_abc_impl'] attr = deepcopy(comb) + # Adjust module name attr['__module__'] = 'freqtrade.strategy' diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 646bd2a94..949a88b91 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -13,10 +13,11 @@ import arrow from pandas import DataFrame from freqtrade.data.dataprovider import DataProvider -from freqtrade.misc import timeframe_to_minutes +from freqtrade.exchange import timeframe_to_minutes from freqtrade.persistence import Trade from freqtrade.wallets import Wallets + logger = logging.getLogger(__name__) @@ -157,7 +158,7 @@ class IStrategy(ABC): """ Parses the given ticker history and returns a populated DataFrame add several TA indicators and buy signal to it - :return DataFrame with ticker data and indicator data + :return: DataFrame with ticker data and indicator data """ pair = str(metadata.get('pair')) @@ -307,14 +308,16 @@ class IStrategy(ABC): if trailing_stop: # trailing stoploss handling - sl_offset = self.config.get('trailing_stop_positive_offset') or 0.0 tsl_only_offset = self.config.get('trailing_only_offset_is_reached', False) + # Make sure current_profit is calculated using high for backtesting. + high_profit = current_profit if not high else trade.calc_profit_percent(high) + # Don't update stoploss if trailing_only_offset_is_reached is true. - if not (tsl_only_offset and current_profit < sl_offset): + if not (tsl_only_offset and high_profit < sl_offset): # Specific handling for trailing_stop_positive - if 'trailing_stop_positive' in self.config and current_profit > sl_offset: + if 'trailing_stop_positive' in self.config and high_profit > sl_offset: # Ignore mypy error check in configuration that this is a float stop_loss_value = self.config.get('trailing_stop_positive') # type: ignore logger.debug(f"using positive stop loss: {stop_loss_value} " @@ -328,8 +331,9 @@ class IStrategy(ABC): (not self.order_types.get('stoploss_on_exchange'))): selltype = SellType.STOP_LOSS - # If Trailing stop (and max-rate did move above open rate) - if trailing_stop and trade.open_rate != trade.max_rate: + + # If initial stoploss is not the same as current one then it is trailing. + if trade.initial_stop_loss != trade.stop_loss: selltype = SellType.TRAILING_STOP_LOSS logger.debug( f"HIT STOP: current price at {current_rate:.6f}, " @@ -347,7 +351,7 @@ class IStrategy(ABC): """ Based an earlier trade and current price and ROI configuration, decides whether bot should sell. Requires current_profit to be in percent!! - :return True if bot should sell at current rate + :return: True if bot should sell at current rate """ # Check if time matches and current rate is above threshold @@ -376,6 +380,7 @@ class IStrategy(ABC): :param metadata: Additional information, like the currently traded pair :return: a Dataframe with all mandatory indicators for the strategies """ + logger.debug(f"Populating indicators for pair {metadata.get('pair')}.") if self._populate_fun_len == 2: warnings.warn("deprecated - check out the Sample strategy to see " "the current function headers!", DeprecationWarning) @@ -391,6 +396,7 @@ class IStrategy(ABC): :param pair: Additional information, like the currently traded pair :return: DataFrame with buy column """ + logger.debug(f"Populating buy signals for pair {metadata.get('pair')}.") if self._buy_fun_len == 2: warnings.warn("deprecated - check out the Sample strategy to see " "the current function headers!", DeprecationWarning) @@ -406,6 +412,7 @@ class IStrategy(ABC): :param pair: Additional information, like the currently traded pair :return: DataFrame with sell column """ + logger.debug(f"Populating sell signals for pair {metadata.get('pair')}.") if self._sell_fun_len == 2: warnings.warn("deprecated - check out the Sample strategy to see " "the current function headers!", DeprecationWarning) diff --git a/freqtrade/tests/conftest.py b/freqtrade/tests/conftest.py index 0bff1d5e9..eb2a8600f 100644 --- a/freqtrade/tests/conftest.py +++ b/freqtrade/tests/conftest.py @@ -2,15 +2,19 @@ import json import logging import re +from copy import deepcopy from datetime import datetime from functools import reduce +from pathlib import Path +from typing import List from unittest.mock import MagicMock, PropertyMock import arrow import pytest from telegram import Chat, Message, Update -from freqtrade import constants +from freqtrade import constants, persistence +from freqtrade.arguments import Arguments from freqtrade.data.converter import parse_ticker_dataframe from freqtrade.edge import Edge, PairInfo from freqtrade.exchange import Exchange @@ -35,6 +39,10 @@ def log_has_re(line, logs): False) +def get_args(args) -> List[str]: + return Arguments(args, '').get_parsed_arg() + + def patch_exchange(mocker, api_mock=None, id='bittrex') -> None: mocker.patch('freqtrade.exchange.Exchange._load_markets', MagicMock(return_value={})) mocker.patch('freqtrade.exchange.Exchange.validate_pairs', MagicMock()) @@ -53,7 +61,7 @@ def get_patched_exchange(mocker, config, api_mock=None, id='bittrex') -> Exchang patch_exchange(mocker, api_mock, id) config["exchange"]["name"] = id try: - exchange = ExchangeResolver(id.title(), config).exchange + exchange = ExchangeResolver(id, config).exchange except ImportError: exchange = Exchange(config) return exchange @@ -96,22 +104,44 @@ def patch_freqtradebot(mocker, config) -> None: :return: None """ mocker.patch('freqtrade.freqtradebot.RPCManager', MagicMock()) - mocker.patch('freqtrade.freqtradebot.persistence.init', MagicMock()) + persistence.init(config['db_url']) patch_exchange(mocker, None) mocker.patch('freqtrade.freqtradebot.RPCManager._init', MagicMock()) mocker.patch('freqtrade.freqtradebot.RPCManager.send_msg', MagicMock()) def get_patched_freqtradebot(mocker, config) -> FreqtradeBot: + """ + This function patches _init_modules() to not call dependencies + :param mocker: a Mocker object to apply patches + :param config: Config to pass to the bot + :return: FreqtradeBot + """ patch_freqtradebot(mocker, config) return FreqtradeBot(config) def get_patched_worker(mocker, config) -> Worker: + """ + This function patches _init_modules() to not call dependencies + :param mocker: a Mocker object to apply patches + :param config: Config to pass to the bot + :return: Worker + """ patch_freqtradebot(mocker, config) return Worker(args=None, config=config) +def patch_get_signal(freqtrade: FreqtradeBot, value=(True, False)) -> None: + """ + :param mocker: mocker to patch IStrategy class + :param value: which value IStrategy.get_signal() must return + :return: None + """ + freqtrade.strategy.get_signal = lambda e, s, t: value + freqtrade.exchange.refresh_latest_ohlcv = lambda p: None + + @pytest.fixture(autouse=True) def patch_coinmarketcap(mocker) -> None: """ @@ -134,6 +164,11 @@ def patch_coinmarketcap(mocker) -> None: ) +@pytest.fixture(scope='function') +def init_persistence(default_conf): + persistence.init(default_conf['db_url'], default_conf['dry_run']) + + @pytest.fixture(scope="function") def default_conf(): """ Returns validated configuration suitable for most tests """ @@ -639,7 +674,7 @@ def ticker_history_list(): @pytest.fixture def ticker_history(ticker_history_list): - return parse_ticker_dataframe(ticker_history_list, "5m", True) + return parse_ticker_dataframe(ticker_history_list, "5m", pair="UNITTEST/BTC", fill_missing=True) @pytest.fixture @@ -843,8 +878,9 @@ def tickers(): @pytest.fixture def result(): - with open('freqtrade/tests/testdata/UNITTEST_BTC-1m.json') as data_file: - return parse_ticker_dataframe(json.load(data_file), '1m', True) + with Path('freqtrade/tests/testdata/UNITTEST_BTC-1m.json').open('r') as data_file: + return parse_ticker_dataframe(json.load(data_file), '1m', pair="UNITTEST/BTC", + fill_missing=True) # FIX: # Create an fixture/function @@ -942,9 +978,10 @@ def buy_order_fee(): @pytest.fixture(scope="function") def edge_conf(default_conf): - default_conf['max_open_trades'] = -1 - default_conf['stake_amount'] = constants.UNLIMITED_STAKE_AMOUNT - default_conf['edge'] = { + conf = deepcopy(default_conf) + conf['max_open_trades'] = -1 + conf['stake_amount'] = constants.UNLIMITED_STAKE_AMOUNT + conf['edge'] = { "enabled": True, "process_throttle_secs": 1800, "calculate_since_number_of_days": 14, @@ -960,4 +997,40 @@ def edge_conf(default_conf): "remove_pumps": False } - return default_conf + return conf + + +@pytest.fixture +def rpc_balance(): + return { + 'BTC': { + 'total': 12.0, + 'free': 12.0, + 'used': 0.0 + }, + 'ETH': { + 'total': 0.0, + 'free': 0.0, + 'used': 0.0 + }, + 'USDT': { + 'total': 10000.0, + 'free': 10000.0, + 'used': 0.0 + }, + 'LTC': { + 'total': 10.0, + 'free': 10.0, + 'used': 0.0 + }, + 'XRP': { + 'total': 1.0, + 'free': 1.0, + 'used': 0.0 + }, + 'EUR': { + 'total': 10.0, + 'free': 10.0, + 'used': 0.0 + }, + } diff --git a/freqtrade/tests/data/test_btanalysis.py b/freqtrade/tests/data/test_btanalysis.py index dd7cbe0d9..1cb48393d 100644 --- a/freqtrade/tests/data/test_btanalysis.py +++ b/freqtrade/tests/data/test_btanalysis.py @@ -1,8 +1,15 @@ -import pytest -from pandas import DataFrame +from unittest.mock import MagicMock -from freqtrade.data.btanalysis import BT_DATA_COLUMNS, load_backtest_data -from freqtrade.data.history import make_testdata_path +from arrow import Arrow +import pytest +from pandas import DataFrame, to_datetime + +from freqtrade.arguments import TimeRange +from freqtrade.data.btanalysis import (BT_DATA_COLUMNS, + extract_trades_of_period, + load_backtest_data, load_trades_from_db) +from freqtrade.data.history import load_pair_history, make_testdata_path +from freqtrade.tests.test_persistence import create_mock_trades def test_load_backtest_data(): @@ -19,3 +26,51 @@ def test_load_backtest_data(): with pytest.raises(ValueError, match=r"File .* does not exist\."): load_backtest_data(str("filename") + "nofile") + + +@pytest.mark.usefixtures("init_persistence") +def test_load_trades_db(default_conf, fee, mocker): + + create_mock_trades(fee) + # remove init so it does not init again + init_mock = mocker.patch('freqtrade.persistence.init', MagicMock()) + + trades = load_trades_from_db(db_url=default_conf['db_url']) + assert init_mock.call_count == 1 + assert len(trades) == 3 + assert isinstance(trades, DataFrame) + assert "pair" in trades.columns + assert "open_time" in trades.columns + + +def test_extract_trades_of_period(): + pair = "UNITTEST/BTC" + timerange = TimeRange(None, 'line', 0, -1000) + + data = load_pair_history(pair=pair, ticker_interval='1m', + datadir=None, timerange=timerange) + + # timerange = 2017-11-14 06:07 - 2017-11-14 22:58:00 + trades = DataFrame( + {'pair': [pair, pair, pair, pair], + 'profit_percent': [0.0, 0.1, -0.2, -0.5], + 'profit_abs': [0.0, 1, -2, -5], + 'open_time': to_datetime([Arrow(2017, 11, 13, 15, 40, 0).datetime, + Arrow(2017, 11, 14, 9, 41, 0).datetime, + Arrow(2017, 11, 14, 14, 20, 0).datetime, + Arrow(2017, 11, 15, 3, 40, 0).datetime, + ], utc=True + ), + 'close_time': to_datetime([Arrow(2017, 11, 13, 16, 40, 0).datetime, + Arrow(2017, 11, 14, 10, 41, 0).datetime, + Arrow(2017, 11, 14, 15, 25, 0).datetime, + Arrow(2017, 11, 15, 3, 55, 0).datetime, + ], utc=True) + }) + trades1 = extract_trades_of_period(data, trades) + # First and last trade are dropped as they are out of range + assert len(trades1) == 2 + assert trades1.iloc[0].open_time == Arrow(2017, 11, 14, 9, 41, 0).datetime + assert trades1.iloc[0].close_time == Arrow(2017, 11, 14, 10, 41, 0).datetime + assert trades1.iloc[-1].open_time == Arrow(2017, 11, 14, 14, 20, 0).datetime + assert trades1.iloc[-1].close_time == Arrow(2017, 11, 14, 15, 25, 0).datetime diff --git a/freqtrade/tests/data/test_converter.py b/freqtrade/tests/data/test_converter.py index 46d564003..f68224e0e 100644 --- a/freqtrade/tests/data/test_converter.py +++ b/freqtrade/tests/data/test_converter.py @@ -2,8 +2,7 @@ import logging from freqtrade.data.converter import parse_ticker_dataframe, ohlcv_fill_up_missing_data -from freqtrade.data.history import load_pair_history -from freqtrade.optimize import validate_backtest_data, get_timeframe +from freqtrade.data.history import load_pair_history, validate_backtest_data, get_timeframe from freqtrade.tests.conftest import log_has @@ -16,7 +15,8 @@ def test_parse_ticker_dataframe(ticker_history_list, caplog): caplog.set_level(logging.DEBUG) # Test file with BV data - dataframe = parse_ticker_dataframe(ticker_history_list, '5m', fill_missing=True) + dataframe = parse_ticker_dataframe(ticker_history_list, '5m', + pair="UNITTEST/BTC", fill_missing=True) assert dataframe.columns.tolist() == columns assert log_has('Parsing tickerlist to dataframe', caplog.record_tuples) @@ -28,18 +28,19 @@ def test_ohlcv_fill_up_missing_data(caplog): pair='UNITTEST/BTC', fill_up_missing=False) caplog.set_level(logging.DEBUG) - data2 = ohlcv_fill_up_missing_data(data, '1m') + data2 = ohlcv_fill_up_missing_data(data, '1m', 'UNITTEST/BTC') assert len(data2) > len(data) # Column names should not change assert (data.columns == data2.columns).all() - assert log_has(f"Missing data fillup: before: {len(data)} - after: {len(data2)}", + assert log_has(f"Missing data fillup for UNITTEST/BTC: before: " + f"{len(data)} - after: {len(data2)}", caplog.record_tuples) # Test fillup actually fixes invalid backtest data min_date, max_date = get_timeframe({'UNITTEST/BTC': data}) - assert validate_backtest_data({'UNITTEST/BTC': data}, min_date, max_date, 1) - assert not validate_backtest_data({'UNITTEST/BTC': data2}, min_date, max_date, 1) + assert validate_backtest_data(data, 'UNITTEST/BTC', min_date, max_date, 1) + assert not validate_backtest_data(data2, 'UNITTEST/BTC', min_date, max_date, 1) def test_ohlcv_fill_up_missing_data2(caplog): @@ -79,10 +80,10 @@ def test_ohlcv_fill_up_missing_data2(caplog): ] # Generate test-data without filling missing - data = parse_ticker_dataframe(ticks, ticker_interval, fill_missing=False) + data = parse_ticker_dataframe(ticks, ticker_interval, pair="UNITTEST/BTC", fill_missing=False) assert len(data) == 3 caplog.set_level(logging.DEBUG) - data2 = ohlcv_fill_up_missing_data(data, ticker_interval) + data2 = ohlcv_fill_up_missing_data(data, ticker_interval, "UNITTEST/BTC") assert len(data2) == 4 # 3rd candle has been filled row = data2.loc[2, :] @@ -95,5 +96,55 @@ def test_ohlcv_fill_up_missing_data2(caplog): # Column names should not change assert (data.columns == data2.columns).all() - assert log_has(f"Missing data fillup: before: {len(data)} - after: {len(data2)}", + assert log_has(f"Missing data fillup for UNITTEST/BTC: before: " + f"{len(data)} - after: {len(data2)}", caplog.record_tuples) + + +def test_ohlcv_drop_incomplete(caplog): + ticker_interval = '1d' + ticks = [[ + 1559750400000, # 2019-06-04 + 8.794e-05, # open + 8.948e-05, # high + 8.794e-05, # low + 8.88e-05, # close + 2255, # volume (in quote currency) + ], + [ + 1559836800000, # 2019-06-05 + 8.88e-05, + 8.942e-05, + 8.88e-05, + 8.893e-05, + 9911, + ], + [ + 1559923200000, # 2019-06-06 + 8.891e-05, + 8.893e-05, + 8.875e-05, + 8.877e-05, + 2251 + ], + [ + 1560009600000, # 2019-06-07 + 8.877e-05, + 8.883e-05, + 8.895e-05, + 8.817e-05, + 123551 + ] + ] + caplog.set_level(logging.DEBUG) + data = parse_ticker_dataframe(ticks, ticker_interval, pair="UNITTEST/BTC", + fill_missing=False, drop_incomplete=False) + assert len(data) == 4 + assert not log_has("Dropping last candle", caplog.record_tuples) + + # Drop last candle + data = parse_ticker_dataframe(ticks, ticker_interval, pair="UNITTEST/BTC", + fill_missing=False, drop_incomplete=True) + assert len(data) == 3 + + assert log_has("Dropping last candle", caplog.record_tuples) diff --git a/freqtrade/tests/data/test_history.py b/freqtrade/tests/data/test_history.py index c0b1cade3..46bcf06c4 100644 --- a/freqtrade/tests/data/test_history.py +++ b/freqtrade/tests/data/test_history.py @@ -2,24 +2,27 @@ import json import os -from pathlib import Path import uuid +from pathlib import Path from shutil import copyfile +from unittest.mock import MagicMock import arrow -from pandas import DataFrame import pytest +from pandas import DataFrame from freqtrade import OperationalException from freqtrade.arguments import TimeRange from freqtrade.data import history from freqtrade.data.history import (download_pair_history, load_cached_data_for_updating, - load_tickerdata_file, - make_testdata_path, + load_tickerdata_file, make_testdata_path, trim_tickerlist) +from freqtrade.exchange import timeframe_to_minutes from freqtrade.misc import file_dump_json -from freqtrade.tests.conftest import get_patched_exchange, log_has +from freqtrade.strategy.default_strategy import DefaultStrategy +from freqtrade.tests.conftest import (get_patched_exchange, log_has, + patch_exchange) # Change this if modifying UNITTEST/BTC testdatafile _BTC_UNITTEST_LENGTH = 13681 @@ -59,7 +62,11 @@ def _clean_test_file(file: str) -> None: def test_load_data_30min_ticker(mocker, caplog, default_conf) -> None: ld = history.load_pair_history(pair='UNITTEST/BTC', ticker_interval='30m', datadir=None) assert isinstance(ld, DataFrame) - assert not log_has('Download the pair: "UNITTEST/BTC", Interval: 30m', caplog.record_tuples) + assert not log_has( + 'Download history data for pair: "UNITTEST/BTC", interval: 30m ' + 'and store in None.', + caplog.record_tuples + ) def test_load_data_7min_ticker(mocker, caplog, default_conf) -> None: @@ -67,8 +74,11 @@ def test_load_data_7min_ticker(mocker, caplog, default_conf) -> None: assert not isinstance(ld, DataFrame) assert ld is None assert log_has( - 'No data for pair: "UNITTEST/BTC", Interval: 7m. ' - 'Use --refresh-pairs-cached to download the data', caplog.record_tuples) + 'No history data for pair: "UNITTEST/BTC", interval: 7m. ' + 'Use --refresh-pairs-cached option or download_backtest_data.py ' + 'script to download the data', + caplog.record_tuples + ) def test_load_data_1min_ticker(ticker_history, mocker, caplog) -> None: @@ -77,7 +87,11 @@ def test_load_data_1min_ticker(ticker_history, mocker, caplog) -> None: _backup_file(file, copy_file=True) history.load_data(datadir=None, ticker_interval='1m', pairs=['UNITTEST/BTC']) assert os.path.isfile(file) is True - assert not log_has('Download the pair: "UNITTEST/BTC", Interval: 1m', caplog.record_tuples) + assert not log_has( + 'Download history data for pair: "UNITTEST/BTC", interval: 1m ' + 'and store in None.', + caplog.record_tuples + ) _clean_test_file(file) @@ -96,9 +110,12 @@ def test_load_data_with_new_pair_1min(ticker_history_list, mocker, caplog, defau refresh_pairs=False, pair='MEME/BTC') assert os.path.isfile(file) is False - assert log_has('No data for pair: "MEME/BTC", Interval: 1m. ' - 'Use --refresh-pairs-cached to download the data', - caplog.record_tuples) + assert log_has( + 'No history data for pair: "MEME/BTC", interval: 1m. ' + 'Use --refresh-pairs-cached option or download_backtest_data.py ' + 'script to download the data', + caplog.record_tuples + ) # download a new pair if refresh_pairs is set history.load_pair_history(datadir=None, @@ -107,7 +124,11 @@ def test_load_data_with_new_pair_1min(ticker_history_list, mocker, caplog, defau exchange=exchange, pair='MEME/BTC') assert os.path.isfile(file) is True - assert log_has('Download the pair: "MEME/BTC", Interval: 1m', caplog.record_tuples) + assert log_has( + 'Download history data for pair: "MEME/BTC", interval: 1m ' + 'and store in None.', + caplog.record_tuples + ) with pytest.raises(OperationalException, match=r'Exchange needs to be initialized when.*'): history.load_pair_history(datadir=None, ticker_interval='1m', @@ -117,6 +138,31 @@ def test_load_data_with_new_pair_1min(ticker_history_list, mocker, caplog, defau _clean_test_file(file) +def test_load_data_live(default_conf, mocker, caplog) -> None: + refresh_mock = MagicMock() + mocker.patch("freqtrade.exchange.Exchange.refresh_latest_ohlcv", refresh_mock) + exchange = get_patched_exchange(mocker, default_conf) + + history.load_data(datadir=None, ticker_interval='5m', + pairs=['UNITTEST/BTC', 'UNITTEST2/BTC'], + live=True, + exchange=exchange) + assert refresh_mock.call_count == 1 + assert len(refresh_mock.call_args_list[0][0][0]) == 2 + assert log_has('Live: Downloading data for all defined pairs ...', caplog.record_tuples) + + +def test_load_data_live_noexchange(default_conf, mocker, caplog) -> None: + + with pytest.raises(OperationalException, + match=r'Exchange needs to be initialized when using live data.'): + history.load_data(datadir=None, ticker_interval='5m', + pairs=['UNITTEST/BTC', 'UNITTEST2/BTC'], + exchange=None, + live=True, + ) + + def test_testdata_path() -> None: assert str(Path('freqtrade') / 'tests' / 'testdata') in str(make_testdata_path(None)) @@ -287,7 +333,7 @@ def test_download_pair_history2(mocker, default_conf) -> None: def test_download_backtesting_data_exception(ticker_history, mocker, caplog, default_conf) -> None: mocker.patch('freqtrade.exchange.Exchange.get_history', - side_effect=BaseException('File Error')) + side_effect=Exception('File Error')) exchange = get_patched_exchange(mocker, default_conf) @@ -302,7 +348,11 @@ def test_download_backtesting_data_exception(ticker_history, mocker, caplog, def # clean files freshly downloaded _clean_test_file(file1_1) _clean_test_file(file1_5) - assert log_has('Failed to download the pair: "MEME/BTC", Interval: 1m', caplog.record_tuples) + assert log_has( + 'Failed to download history data for pair: "MEME/BTC", interval: 1m. ' + 'Error: File Error', + caplog.record_tuples + ) def test_load_tickerdata_file() -> None: @@ -473,3 +523,62 @@ def test_file_dump_json_tofile() -> None: # Remove the file _clean_test_file(file) + + +def test_get_timeframe(default_conf, mocker) -> None: + patch_exchange(mocker) + strategy = DefaultStrategy(default_conf) + + data = strategy.tickerdata_to_dataframe( + history.load_data( + datadir=None, + ticker_interval='1m', + pairs=['UNITTEST/BTC'] + ) + ) + min_date, max_date = history.get_timeframe(data) + assert min_date.isoformat() == '2017-11-04T23:02:00+00:00' + assert max_date.isoformat() == '2017-11-14T22:58:00+00:00' + + +def test_validate_backtest_data_warn(default_conf, mocker, caplog) -> None: + patch_exchange(mocker) + strategy = DefaultStrategy(default_conf) + + data = strategy.tickerdata_to_dataframe( + history.load_data( + datadir=None, + ticker_interval='1m', + pairs=['UNITTEST/BTC'], + fill_up_missing=False + ) + ) + min_date, max_date = history.get_timeframe(data) + caplog.clear() + assert history.validate_backtest_data(data['UNITTEST/BTC'], 'UNITTEST/BTC', + min_date, max_date, timeframe_to_minutes('1m')) + assert len(caplog.record_tuples) == 1 + assert log_has( + "UNITTEST/BTC has missing frames: expected 14396, got 13680, that's 716 missing values", + caplog.record_tuples) + + +def test_validate_backtest_data(default_conf, mocker, caplog) -> None: + patch_exchange(mocker) + strategy = DefaultStrategy(default_conf) + + timerange = TimeRange('index', 'index', 200, 250) + data = strategy.tickerdata_to_dataframe( + history.load_data( + datadir=None, + ticker_interval='5m', + pairs=['UNITTEST/BTC'], + timerange=timerange + ) + ) + + min_date, max_date = history.get_timeframe(data) + caplog.clear() + assert not history.validate_backtest_data(data['UNITTEST/BTC'], 'UNITTEST/BTC', + min_date, max_date, timeframe_to_minutes('5m')) + assert len(caplog.record_tuples) == 0 diff --git a/freqtrade/tests/edge/test_edge.py b/freqtrade/tests/edge/test_edge.py index af8674188..45b8e609e 100644 --- a/freqtrade/tests/edge/test_edge.py +++ b/freqtrade/tests/edge/test_edge.py @@ -10,10 +10,11 @@ import numpy as np import pytest from pandas import DataFrame, to_datetime +from freqtrade import OperationalException from freqtrade.data.converter import parse_ticker_dataframe from freqtrade.edge import Edge, PairInfo from freqtrade.strategy.interface import SellType -from freqtrade.tests.conftest import get_patched_freqtradebot +from freqtrade.tests.conftest import get_patched_freqtradebot, log_has from freqtrade.tests.optimize import (BTContainer, BTrade, _build_backtest_dataframe, _get_frame_time_from_offset) @@ -30,7 +31,50 @@ ticker_start_time = arrow.get(2018, 10, 3) ticker_interval_in_minute = 60 _ohlc = {'date': 0, 'buy': 1, 'open': 2, 'high': 3, 'low': 4, 'close': 5, 'sell': 6, 'volume': 7} +# Helpers for this test file + +def _validate_ohlc(buy_ohlc_sell_matrice): + for index, ohlc in enumerate(buy_ohlc_sell_matrice): + # if not high < open < low or not high < close < low + if not ohlc[3] >= ohlc[2] >= ohlc[4] or not ohlc[3] >= ohlc[5] >= ohlc[4]: + raise Exception('Line ' + str(index + 1) + ' of ohlc has invalid values!') + return True + + +def _build_dataframe(buy_ohlc_sell_matrice): + _validate_ohlc(buy_ohlc_sell_matrice) + tickers = [] + for ohlc in buy_ohlc_sell_matrice: + ticker = { + 'date': ticker_start_time.shift( + minutes=( + ohlc[0] * + ticker_interval_in_minute)).timestamp * + 1000, + 'buy': ohlc[1], + 'open': ohlc[2], + 'high': ohlc[3], + 'low': ohlc[4], + 'close': ohlc[5], + 'sell': ohlc[6]} + tickers.append(ticker) + + frame = DataFrame(tickers) + frame['date'] = to_datetime(frame['date'], + unit='ms', + utc=True, + infer_datetime_format=True) + + return frame + + +def _time_on_candle(number): + return np.datetime64(ticker_start_time.shift( + minutes=(number * ticker_interval_in_minute)).timestamp * 1000, 'ms') + + +# End helper functions # Open trade should be removed from the end tc0 = BTContainer(data=[ # D O H L C V B S @@ -203,46 +247,6 @@ def test_nonexisting_stake_amount(mocker, edge_conf): assert edge.stake_amount('N/O', 1, 2, 1) == 0.15 -def _validate_ohlc(buy_ohlc_sell_matrice): - for index, ohlc in enumerate(buy_ohlc_sell_matrice): - # if not high < open < low or not high < close < low - if not ohlc[3] >= ohlc[2] >= ohlc[4] or not ohlc[3] >= ohlc[5] >= ohlc[4]: - raise Exception('Line ' + str(index + 1) + ' of ohlc has invalid values!') - return True - - -def _build_dataframe(buy_ohlc_sell_matrice): - _validate_ohlc(buy_ohlc_sell_matrice) - tickers = [] - for ohlc in buy_ohlc_sell_matrice: - ticker = { - 'date': ticker_start_time.shift( - minutes=( - ohlc[0] * - ticker_interval_in_minute)).timestamp * - 1000, - 'buy': ohlc[1], - 'open': ohlc[2], - 'high': ohlc[3], - 'low': ohlc[4], - 'close': ohlc[5], - 'sell': ohlc[6]} - tickers.append(ticker) - - frame = DataFrame(tickers) - frame['date'] = to_datetime(frame['date'], - unit='ms', - utc=True, - infer_datetime_format=True) - - return frame - - -def _time_on_candle(number): - return np.datetime64(ticker_start_time.shift( - minutes=(number * ticker_interval_in_minute)).timestamp * 1000, 'ms') - - def test_edge_heartbeat_calculate(mocker, edge_conf): freqtrade = get_patched_freqtradebot(mocker, edge_conf) edge = Edge(edge_conf, freqtrade.exchange, freqtrade.strategy) @@ -259,7 +263,7 @@ def mocked_load_data(datadir, pairs=[], ticker_interval='0m', refresh_pairs=Fals hz = 0.1 base = 0.001 - ETHBTC = [ + NEOBTC = [ [ ticker_start_time.shift(minutes=(x * ticker_interval_in_minute)).timestamp * 1000, math.sin(x * hz) / 1000 + base, @@ -281,8 +285,8 @@ def mocked_load_data(datadir, pairs=[], ticker_interval='0m', refresh_pairs=Fals 123.45 ] for x in range(0, 500)] - pairdata = {'NEO/BTC': parse_ticker_dataframe(ETHBTC, '1h', fill_missing=True), - 'LTC/BTC': parse_ticker_dataframe(LTCBTC, '1h', fill_missing=True)} + pairdata = {'NEO/BTC': parse_ticker_dataframe(NEOBTC, '1h', pair="NEO/BTC", fill_missing=True), + 'LTC/BTC': parse_ticker_dataframe(LTCBTC, '1h', pair="LTC/BTC", fill_missing=True)} return pairdata @@ -298,6 +302,40 @@ def test_edge_process_downloaded_data(mocker, edge_conf): assert edge._last_updated <= arrow.utcnow().timestamp + 2 +def test_edge_process_no_data(mocker, edge_conf, caplog): + edge_conf['datadir'] = None + freqtrade = get_patched_freqtradebot(mocker, edge_conf) + mocker.patch('freqtrade.exchange.Exchange.get_fee', MagicMock(return_value=0.001)) + mocker.patch('freqtrade.data.history.load_data', MagicMock(return_value={})) + edge = Edge(edge_conf, freqtrade.exchange, freqtrade.strategy) + + assert not edge.calculate() + assert len(edge._cached_pairs) == 0 + assert log_has("No data found. Edge is stopped ...", caplog.record_tuples) + assert edge._last_updated == 0 + + +def test_edge_process_no_trades(mocker, edge_conf, caplog): + edge_conf['datadir'] = None + freqtrade = get_patched_freqtradebot(mocker, edge_conf) + mocker.patch('freqtrade.exchange.Exchange.get_fee', MagicMock(return_value=0.001)) + mocker.patch('freqtrade.data.history.load_data', mocked_load_data) + # Return empty + mocker.patch('freqtrade.edge.Edge._find_trades_for_stoploss_range', MagicMock(return_value=[])) + edge = Edge(edge_conf, freqtrade.exchange, freqtrade.strategy) + + assert not edge.calculate() + assert len(edge._cached_pairs) == 0 + assert log_has("No trades found.", caplog.record_tuples) + + +def test_edge_init_error(mocker, edge_conf,): + edge_conf['stake_amount'] = 0.5 + mocker.patch('freqtrade.exchange.Exchange.get_fee', MagicMock(return_value=0.001)) + with pytest.raises(OperationalException, match='Edge works only with unlimited stake amount'): + get_patched_freqtradebot(mocker, edge_conf) + + def test_process_expectancy(mocker, edge_conf): edge_conf['edge']['min_trade_number'] = 2 freqtrade = get_patched_freqtradebot(mocker, edge_conf) @@ -360,3 +398,11 @@ def test_process_expectancy(mocker, edge_conf): assert round(final['TEST/BTC'].risk_reward_ratio, 10) == 306.5384615384 assert round(final['TEST/BTC'].required_risk_reward, 10) == 2.0 assert round(final['TEST/BTC'].expectancy, 10) == 101.5128205128 + + # Pop last item so no trade is profitable + trades.pop() + trades_df = DataFrame(trades) + trades_df = edge._fill_calculable_fields(trades_df) + final = edge._process_expectancy(trades_df) + assert len(final) == 0 + assert isinstance(final, dict) diff --git a/freqtrade/tests/exchange/test_exchange.py b/freqtrade/tests/exchange/test_exchange.py index 9e471d551..48a8538a9 100644 --- a/freqtrade/tests/exchange/test_exchange.py +++ b/freqtrade/tests/exchange/test_exchange.py @@ -124,14 +124,14 @@ def test_exchange_resolver(default_conf, mocker, caplog): caplog.record_tuples) caplog.clear() - exchange = ExchangeResolver('Kraken', default_conf).exchange + exchange = ExchangeResolver('kraken', default_conf).exchange assert isinstance(exchange, Exchange) assert isinstance(exchange, Kraken) assert not isinstance(exchange, Binance) assert not log_has_re(r"No .* specific subclass found. Using the generic class instead.", caplog.record_tuples) - exchange = ExchangeResolver('Binance', default_conf).exchange + exchange = ExchangeResolver('binance', default_conf).exchange assert isinstance(exchange, Exchange) assert isinstance(exchange, Binance) assert not isinstance(exchange, Kraken) @@ -301,6 +301,20 @@ def test__reload_markets(default_conf, mocker, caplog): assert log_has('Performing scheduled market reload..', caplog.record_tuples) +def test__reload_markets_exception(default_conf, mocker, caplog): + caplog.set_level(logging.DEBUG) + + api_mock = MagicMock() + api_mock.load_markets = MagicMock(side_effect=ccxt.NetworkError) + default_conf['exchange']['markets_refresh_interval'] = 10 + exchange = get_patched_exchange(mocker, default_conf, api_mock, id="binance") + + # less than 10 minutes have passed, no reload + exchange._reload_markets() + assert exchange._last_markets_refresh == 0 + assert log_has_re(r"Could not reload markets.*", caplog.record_tuples) + + def test_validate_pairs(default_conf, mocker): # test exchange.validate_pairs directly api_mock = MagicMock() type(api_mock).markets = PropertyMock(return_value={ @@ -1002,7 +1016,7 @@ def test_refresh_latest_ohlcv(mocker, default_conf, caplog) -> None: exchange.refresh_latest_ohlcv([('IOTA/ETH', '5m'), ('XRP/ETH', '5m')]) assert exchange._api_async.fetch_ohlcv.call_count == 2 - assert log_has(f"Using cached ohlcv data for {pairs[0][0]}, {pairs[0][1]} ...", + assert log_has(f"Using cached ohlcv data for pair {pairs[0][0]}, interval {pairs[0][1]} ...", caplog.record_tuples) @@ -1421,3 +1435,30 @@ 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_merge_ft_has_dict(default_conf, mocker): + mocker.patch('freqtrade.exchange.Exchange._init_ccxt', MagicMock(return_value=MagicMock())) + mocker.patch('freqtrade.exchange.Exchange._load_async_markets', MagicMock()) + mocker.patch('freqtrade.exchange.Exchange.validate_pairs', MagicMock()) + mocker.patch('freqtrade.exchange.Exchange.validate_timeframes', MagicMock()) + ex = Exchange(default_conf) + assert ex._ft_has == Exchange._ft_has_default + + ex = Kraken(default_conf) + assert ex._ft_has == Exchange._ft_has_default + + # Binance defines different values + ex = Binance(default_conf) + assert ex._ft_has != Exchange._ft_has_default + assert ex._ft_has['stoploss_on_exchange'] + assert ex._ft_has['order_time_in_force'] == ['gtc', 'fok', 'ioc'] + + conf = copy.deepcopy(default_conf) + conf['exchange']['_ft_has_params'] = {"DeadBeef": 20, + "stoploss_on_exchange": False} + # Use settings from configuration (overriding stoploss_on_exchange) + ex = Binance(conf) + assert ex._ft_has != Exchange._ft_has_default + assert not ex._ft_has['stoploss_on_exchange'] + assert ex._ft_has['DeadBeef'] == 20 diff --git a/freqtrade/tests/optimize/__init__.py b/freqtrade/tests/optimize/__init__.py index 227050770..41500051f 100644 --- a/freqtrade/tests/optimize/__init__.py +++ b/freqtrade/tests/optimize/__init__.py @@ -3,7 +3,7 @@ from typing import NamedTuple, List import arrow from pandas import DataFrame -from freqtrade.misc import timeframe_to_minutes +from freqtrade.exchange import timeframe_to_minutes from freqtrade.strategy.interface import SellType ticker_start_time = arrow.get(2018, 10, 3) @@ -29,6 +29,10 @@ class BTContainer(NamedTuple): 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_offset: float = 0.0 + use_sell_signal: bool = False def _get_frame_time_from_offset(offset): diff --git a/freqtrade/tests/optimize/test_backtest_detail.py b/freqtrade/tests/optimize/test_backtest_detail.py index b98369533..402e22391 100644 --- a/freqtrade/tests/optimize/test_backtest_detail.py +++ b/freqtrade/tests/optimize/test_backtest_detail.py @@ -2,17 +2,32 @@ import logging from unittest.mock import MagicMock -from pandas import DataFrame import pytest +from pandas import DataFrame - -from freqtrade.optimize import get_timeframe +from freqtrade.data.history import get_timeframe from freqtrade.optimize.backtesting import Backtesting from freqtrade.strategy.interface import SellType -from freqtrade.tests.optimize import (BTrade, BTContainer, _build_backtest_dataframe, - _get_frame_time_from_offset, tests_ticker_interval) from freqtrade.tests.conftest import patch_exchange +from freqtrade.tests.optimize import (BTContainer, BTrade, + _build_backtest_dataframe, + _get_frame_time_from_offset, + tests_ticker_interval) +# Test 0 Sell signal sell +# Test with Stop-loss at 1% +# TC0: Sell signal in candle 3 +tc0 = BTContainer(data=[ + # D O H L C V B S + [0, 5000, 5025, 4975, 4987, 6172, 1, 0], + [1, 5000, 5025, 4975, 4987, 6172, 0, 0], # enter trade (signal on last candle) + [2, 4987, 5012, 4986, 4600, 6172, 0, 0], # exit with stoploss hit + [3, 5010, 5000, 4980, 5010, 6172, 0, 1], + [4, 5010, 4987, 4977, 4995, 6172, 0, 0], + [5, 4995, 4995, 4995, 4950, 6172, 0, 0]], + stop_loss=-0.01, roi=1, profit_perc=0.002, use_sell_signal=True, + trades=[BTrade(sell_reason=SellType.SELL_SIGNAL, open_tick=1, close_tick=4)] +) # Test 1 Minus 8% Close # Test with Stop-loss at 1% @@ -146,7 +161,7 @@ tc8 = BTContainer(data=[ # Test 9 - trailing_stop should raise - high and low in same candle. # Candle Data for test 9 # Set stop-loss at 10%, ROI at 10% (should not apply) -# TC9: Trailing stoploss - stoploss should be adjusted candle 2 +# TC9: Trailing stoploss - stoploss should be adjusted candle 3 tc9 = BTContainer(data=[ # D O H L C V B S [0, 5000, 5050, 4950, 5000, 6172, 1, 0], @@ -158,7 +173,59 @@ tc9 = BTContainer(data=[ trades=[BTrade(sell_reason=SellType.TRAILING_STOP_LOSS, open_tick=1, close_tick=3)] ) +# Test 10 - trailing_stop should raise so candle 3 causes a stoploss +# without applying trailing_stop_positive since stoploss_offset is at 10%. +# Set stop-loss at 10%, ROI at 10% (should not apply) +# TC10: Trailing stoploss - stoploss should be adjusted candle 2 +tc10 = BTContainer(data=[ + # D O H L C V B S + [0, 5000, 5050, 4950, 5000, 6172, 1, 0], + [1, 5000, 5050, 4950, 5100, 6172, 0, 0], + [2, 5100, 5251, 5100, 5100, 6172, 0, 0], + [3, 4850, 5050, 4650, 4750, 6172, 0, 0], + [4, 4750, 4950, 4350, 4750, 6172, 0, 0]], + stop_loss=-0.10, roi=0.10, profit_perc=-0.1, trailing_stop=True, + trailing_only_offset_is_reached=True, trailing_stop_positive_offset=0.10, + trailing_stop_positive=0.03, + trades=[BTrade(sell_reason=SellType.STOP_LOSS, open_tick=1, close_tick=4)] +) + +# Test 11 - trailing_stop should raise so candle 3 causes a stoploss +# applying a positive trailing stop of 3% since stop_positive_offset is reached. +# Set stop-loss at 10%, ROI at 10% (should not apply) +# TC11: Trailing stoploss - stoploss should be adjusted candle 2, +tc11 = BTContainer(data=[ + # D O H L C V B S + [0, 5000, 5050, 4950, 5000, 6172, 1, 0], + [1, 5000, 5050, 4950, 5100, 6172, 0, 0], + [2, 5100, 5251, 5100, 5100, 6172, 0, 0], + [3, 4850, 5050, 4650, 4750, 6172, 0, 0], + [4, 4750, 4950, 4350, 4750, 6172, 0, 0]], + stop_loss=-0.10, roi=0.10, profit_perc=0.019, trailing_stop=True, + trailing_only_offset_is_reached=True, trailing_stop_positive_offset=0.05, + trailing_stop_positive=0.03, + trades=[BTrade(sell_reason=SellType.TRAILING_STOP_LOSS, open_tick=1, close_tick=3)] +) + +# Test 12 - trailing_stop should raise in candle 2 and cause a stoploss in the same candle +# applying a positive trailing stop of 3% since stop_positive_offset is reached. +# Set stop-loss at 10%, ROI at 10% (should not apply) +# TC12: Trailing stoploss - stoploss should be adjusted candle 2, +tc12 = BTContainer(data=[ + # D O H L C V B S + [0, 5000, 5050, 4950, 5000, 6172, 1, 0], + [1, 5000, 5050, 4950, 5100, 6172, 0, 0], + [2, 5100, 5251, 4650, 5100, 6172, 0, 0], + [3, 4850, 5050, 4650, 4750, 6172, 0, 0], + [4, 4750, 4950, 4350, 4750, 6172, 0, 0]], + stop_loss=-0.10, roi=0.10, profit_perc=0.019, trailing_stop=True, + trailing_only_offset_is_reached=True, trailing_stop_positive_offset=0.05, + trailing_stop_positive=0.03, + trades=[BTrade(sell_reason=SellType.TRAILING_STOP_LOSS, open_tick=1, close_tick=2)] +) + TESTS = [ + tc0, tc1, tc2, tc3, @@ -168,6 +235,9 @@ TESTS = [ tc7, tc8, tc9, + tc10, + tc11, + tc12, ] @@ -180,6 +250,13 @@ def test_backtest_results(default_conf, fee, mocker, caplog, data) -> None: default_conf["minimal_roi"] = {"0": data.roi} default_conf["ticker_interval"] = tests_ticker_interval 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: + default_conf["trailing_stop_positive"] = data.trailing_stop_positive + default_conf["trailing_stop_positive_offset"] = data.trailing_stop_positive_offset + default_conf["experimental"] = {"use_sell_signal": data.use_sell_signal} + mocker.patch("freqtrade.exchange.Exchange.get_fee", MagicMock(return_value=0.0)) patch_exchange(mocker) frame = _build_backtest_dataframe(data.data) diff --git a/freqtrade/tests/optimize/test_backtesting.py b/freqtrade/tests/optimize/test_backtesting.py index 0596cffb5..28568f20c 100644 --- a/freqtrade/tests/optimize/test_backtesting.py +++ b/freqtrade/tests/optimize/test_backtesting.py @@ -3,7 +3,6 @@ import json import math import random -from typing import List from unittest.mock import MagicMock import numpy as np @@ -12,28 +11,24 @@ import pytest from arrow import Arrow from freqtrade import DependencyException, constants -from freqtrade.arguments import Arguments, TimeRange +from freqtrade.arguments import TimeRange from freqtrade.data import history from freqtrade.data.btanalysis import evaluate_result_multi from freqtrade.data.converter import parse_ticker_dataframe from freqtrade.data.dataprovider import DataProvider -from freqtrade.optimize import get_timeframe -from freqtrade.optimize.backtesting import (Backtesting, setup_configuration, - start) +from freqtrade.data.history import get_timeframe +from freqtrade.optimize import setup_configuration, start_backtesting +from freqtrade.optimize.backtesting import Backtesting from freqtrade.state import RunMode from freqtrade.strategy.default_strategy import DefaultStrategy from freqtrade.strategy.interface import SellType -from freqtrade.tests.conftest import log_has, patch_exchange - - -def get_args(args) -> List[str]: - return Arguments(args, '').get_parsed_arg() +from freqtrade.tests.conftest import get_args, log_has, log_has_re, patch_exchange def trim_dictlist(dict_list, num): new = {} for pair, pair_data in dict_list.items(): - new[pair] = pair_data[num:] + new[pair] = pair_data[num:].reset_index() return new @@ -78,7 +73,8 @@ def load_data_test(what): pair[x][5] # Keep old volume ] for x in range(0, datalen) ] - return {'UNITTEST/BTC': parse_ticker_dataframe(data, '1m', fill_missing=True)} + return {'UNITTEST/BTC': parse_ticker_dataframe(data, '1m', pair="UNITTEST/BTC", + fill_missing=True)} def simple_backtest(config, contour, num_results, mocker) -> None: @@ -105,9 +101,10 @@ def simple_backtest(config, contour, num_results, mocker) -> None: def mocked_load_data(datadir, pairs=[], ticker_interval='0m', refresh_pairs=False, - timerange=None, exchange=None): + timerange=None, exchange=None, live=False): tickerdata = history.load_tickerdata_file(datadir, 'UNITTEST/BTC', '1m', timerange=timerange) - pairdata = {'UNITTEST/BTC': parse_ticker_dataframe(tickerdata, '1m', fill_missing=True)} + pairdata = {'UNITTEST/BTC': parse_ticker_dataframe(tickerdata, '1m', pair="UNITTEST/BTC", + fill_missing=True)} return pairdata @@ -178,7 +175,7 @@ def test_setup_configuration_without_arguments(mocker, default_conf, caplog) -> 'backtesting' ] - config = setup_configuration(get_args(args)) + config = setup_configuration(get_args(args), RunMode.BACKTEST) assert 'max_open_trades' in config assert 'stake_currency' in config assert 'stake_amount' in config @@ -190,7 +187,7 @@ def test_setup_configuration_without_arguments(mocker, default_conf, caplog) -> caplog.record_tuples ) assert 'ticker_interval' in config - assert not log_has('Parameter -i/--ticker-interval detected ...', caplog.record_tuples) + assert not log_has_re('Parameter -i/--ticker-interval detected .*', caplog.record_tuples) assert 'live' not in config assert not log_has('Parameter -l/--live detected ...', caplog.record_tuples) @@ -228,7 +225,7 @@ def test_setup_bt_configuration_with_arguments(mocker, default_conf, caplog) -> '--export-filename', 'foo_bar.json' ] - config = setup_configuration(get_args(args)) + config = setup_configuration(get_args(args), RunMode.BACKTEST) assert 'max_open_trades' in config assert 'stake_currency' in config assert 'stake_amount' in config @@ -242,11 +239,8 @@ def test_setup_bt_configuration_with_arguments(mocker, default_conf, caplog) -> caplog.record_tuples ) assert 'ticker_interval' in config - assert log_has('Parameter -i/--ticker-interval detected ...', caplog.record_tuples) - assert log_has( - 'Using ticker_interval: 1m ...', - caplog.record_tuples - ) + assert log_has('Parameter -i/--ticker-interval detected ... Using ticker_interval: 1m ...', + caplog.record_tuples) assert 'live' in config assert log_has('Parameter -l/--live detected ...', caplog.record_tuples) @@ -260,6 +254,7 @@ def test_setup_bt_configuration_with_arguments(mocker, default_conf, caplog) -> assert 'refresh_pairs' in config assert log_has('Parameter -r/--refresh-pairs-cached detected ...', caplog.record_tuples) + assert 'timerange' in config assert log_has( 'Parameter --timerange detected: {} ...'.format(config['timerange']), @@ -292,7 +287,7 @@ def test_setup_configuration_unlimited_stake_amount(mocker, default_conf, caplog ] with pytest.raises(DependencyException, match=r'.*stake amount.*'): - setup_configuration(get_args(args)) + setup_configuration(get_args(args), RunMode.BACKTEST) def test_start(mocker, fee, default_conf, caplog) -> None: @@ -309,7 +304,7 @@ def test_start(mocker, fee, default_conf, caplog) -> None: 'backtesting' ] args = get_args(args) - start(args) + start_backtesting(args) assert log_has( 'Starting freqtrade in Backtesting mode', caplog.record_tuples @@ -357,7 +352,8 @@ def test_tickerdata_to_dataframe_bt(default_conf, mocker) -> None: patch_exchange(mocker) timerange = TimeRange(None, 'line', 0, -100) tick = history.load_tickerdata_file(None, 'UNITTEST/BTC', '1m', timerange=timerange) - tickerlist = {'UNITTEST/BTC': parse_ticker_dataframe(tick, '1m', fill_missing=True)} + tickerlist = {'UNITTEST/BTC': parse_ticker_dataframe(tick, '1m', pair="UNITTEST/BTC", + fill_missing=True)} backtesting = Backtesting(default_conf) data = backtesting.strategy.tickerdata_to_dataframe(tickerlist) @@ -474,7 +470,7 @@ def test_backtesting_start(default_conf, mocker, caplog) -> None: 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.optimize.get_timeframe', get_timeframe) + mocker.patch('freqtrade.data.history.get_timeframe', get_timeframe) mocker.patch('freqtrade.exchange.Exchange.refresh_latest_ohlcv', MagicMock()) patch_exchange(mocker) mocker.patch.multiple( @@ -494,10 +490,9 @@ def test_backtesting_start(default_conf, mocker, caplog) -> None: backtesting.start() # check the logs, that will contain the backtest result exists = [ - 'Using local backtesting data (using whitelist in given config) ...', 'Using stake_currency: BTC ...', 'Using stake_amount: 0.001 ...', - 'Measuring data from 2017-11-14T21:17:00+00:00 ' + 'Backtesting with data from 2017-11-14T21:17:00+00:00 ' 'up to 2017-11-14T22:59:00+00:00 (0 days)..' ] for line in exists: @@ -509,7 +504,7 @@ def test_backtesting_start_no_data(default_conf, mocker, caplog) -> None: return Arrow(2017, 11, 14, 21, 17), Arrow(2017, 11, 14, 22, 59) mocker.patch('freqtrade.data.history.load_data', MagicMock(return_value={})) - mocker.patch('freqtrade.optimize.get_timeframe', get_timeframe) + mocker.patch('freqtrade.data.history.get_timeframe', get_timeframe) mocker.patch('freqtrade.exchange.Exchange.refresh_latest_ohlcv', MagicMock()) patch_exchange(mocker) mocker.patch.multiple( @@ -710,7 +705,7 @@ def test_backtest_multi_pair(default_conf, fee, mocker, tres, pair): data = trim_dictlist(data, -500) # Remove data for one pair from the beginning of the data - data[pair] = data[pair][tres:] + data[pair] = data[pair][tres:].reset_index() # We need to enable sell-signal - otherwise it sells on ROI!! default_conf['experimental'] = {"use_sell_signal": True} default_conf['ticker_interval'] = '5m' @@ -849,19 +844,19 @@ def test_backtest_start_live(default_conf, mocker, caplog): '--disable-max-market-positions' ] args = get_args(args) - start(args) + start_backtesting(args) # check the logs, that will contain the backtest result exists = [ - 'Parameter -i/--ticker-interval detected ...', - 'Using ticker_interval: 1m ...', + 'Parameter -i/--ticker-interval detected ... Using ticker_interval: 1m ...', 'Parameter -l/--live detected ...', 'Ignoring max_open_trades (--disable-max-market-positions was used) ...', 'Parameter --timerange detected: -100 ...', 'Using data folder: freqtrade/tests/testdata ...', 'Using stake_currency: BTC ...', 'Using stake_amount: 0.001 ...', - 'Downloading data for all pairs in whitelist ...', - 'Measuring data from 2017-11-14T19:31:00+00:00 up to 2017-11-14T22:58:00+00:00 (0 days)..', + 'Live: Downloading data for all defined pairs ...', + 'Backtesting with data from 2017-11-14T19:31:00+00:00 ' + 'up to 2017-11-14T22:58:00+00:00 (0 days)..', 'Parameter --enable-position-stacking detected ...' ] @@ -903,7 +898,7 @@ def test_backtest_start_multi_strat(default_conf, mocker, caplog): 'TestStrategy', ] args = get_args(args) - start(args) + start_backtesting(args) # 2 backtests, 4 tables assert backtestmock.call_count == 2 assert gen_table_mock.call_count == 4 @@ -911,16 +906,16 @@ def test_backtest_start_multi_strat(default_conf, mocker, caplog): # check the logs, that will contain the backtest result exists = [ - 'Parameter -i/--ticker-interval detected ...', - 'Using ticker_interval: 1m ...', + 'Parameter -i/--ticker-interval detected ... Using ticker_interval: 1m ...', 'Parameter -l/--live detected ...', 'Ignoring max_open_trades (--disable-max-market-positions was used) ...', 'Parameter --timerange detected: -100 ...', 'Using data folder: freqtrade/tests/testdata ...', 'Using stake_currency: BTC ...', 'Using stake_amount: 0.001 ...', - 'Downloading data for all pairs in whitelist ...', - 'Measuring data from 2017-11-14T19:31:00+00:00 up to 2017-11-14T22:58:00+00:00 (0 days)..', + 'Live: Downloading data for all defined pairs ...', + 'Backtesting with data from 2017-11-14T19:31:00+00:00 ' + 'up to 2017-11-14T22:58:00+00:00 (0 days)..', 'Parameter --enable-position-stacking detected ...', 'Running backtesting for Strategy DefaultStrategy', 'Running backtesting for Strategy TestStrategy', diff --git a/freqtrade/tests/optimize/test_edge_cli.py b/freqtrade/tests/optimize/test_edge_cli.py index a58620139..6b527543f 100644 --- a/freqtrade/tests/optimize/test_edge_cli.py +++ b/freqtrade/tests/optimize/test_edge_cli.py @@ -1,18 +1,14 @@ # pragma pylint: disable=missing-docstring, C0103, C0330 # pragma pylint: disable=protected-access, too-many-lines, invalid-name, too-many-arguments -from unittest.mock import MagicMock import json -from typing import List +from unittest.mock import MagicMock + from freqtrade.edge import PairInfo -from freqtrade.arguments import Arguments -from freqtrade.optimize.edge_cli import (EdgeCli, setup_configuration, start) +from freqtrade.optimize import setup_configuration, start_edge +from freqtrade.optimize.edge_cli import EdgeCli from freqtrade.state import RunMode -from freqtrade.tests.conftest import log_has, patch_exchange - - -def get_args(args) -> List[str]: - return Arguments(args, '').get_parsed_arg() +from freqtrade.tests.conftest import get_args, log_has, log_has_re, patch_exchange def test_setup_configuration_without_arguments(mocker, default_conf, caplog) -> None: @@ -26,8 +22,8 @@ def test_setup_configuration_without_arguments(mocker, default_conf, caplog) -> 'edge' ] - config = setup_configuration(get_args(args)) - assert config['runmode'] == RunMode.EDGECLI + config = setup_configuration(get_args(args), RunMode.EDGE) + assert config['runmode'] == RunMode.EDGE assert 'max_open_trades' in config assert 'stake_currency' in config @@ -40,7 +36,7 @@ def test_setup_configuration_without_arguments(mocker, default_conf, caplog) -> caplog.record_tuples ) assert 'ticker_interval' in config - assert not log_has('Parameter -i/--ticker-interval detected ...', caplog.record_tuples) + assert not log_has_re('Parameter -i/--ticker-interval detected .*', caplog.record_tuples) assert 'refresh_pairs' not in config assert not log_has('Parameter -r/--refresh-pairs-cached detected ...', caplog.record_tuples) @@ -66,24 +62,21 @@ def test_setup_edge_configuration_with_arguments(mocker, edge_conf, caplog) -> N '--stoplosses=-0.01,-0.10,-0.001' ] - config = setup_configuration(get_args(args)) + config = setup_configuration(get_args(args), RunMode.EDGE) assert 'max_open_trades' in config assert 'stake_currency' in config assert 'stake_amount' in config assert 'exchange' in config assert 'pair_whitelist' in config['exchange'] assert 'datadir' in config - assert config['runmode'] == RunMode.EDGECLI + assert config['runmode'] == RunMode.EDGE assert log_has( 'Using data folder: {} ...'.format(config['datadir']), caplog.record_tuples ) assert 'ticker_interval' in config - assert log_has('Parameter -i/--ticker-interval detected ...', caplog.record_tuples) - assert log_has( - 'Using ticker_interval: 1m ...', - caplog.record_tuples - ) + assert log_has('Parameter -i/--ticker-interval detected ... Using ticker_interval: 1m ...', + caplog.record_tuples) assert 'refresh_pairs' in config assert log_has('Parameter -r/--refresh-pairs-cached detected ...', caplog.record_tuples) @@ -108,7 +101,7 @@ def test_start(mocker, fee, edge_conf, caplog) -> None: 'edge' ] args = get_args(args) - start(args) + start_edge(args) assert log_has( 'Starting freqtrade in Edge mode', caplog.record_tuples @@ -118,8 +111,10 @@ def test_start(mocker, fee, edge_conf, caplog) -> None: def test_edge_init(mocker, edge_conf) -> None: patch_exchange(mocker) + edge_conf['stake_amount'] = 20 edge_cli = EdgeCli(edge_conf) assert edge_cli.config == edge_conf + assert edge_cli.config['stake_amount'] == 'unlimited' assert callable(edge_cli.edge.calculate) diff --git a/freqtrade/tests/optimize/test_hyperopt.py b/freqtrade/tests/optimize/test_hyperopt.py index 20baee99e..c3d6d0076 100644 --- a/freqtrade/tests/optimize/test_hyperopt.py +++ b/freqtrade/tests/optimize/test_hyperopt.py @@ -1,18 +1,22 @@ # pragma pylint: disable=missing-docstring,W0212,C0103 -from datetime import datetime +import json import os +from datetime import datetime from unittest.mock import MagicMock +from filelock import Timeout import pandas as pd import pytest +from freqtrade import DependencyException from freqtrade.data.converter import parse_ticker_dataframe from freqtrade.data.history import load_tickerdata_file -from freqtrade.optimize.hyperopt import Hyperopt, start from freqtrade.optimize.default_hyperopt import DefaultHyperOpts -from freqtrade.resolvers import StrategyResolver, HyperOptResolver -from freqtrade.tests.conftest import log_has, patch_exchange -from freqtrade.tests.optimize.test_backtesting import get_args +from freqtrade.optimize.hyperopt import Hyperopt, HYPEROPT_LOCKFILE +from freqtrade.optimize import setup_configuration, start_hyperopt +from freqtrade.resolvers.hyperopt_resolver import HyperOptResolver +from freqtrade.state import RunMode +from freqtrade.tests.conftest import get_args, log_has, log_has_re, patch_exchange @pytest.fixture(scope='function') @@ -39,6 +43,110 @@ def create_trials(mocker, hyperopt) -> None: return [{'loss': 1, 'result': 'foo', 'params': {}}] +def test_setup_hyperopt_configuration_without_arguments(mocker, default_conf, caplog) -> None: + mocker.patch('freqtrade.configuration.open', mocker.mock_open( + read_data=json.dumps(default_conf) + )) + + args = [ + '--config', 'config.json', + 'hyperopt' + ] + + config = setup_configuration(get_args(args), RunMode.HYPEROPT) + assert 'max_open_trades' in config + assert 'stake_currency' in config + assert 'stake_amount' in config + assert 'exchange' in config + assert 'pair_whitelist' in config['exchange'] + assert 'datadir' in config + assert log_has( + 'Using data folder: {} ...'.format(config['datadir']), + caplog.record_tuples + ) + assert 'ticker_interval' in config + assert not log_has_re('Parameter -i/--ticker-interval detected .*', caplog.record_tuples) + + assert 'live' not in config + assert not log_has('Parameter -l/--live detected ...', caplog.record_tuples) + + assert 'position_stacking' not in config + assert not log_has('Parameter --enable-position-stacking detected ...', caplog.record_tuples) + + assert 'refresh_pairs' not in config + assert not log_has('Parameter -r/--refresh-pairs-cached detected ...', caplog.record_tuples) + + assert 'timerange' not in config + assert 'runmode' in config + assert config['runmode'] == RunMode.HYPEROPT + + +def test_setup_hyperopt_configuration_with_arguments(mocker, default_conf, caplog) -> None: + mocker.patch('freqtrade.configuration.open', mocker.mock_open( + read_data=json.dumps(default_conf) + )) + mocker.patch('freqtrade.configuration.Configuration._create_datadir', lambda s, c, x: x) + + args = [ + '--config', 'config.json', + '--datadir', '/foo/bar', + 'hyperopt', + '--ticker-interval', '1m', + '--timerange', ':100', + '--refresh-pairs-cached', + '--enable-position-stacking', + '--disable-max-market-positions', + '--epochs', '1000', + '--spaces', 'all', + '--print-all' + ] + + config = setup_configuration(get_args(args), RunMode.HYPEROPT) + assert 'max_open_trades' in config + assert 'stake_currency' in config + assert 'stake_amount' in config + assert 'exchange' in config + assert 'pair_whitelist' in config['exchange'] + assert 'datadir' in config + assert config['runmode'] == RunMode.HYPEROPT + + assert log_has( + 'Using data folder: {} ...'.format(config['datadir']), + caplog.record_tuples + ) + assert 'ticker_interval' in config + assert log_has('Parameter -i/--ticker-interval detected ... Using ticker_interval: 1m ...', + caplog.record_tuples) + + assert 'position_stacking' in config + assert log_has('Parameter --enable-position-stacking detected ...', caplog.record_tuples) + + assert 'use_max_market_positions' in config + assert log_has('Parameter --disable-max-market-positions detected ...', caplog.record_tuples) + assert log_has('max_open_trades set to unlimited ...', caplog.record_tuples) + + assert 'refresh_pairs' in config + assert log_has('Parameter -r/--refresh-pairs-cached detected ...', caplog.record_tuples) + + assert 'timerange' in config + assert log_has( + 'Parameter --timerange detected: {} ...'.format(config['timerange']), + caplog.record_tuples + ) + + assert 'epochs' in config + assert log_has('Parameter --epochs detected ... Will run Hyperopt with for 1000 epochs ...', + caplog.record_tuples) + + assert 'spaces' in config + assert log_has( + 'Parameter -s/--spaces detected: {}'.format(config['spaces']), + caplog.record_tuples + ) + assert 'print_all' in config + assert log_has('Parameter --print-all detected ...', caplog.record_tuples) + + def test_hyperoptresolver(mocker, default_conf, caplog) -> None: mocker.patch( @@ -59,6 +167,7 @@ def test_hyperoptresolver(mocker, default_conf, caplog) -> None: "Using populate_sell_trend from DefaultStrategy.", caplog.record_tuples) assert log_has("Custom Hyperopt does not provide populate_buy_trend. " "Using populate_buy_trend from DefaultStrategy.", caplog.record_tuples) + assert hasattr(x, "ticker_interval") def test_start(mocker, default_conf, caplog) -> None: @@ -72,13 +181,11 @@ def test_start(mocker, default_conf, caplog) -> None: args = [ '--config', 'config.json', - '--strategy', 'DefaultStrategy', 'hyperopt', '--epochs', '5' ] args = get_args(args) - StrategyResolver({'strategy': 'DefaultStrategy'}) - start(args) + start_hyperopt(args) import pprint pprint.pprint(caplog.record_tuples) @@ -90,6 +197,33 @@ def test_start(mocker, default_conf, caplog) -> None: assert start_mock.call_count == 1 +def test_start_no_data(mocker, default_conf, caplog) -> None: + mocker.patch( + 'freqtrade.configuration.Configuration._load_config_file', + lambda *args, **kwargs: default_conf + ) + mocker.patch('freqtrade.optimize.hyperopt.load_data', MagicMock(return_value={})) + mocker.patch( + 'freqtrade.optimize.hyperopt.get_timeframe', + MagicMock(return_value=(datetime(2017, 12, 10), datetime(2017, 12, 13))) + ) + + patch_exchange(mocker) + + args = [ + '--config', 'config.json', + 'hyperopt', + '--epochs', '5' + ] + args = get_args(args) + start_hyperopt(args) + + import pprint + pprint.pprint(caplog.record_tuples) + + assert log_has('No data found. Terminating.', caplog.record_tuples) + + def test_start_failure(mocker, default_conf, caplog) -> None: start_mock = MagicMock() mocker.patch( @@ -106,17 +240,37 @@ def test_start_failure(mocker, default_conf, caplog) -> None: '--epochs', '5' ] args = get_args(args) - StrategyResolver({'strategy': 'DefaultStrategy'}) - with pytest.raises(ValueError): - start(args) + with pytest.raises(DependencyException): + start_hyperopt(args) assert log_has( "Please don't use --strategy for hyperopt.", caplog.record_tuples ) +def test_start_filelock(mocker, default_conf, caplog) -> None: + start_mock = MagicMock(side_effect=Timeout(HYPEROPT_LOCKFILE)) + mocker.patch( + 'freqtrade.configuration.Configuration._load_config_file', + lambda *args, **kwargs: default_conf + ) + mocker.patch('freqtrade.optimize.hyperopt.Hyperopt.start', start_mock) + patch_exchange(mocker) + + args = [ + '--config', 'config.json', + 'hyperopt', + '--epochs', '5' + ] + args = get_args(args) + start_hyperopt(args) + assert log_has( + "Another running instance of freqtrade Hyperopt detected.", + caplog.record_tuples + ) + + def test_loss_calculation_prefer_correct_trade_count(hyperopt) -> None: - StrategyResolver({'strategy': 'DefaultStrategy'}) correct = hyperopt.calculate_loss(1, hyperopt.target_trades, 20) over = hyperopt.calculate_loss(1, hyperopt.target_trades + 100, 20) @@ -146,11 +300,12 @@ def test_log_results_if_loss_improves(hyperopt, capsys) -> None: 'loss': 1, 'current_tries': 1, 'total_tries': 2, - 'result': 'foo' + 'result': 'foo.', + 'initial_point': False } ) out, err = capsys.readouterr() - assert ' 1/2: foo. Loss 1.00000' in out + assert ' 2/2: foo. Objective: 1.00000' in out def test_no_log_if_loss_does_not_improve(hyperopt, caplog) -> None: @@ -206,26 +361,32 @@ def test_roi_table_generation(hyperopt) -> None: def test_start_calls_optimizer(mocker, default_conf, caplog) -> None: dumper = mocker.patch('freqtrade.optimize.hyperopt.dump', MagicMock()) mocker.patch('freqtrade.optimize.hyperopt.load_data', MagicMock()) - mocker.patch('freqtrade.optimize.hyperopt.multiprocessing.cpu_count', MagicMock(return_value=1)) + mocker.patch( + 'freqtrade.optimize.hyperopt.get_timeframe', + MagicMock(return_value=(datetime(2017, 12, 10), datetime(2017, 12, 13))) + ) + parallel = mocker.patch( 'freqtrade.optimize.hyperopt.Hyperopt.run_optimizer_parallel', MagicMock(return_value=[{'loss': 1, 'result': 'foo result', 'params': {}}]) ) patch_exchange(mocker) - default_conf.update({'config': 'config.json.example'}) - default_conf.update({'epochs': 1}) - default_conf.update({'timerange': None}) - default_conf.update({'spaces': 'all'}) + default_conf.update({'config': 'config.json.example', + 'epochs': 1, + 'timerange': None, + 'spaces': 'all', + 'hyperopt_jobs': 1, }) hyperopt = Hyperopt(default_conf) hyperopt.strategy.tickerdata_to_dataframe = MagicMock() hyperopt.start() parallel.assert_called_once() - - assert 'Best result:\nfoo result\nwith values:\n\n' in caplog.text + assert log_has('Best result:\nfoo result\nwith values:\n', caplog.record_tuples) assert dumper.called + # Should be called twice, once for tickerdata, once to save evaluations + assert dumper.call_count == 2 def test_format_results(hyperopt): @@ -266,7 +427,8 @@ def test_has_space(hyperopt): def test_populate_indicators(hyperopt) -> None: tick = load_tickerdata_file(None, 'UNITTEST/BTC', '1m') - tickerlist = {'UNITTEST/BTC': parse_ticker_dataframe(tick, '1m', fill_missing=True)} + tickerlist = {'UNITTEST/BTC': parse_ticker_dataframe(tick, '1m', pair="UNITTEST/BTC", + fill_missing=True)} dataframes = hyperopt.strategy.tickerdata_to_dataframe(tickerlist) dataframe = hyperopt.custom_hyperopt.populate_indicators(dataframes['UNITTEST/BTC'], {'pair': 'UNITTEST/BTC'}) @@ -279,7 +441,8 @@ def test_populate_indicators(hyperopt) -> None: def test_buy_strategy_generator(hyperopt) -> None: tick = load_tickerdata_file(None, 'UNITTEST/BTC', '1m') - tickerlist = {'UNITTEST/BTC': parse_ticker_dataframe(tick, '1m', fill_missing=True)} + tickerlist = {'UNITTEST/BTC': parse_ticker_dataframe(tick, '1m', pair="UNITTEST/BTC", + fill_missing=True)} dataframes = hyperopt.strategy.tickerdata_to_dataframe(tickerlist) dataframe = hyperopt.custom_hyperopt.populate_indicators(dataframes['UNITTEST/BTC'], {'pair': 'UNITTEST/BTC'}) @@ -307,6 +470,7 @@ def test_generate_optimizer(mocker, default_conf) -> None: default_conf.update({'config': 'config.json.example'}) default_conf.update({'timerange': None}) default_conf.update({'spaces': 'all'}) + default_conf.update({'hyperopt_min_trades': 1}) trades = [ ('POWR/BTC', 0.023117, 0.000233, 100) @@ -355,7 +519,7 @@ def test_generate_optimizer(mocker, default_conf) -> None: response_expected = { 'loss': 1.9840569076926293, 'result': ' 1 trades. Avg profit 2.31%. Total profit 0.00023300 BTC ' - '(0.0231Σ%). Avg duration 100.0 mins.', + '( 2.31Σ%). Avg duration 100.0 mins.', 'params': optimizer_param } diff --git a/freqtrade/tests/optimize/test_optimize.py b/freqtrade/tests/optimize/test_optimize.py deleted file mode 100644 index 088743038..000000000 --- a/freqtrade/tests/optimize/test_optimize.py +++ /dev/null @@ -1,66 +0,0 @@ -# pragma pylint: disable=missing-docstring, protected-access, C0103 -from freqtrade import optimize -from freqtrade.arguments import TimeRange -from freqtrade.data import history -from freqtrade.misc import timeframe_to_minutes -from freqtrade.strategy.default_strategy import DefaultStrategy -from freqtrade.tests.conftest import log_has, patch_exchange - - -def test_get_timeframe(default_conf, mocker) -> None: - patch_exchange(mocker) - strategy = DefaultStrategy(default_conf) - - data = strategy.tickerdata_to_dataframe( - history.load_data( - datadir=None, - ticker_interval='1m', - pairs=['UNITTEST/BTC'] - ) - ) - min_date, max_date = optimize.get_timeframe(data) - assert min_date.isoformat() == '2017-11-04T23:02:00+00:00' - assert max_date.isoformat() == '2017-11-14T22:58:00+00:00' - - -def test_validate_backtest_data_warn(default_conf, mocker, caplog) -> None: - patch_exchange(mocker) - strategy = DefaultStrategy(default_conf) - - data = strategy.tickerdata_to_dataframe( - history.load_data( - datadir=None, - ticker_interval='1m', - pairs=['UNITTEST/BTC'], - fill_up_missing=False - ) - ) - min_date, max_date = optimize.get_timeframe(data) - caplog.clear() - assert optimize.validate_backtest_data(data, min_date, max_date, - timeframe_to_minutes('1m')) - assert len(caplog.record_tuples) == 1 - assert log_has( - "UNITTEST/BTC has missing frames: expected 14396, got 13680, that's 716 missing values", - caplog.record_tuples) - - -def test_validate_backtest_data(default_conf, mocker, caplog) -> None: - patch_exchange(mocker) - strategy = DefaultStrategy(default_conf) - - timerange = TimeRange('index', 'index', 200, 250) - data = strategy.tickerdata_to_dataframe( - history.load_data( - datadir=None, - ticker_interval='5m', - pairs=['UNITTEST/BTC'], - timerange=timerange - ) - ) - - min_date, max_date = optimize.get_timeframe(data) - caplog.clear() - assert not optimize.validate_backtest_data(data, min_date, max_date, - timeframe_to_minutes('5m')) - assert len(caplog.record_tuples) == 0 diff --git a/freqtrade/tests/rpc/test_rpc.py b/freqtrade/tests/rpc/test_rpc.py index 25d1109b2..5a4b5d1b2 100644 --- a/freqtrade/tests/rpc/test_rpc.py +++ b/freqtrade/tests/rpc/test_rpc.py @@ -14,8 +14,7 @@ from freqtrade.persistence import Trade from freqtrade.rpc import RPC, RPCException from freqtrade.rpc.fiat_convert import CryptoToFiatConverter from freqtrade.state import State -from freqtrade.tests.conftest import patch_exchange -from freqtrade.tests.test_freqtradebot import patch_get_signal +from freqtrade.tests.conftest import patch_exchange, patch_get_signal # Functions for recurrent object patching @@ -47,12 +46,14 @@ def test_rpc_trade_status(default_conf, ticker, fee, markets, mocker) -> None: freqtradebot.create_trade() results = rpc._rpc_trade_status() - assert { 'trade_id': 1, 'pair': 'ETH/BTC', 'base_currency': 'BTC', - 'date': ANY, + 'open_date': ANY, + 'open_date_hum': ANY, + 'close_date': None, + 'close_date_hum': None, 'open_rate': 1.099e-05, 'close_rate': None, 'current_rate': 1.098e-05, @@ -78,7 +79,10 @@ def test_rpc_trade_status(default_conf, ticker, fee, markets, mocker) -> None: 'trade_id': 1, 'pair': 'ETH/BTC', 'base_currency': 'BTC', - 'date': ANY, + 'open_date': ANY, + 'open_date_hum': ANY, + 'close_date': None, + 'close_date_hum': None, 'open_rate': 1.099e-05, 'close_rate': None, 'current_rate': ANY, @@ -114,7 +118,7 @@ def test_rpc_status_table(default_conf, ticker, fee, markets, mocker) -> None: freqtradebot.create_trade() result = rpc._rpc_status_table() - assert 'just now' in result['Since'].all() + assert 'instantly' in result['Since'].all() assert 'ETH/BTC' in result['Pair'].all() assert '-0.59%' in result['Profit'].all() @@ -123,7 +127,7 @@ def test_rpc_status_table(default_conf, ticker, fee, markets, mocker) -> None: # invalidate ticker cache rpc._freqtrade.exchange._cached_ticker = {} result = rpc._rpc_status_table() - assert 'just now' in result['Since'].all() + assert 'instantly' in result['Since'].all() assert 'ETH/BTC' in result['Pair'].all() assert 'nan%' in result['Profit'].all() @@ -463,12 +467,15 @@ def test_rpc_forcesell(default_conf, ticker, fee, mocker, markets) -> None: with pytest.raises(RPCException, match=r'.*invalid argument*'): rpc._rpc_forcesell(None) - rpc._rpc_forcesell('all') + msg = rpc._rpc_forcesell('all') + assert msg == {'result': 'Created sell orders for all open trades.'} freqtradebot.create_trade() - rpc._rpc_forcesell('all') + msg = rpc._rpc_forcesell('all') + assert msg == {'result': 'Created sell orders for all open trades.'} - rpc._rpc_forcesell('1') + msg = rpc._rpc_forcesell('1') + assert msg == {'result': 'Created sell order for trade 1.'} freqtradebot.state = State.STOPPED with pytest.raises(RPCException, match=r'.*trader is not running*'): @@ -511,7 +518,8 @@ def test_rpc_forcesell(default_conf, ticker, fee, mocker, markets) -> None: } ) # check that the trade is called, which is done by ensuring exchange.cancel_order is called - rpc._rpc_forcesell('2') + msg = rpc._rpc_forcesell('2') + assert msg == {'result': 'Created sell order for trade 2.'} assert cancel_order_mock.call_count == 2 assert trade.amount == amount @@ -525,7 +533,8 @@ def test_rpc_forcesell(default_conf, ticker, fee, mocker, markets) -> None: 'side': 'sell' } ) - rpc._rpc_forcesell('3') + msg = rpc._rpc_forcesell('3') + assert msg == {'result': 'Created sell order for trade 3.'} # status quo, no exchange calls assert cancel_order_mock.call_count == 2 diff --git a/freqtrade/tests/rpc/test_rpc_apiserver.py b/freqtrade/tests/rpc/test_rpc_apiserver.py new file mode 100644 index 000000000..b7721fd8e --- /dev/null +++ b/freqtrade/tests/rpc/test_rpc_apiserver.py @@ -0,0 +1,556 @@ +""" +Unit test file for rpc/api_server.py +""" + +from datetime import datetime +from unittest.mock import ANY, MagicMock, PropertyMock + +import pytest +from flask import Flask +from requests.auth import _basic_auth_str + +from freqtrade.__init__ import __version__ +from freqtrade.persistence import Trade +from freqtrade.rpc.api_server import BASE_URI, ApiServer +from freqtrade.state import State +from freqtrade.tests.conftest import (get_patched_freqtradebot, log_has, + patch_get_signal) + + +_TEST_USER = "FreqTrader" +_TEST_PASS = "SuperSecurePassword1!" + + +@pytest.fixture +def botclient(default_conf, mocker): + default_conf.update({"api_server": {"enabled": True, + "listen_ip_address": "127.0.0.1", + "listen_port": "8080", + "username": _TEST_USER, + "password": _TEST_PASS, + }}) + + ftbot = get_patched_freqtradebot(mocker, default_conf) + mocker.patch('freqtrade.rpc.api_server.ApiServer.run', MagicMock()) + apiserver = ApiServer(ftbot) + yield ftbot, apiserver.app.test_client() + # Cleanup ... ? + + +def client_post(client, url, data={}): + return client.post(url, + content_type="application/json", + data=data, + headers={'Authorization': _basic_auth_str(_TEST_USER, _TEST_PASS)}) + + +def client_get(client, url): + return client.get(url, headers={'Authorization': _basic_auth_str(_TEST_USER, _TEST_PASS)}) + + +def assert_response(response, expected_code=200): + assert response.status_code == expected_code + assert response.content_type == "application/json" + + +def test_api_not_found(botclient): + ftbot, client = botclient + + rc = client_post(client, f"{BASE_URI}/invalid_url") + assert_response(rc, 404) + assert rc.json == {"status": "error", + "reason": f"There's no API call for http://localhost{BASE_URI}/invalid_url.", + "code": 404 + } + + +def test_api_unauthorized(botclient): + ftbot, client = botclient + # Don't send user/pass information + rc = client.get(f"{BASE_URI}/version") + assert_response(rc, 401) + assert rc.json == {'error': 'Unauthorized'} + + # Change only username + ftbot.config['api_server']['username'] = "Ftrader" + rc = client_get(client, f"{BASE_URI}/version") + assert_response(rc, 401) + assert rc.json == {'error': 'Unauthorized'} + + # Change only password + ftbot.config['api_server']['username'] = _TEST_USER + ftbot.config['api_server']['password'] = "WrongPassword" + rc = client_get(client, f"{BASE_URI}/version") + assert_response(rc, 401) + assert rc.json == {'error': 'Unauthorized'} + + ftbot.config['api_server']['username'] = "Ftrader" + ftbot.config['api_server']['password'] = "WrongPassword" + + rc = client_get(client, f"{BASE_URI}/version") + assert_response(rc, 401) + assert rc.json == {'error': 'Unauthorized'} + + +def test_api_stop_workflow(botclient): + ftbot, client = botclient + assert ftbot.state == State.RUNNING + rc = client_post(client, f"{BASE_URI}/stop") + assert_response(rc) + assert rc.json == {'status': 'stopping trader ...'} + assert ftbot.state == State.STOPPED + + # Stop bot again + rc = client_post(client, f"{BASE_URI}/stop") + assert_response(rc) + assert rc.json == {'status': 'already stopped'} + + # Start bot + rc = client_post(client, f"{BASE_URI}/start") + assert_response(rc) + assert rc.json == {'status': 'starting trader ...'} + assert ftbot.state == State.RUNNING + + # Call start again + rc = client_post(client, f"{BASE_URI}/start") + assert_response(rc) + assert rc.json == {'status': 'already running'} + + +def test_api__init__(default_conf, mocker): + """ + Test __init__() method + """ + mocker.patch('freqtrade.rpc.telegram.Updater', MagicMock()) + mocker.patch('freqtrade.rpc.api_server.ApiServer.run', MagicMock()) + + apiserver = ApiServer(get_patched_freqtradebot(mocker, default_conf)) + assert apiserver._config == default_conf + + +def test_api_run(default_conf, mocker, caplog): + default_conf.update({"api_server": {"enabled": True, + "listen_ip_address": "127.0.0.1", + "listen_port": "8080"}}) + mocker.patch('freqtrade.rpc.telegram.Updater', MagicMock()) + mocker.patch('freqtrade.rpc.api_server.threading.Thread', MagicMock()) + + server_mock = MagicMock() + mocker.patch('freqtrade.rpc.api_server.make_server', server_mock) + + apiserver = ApiServer(get_patched_freqtradebot(mocker, default_conf)) + + assert apiserver._config == default_conf + apiserver.run() + assert server_mock.call_count == 1 + assert server_mock.call_args_list[0][0][0] == "127.0.0.1" + assert server_mock.call_args_list[0][0][1] == "8080" + assert isinstance(server_mock.call_args_list[0][0][2], Flask) + assert hasattr(apiserver, "srv") + + assert log_has("Starting HTTP Server at 127.0.0.1:8080", caplog.record_tuples) + assert log_has("Starting Local Rest Server.", caplog.record_tuples) + + # Test binding to public + caplog.clear() + server_mock.reset_mock() + apiserver._config.update({"api_server": {"enabled": True, + "listen_ip_address": "0.0.0.0", + "listen_port": "8089", + "password": "", + }}) + apiserver.run() + + assert server_mock.call_count == 1 + assert server_mock.call_args_list[0][0][0] == "0.0.0.0" + assert server_mock.call_args_list[0][0][1] == "8089" + assert isinstance(server_mock.call_args_list[0][0][2], Flask) + assert log_has("Starting HTTP Server at 0.0.0.0:8089", caplog.record_tuples) + assert log_has("Starting Local Rest Server.", caplog.record_tuples) + assert log_has("SECURITY WARNING - Local Rest Server listening to external connections", + caplog.record_tuples) + assert log_has("SECURITY WARNING - This is insecure please set to your loopback," + "e.g 127.0.0.1 in config.json", + caplog.record_tuples) + assert log_has("SECURITY WARNING - No password for local REST Server defined. " + "Please make sure that this is intentional!", + caplog.record_tuples) + + # Test crashing flask + caplog.clear() + mocker.patch('freqtrade.rpc.api_server.make_server', MagicMock(side_effect=Exception)) + apiserver.run() + assert log_has("Api server failed to start.", caplog.record_tuples) + + +def test_api_cleanup(default_conf, mocker, caplog): + default_conf.update({"api_server": {"enabled": True, + "listen_ip_address": "127.0.0.1", + "listen_port": "8080"}}) + mocker.patch('freqtrade.rpc.telegram.Updater', MagicMock()) + mocker.patch('freqtrade.rpc.api_server.threading.Thread', MagicMock()) + mocker.patch('freqtrade.rpc.api_server.make_server', MagicMock()) + + apiserver = ApiServer(get_patched_freqtradebot(mocker, default_conf)) + apiserver.run() + stop_mock = MagicMock() + stop_mock.shutdown = MagicMock() + apiserver.srv = stop_mock + + apiserver.cleanup() + assert stop_mock.shutdown.call_count == 1 + assert log_has("Stopping API Server", caplog.record_tuples) + + +def test_api_reloadconf(botclient): + ftbot, client = botclient + + rc = client_post(client, f"{BASE_URI}/reload_conf") + assert_response(rc) + assert rc.json == {'status': 'reloading config ...'} + assert ftbot.state == State.RELOAD_CONF + + +def test_api_stopbuy(botclient): + ftbot, client = botclient + assert ftbot.config['max_open_trades'] != 0 + + rc = client_post(client, f"{BASE_URI}/stopbuy") + assert_response(rc) + assert rc.json == {'status': 'No more buy will occur from now. Run /reload_conf to reset.'} + assert ftbot.config['max_open_trades'] == 0 + + +def test_api_balance(botclient, mocker, rpc_balance): + ftbot, client = botclient + + def mock_ticker(symbol, refresh): + if symbol == 'BTC/USDT': + return { + 'bid': 10000.00, + 'ask': 10000.00, + 'last': 10000.00, + } + elif symbol == 'XRP/BTC': + return { + 'bid': 0.00001, + 'ask': 0.00001, + 'last': 0.00001, + } + return { + 'bid': 0.1, + 'ask': 0.1, + 'last': 0.1, + } + mocker.patch('freqtrade.exchange.Exchange.get_balances', return_value=rpc_balance) + mocker.patch('freqtrade.exchange.Exchange.get_ticker', side_effect=mock_ticker) + + rc = client_get(client, f"{BASE_URI}/balance") + assert_response(rc) + assert "currencies" in rc.json + assert len(rc.json["currencies"]) == 5 + assert rc.json['currencies'][0] == { + 'currency': 'BTC', + 'available': 12.0, + 'balance': 12.0, + 'pending': 0.0, + 'est_btc': 12.0, + } + + +def test_api_count(botclient, mocker, ticker, fee, markets): + ftbot, client = botclient + patch_get_signal(ftbot, (True, False)) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_balances=MagicMock(return_value=ticker), + get_ticker=ticker, + get_fee=fee, + markets=PropertyMock(return_value=markets) + ) + rc = client_get(client, f"{BASE_URI}/count") + assert_response(rc) + + assert rc.json["current"] == 0 + assert rc.json["max"] == 1.0 + + # Create some test data + ftbot.create_trade() + rc = client_get(client, f"{BASE_URI}/count") + assert_response(rc) + assert rc.json["current"] == 1.0 + assert rc.json["max"] == 1.0 + + +def test_api_daily(botclient, mocker, ticker, fee, markets): + ftbot, client = botclient + patch_get_signal(ftbot, (True, False)) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_balances=MagicMock(return_value=ticker), + get_ticker=ticker, + get_fee=fee, + markets=PropertyMock(return_value=markets) + ) + rc = client_get(client, f"{BASE_URI}/daily") + assert_response(rc) + assert len(rc.json) == 7 + assert rc.json[0][0] == str(datetime.utcnow().date()) + + +def test_api_edge_disabled(botclient, mocker, ticker, fee, markets): + ftbot, client = botclient + patch_get_signal(ftbot, (True, False)) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_balances=MagicMock(return_value=ticker), + get_ticker=ticker, + get_fee=fee, + markets=PropertyMock(return_value=markets) + ) + rc = client_get(client, f"{BASE_URI}/edge") + assert_response(rc, 502) + assert rc.json == {"error": "Error querying _edge: Edge is not enabled."} + + +def test_api_profit(botclient, mocker, ticker, fee, markets, limit_buy_order, limit_sell_order): + ftbot, client = botclient + patch_get_signal(ftbot, (True, False)) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_balances=MagicMock(return_value=ticker), + get_ticker=ticker, + get_fee=fee, + markets=PropertyMock(return_value=markets) + ) + + rc = client_get(client, f"{BASE_URI}/profit") + assert_response(rc, 502) + assert len(rc.json) == 1 + assert rc.json == {"error": "Error querying _profit: no closed trade"} + + ftbot.create_trade() + trade = Trade.query.first() + + # Simulate fulfilled LIMIT_BUY order for trade + trade.update(limit_buy_order) + rc = client_get(client, f"{BASE_URI}/profit") + assert_response(rc, 502) + assert rc.json == {"error": "Error querying _profit: no closed trade"} + + trade.update(limit_sell_order) + + trade.close_date = datetime.utcnow() + trade.is_open = False + + rc = client_get(client, f"{BASE_URI}/profit") + assert_response(rc) + assert rc.json == {'avg_duration': '0:00:00', + 'best_pair': 'ETH/BTC', + 'best_rate': 6.2, + 'first_trade_date': 'just now', + 'latest_trade_date': 'just now', + 'profit_all_coin': 6.217e-05, + 'profit_all_fiat': 0, + 'profit_all_percent': 6.2, + 'profit_closed_coin': 6.217e-05, + 'profit_closed_fiat': 0, + 'profit_closed_percent': 6.2, + 'trade_count': 1 + } + + +def test_api_performance(botclient, mocker, ticker, fee): + ftbot, client = botclient + patch_get_signal(ftbot, (True, False)) + + trade = Trade( + pair='LTC/ETH', + amount=1, + exchange='binance', + stake_amount=1, + open_rate=0.245441, + open_order_id="123456", + is_open=False, + fee_close=fee.return_value, + fee_open=fee.return_value, + close_rate=0.265441, + + ) + trade.close_profit = trade.calc_profit_percent() + Trade.session.add(trade) + + trade = Trade( + pair='XRP/ETH', + amount=5, + stake_amount=1, + exchange='binance', + open_rate=0.412, + open_order_id="123456", + is_open=False, + fee_close=fee.return_value, + fee_open=fee.return_value, + close_rate=0.391 + ) + trade.close_profit = trade.calc_profit_percent() + Trade.session.add(trade) + Trade.session.flush() + + rc = client_get(client, f"{BASE_URI}/performance") + assert_response(rc) + assert len(rc.json) == 2 + assert rc.json == [{'count': 1, 'pair': 'LTC/ETH', 'profit': 7.61}, + {'count': 1, 'pair': 'XRP/ETH', 'profit': -5.57}] + + +def test_api_status(botclient, mocker, ticker, fee, markets): + ftbot, client = botclient + patch_get_signal(ftbot, (True, False)) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_balances=MagicMock(return_value=ticker), + get_ticker=ticker, + get_fee=fee, + markets=PropertyMock(return_value=markets) + ) + + rc = client_get(client, f"{BASE_URI}/status") + assert_response(rc, 502) + assert rc.json == {'error': 'Error querying _status: no active trade'} + + ftbot.create_trade() + rc = client_get(client, f"{BASE_URI}/status") + assert_response(rc) + assert len(rc.json) == 1 + assert rc.json == [{'amount': 90.99181074, + 'base_currency': 'BTC', + 'close_date': None, + 'close_date_hum': None, + 'close_profit': None, + 'close_rate': None, + 'current_profit': -0.59, + 'current_rate': 1.098e-05, + 'initial_stop_loss': 0.0, + 'initial_stop_loss_pct': None, + 'open_date': ANY, + 'open_date_hum': 'just now', + 'open_order': '(limit buy rem=0.00000000)', + 'open_rate': 1.099e-05, + 'pair': 'ETH/BTC', + 'stake_amount': 0.001, + 'stop_loss': 0.0, + 'stop_loss_pct': None, + 'trade_id': 1}] + + +def test_api_version(botclient): + ftbot, client = botclient + + rc = client_get(client, f"{BASE_URI}/version") + assert_response(rc) + assert rc.json == {"version": __version__} + + +def test_api_blacklist(botclient, mocker): + ftbot, client = botclient + + rc = client_get(client, f"{BASE_URI}/blacklist") + assert_response(rc) + assert rc.json == {"blacklist": ["DOGE/BTC", "HOT/BTC"], + "length": 2, + "method": "StaticPairList"} + + # Add ETH/BTC to blacklist + rc = client_post(client, f"{BASE_URI}/blacklist", + data='{"blacklist": ["ETH/BTC"]}') + assert_response(rc) + assert rc.json == {"blacklist": ["DOGE/BTC", "HOT/BTC", "ETH/BTC"], + "length": 3, + "method": "StaticPairList"} + + +def test_api_whitelist(botclient): + ftbot, client = botclient + + rc = client_get(client, f"{BASE_URI}/whitelist") + assert_response(rc) + assert rc.json == {"whitelist": ['ETH/BTC', 'LTC/BTC', 'XRP/BTC', 'NEO/BTC'], + "length": 4, + "method": "StaticPairList"} + + +def test_api_forcebuy(botclient, mocker, fee): + ftbot, client = botclient + + rc = client_post(client, f"{BASE_URI}/forcebuy", + data='{"pair": "ETH/BTC"}') + assert_response(rc, 502) + assert rc.json == {"error": "Error querying _forcebuy: Forcebuy not enabled."} + + # enable forcebuy + ftbot.config["forcebuy_enable"] = True + + fbuy_mock = MagicMock(return_value=None) + mocker.patch("freqtrade.rpc.RPC._rpc_forcebuy", fbuy_mock) + rc = client_post(client, f"{BASE_URI}/forcebuy", + data='{"pair": "ETH/BTC"}') + assert_response(rc) + assert rc.json == {"status": "Error buying pair ETH/BTC."} + + # Test creating trae + fbuy_mock = MagicMock(return_value=Trade( + pair='ETH/ETH', + amount=1, + exchange='bittrex', + stake_amount=1, + open_rate=0.245441, + open_order_id="123456", + open_date=datetime.utcnow(), + is_open=False, + fee_close=fee.return_value, + fee_open=fee.return_value, + close_rate=0.265441, + )) + mocker.patch("freqtrade.rpc.RPC._rpc_forcebuy", fbuy_mock) + + rc = client_post(client, f"{BASE_URI}/forcebuy", + data='{"pair": "ETH/BTC"}') + assert_response(rc) + assert rc.json == {'amount': 1, + 'close_date': None, + 'close_date_hum': None, + 'close_rate': 0.265441, + 'initial_stop_loss': None, + 'initial_stop_loss_pct': None, + 'open_date': ANY, + 'open_date_hum': 'just now', + 'open_rate': 0.245441, + 'pair': 'ETH/ETH', + 'stake_amount': 1, + 'stop_loss': None, + 'stop_loss_pct': None, + 'trade_id': None} + + +def test_api_forcesell(botclient, mocker, ticker, fee, markets): + ftbot, client = botclient + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_balances=MagicMock(return_value=ticker), + get_ticker=ticker, + get_fee=fee, + markets=PropertyMock(return_value=markets) + ) + patch_get_signal(ftbot, (True, False)) + + rc = client_post(client, f"{BASE_URI}/forcesell", + data='{"tradeid": "1"}') + assert_response(rc, 502) + assert rc.json == {"error": "Error querying _forcesell: invalid argument"} + + ftbot.create_trade() + + rc = client_post(client, f"{BASE_URI}/forcesell", + data='{"tradeid": "1"}') + assert_response(rc) + assert rc.json == {'result': 'Created sell order for trade 1.'} diff --git a/freqtrade/tests/rpc/test_rpc_manager.py b/freqtrade/tests/rpc/test_rpc_manager.py index 15d9c20c6..91fd2297f 100644 --- a/freqtrade/tests/rpc/test_rpc_manager.py +++ b/freqtrade/tests/rpc/test_rpc_manager.py @@ -135,3 +135,32 @@ def test_startupmessages_telegram_enabled(mocker, default_conf, caplog) -> None: rpc_manager.startup_messages(default_conf, freqtradebot.pairlists) assert telegram_mock.call_count == 3 assert "Dry run is enabled." in telegram_mock.call_args_list[0][0][0]['status'] + + +def test_init_apiserver_disabled(mocker, default_conf, caplog) -> None: + caplog.set_level(logging.DEBUG) + run_mock = MagicMock() + mocker.patch('freqtrade.rpc.api_server.ApiServer.run', run_mock) + default_conf['telegram']['enabled'] = False + rpc_manager = RPCManager(get_patched_freqtradebot(mocker, default_conf)) + + assert not log_has('Enabling rpc.api_server', caplog.record_tuples) + assert rpc_manager.registered_modules == [] + assert run_mock.call_count == 0 + + +def test_init_apiserver_enabled(mocker, default_conf, caplog) -> None: + caplog.set_level(logging.DEBUG) + run_mock = MagicMock() + mocker.patch('freqtrade.rpc.api_server.ApiServer.run', run_mock) + + default_conf["telegram"]["enabled"] = False + default_conf["api_server"] = {"enabled": True, + "listen_ip_address": "127.0.0.1", + "listen_port": "8080"} + rpc_manager = RPCManager(get_patched_freqtradebot(mocker, default_conf)) + + assert log_has('Enabling rpc.api_server', caplog.record_tuples) + assert len(rpc_manager.registered_modules) == 1 + assert 'apiserver' in [mod.name for mod in rpc_manager.registered_modules] + assert run_mock.call_count == 1 diff --git a/freqtrade/tests/rpc/test_rpc_telegram.py b/freqtrade/tests/rpc/test_rpc_telegram.py index f2f3f3945..b34e214af 100644 --- a/freqtrade/tests/rpc/test_rpc_telegram.py +++ b/freqtrade/tests/rpc/test_rpc_telegram.py @@ -22,8 +22,7 @@ from freqtrade.rpc.telegram import Telegram, authorized_only from freqtrade.state import State from freqtrade.strategy.interface import SellType from freqtrade.tests.conftest import (get_patched_freqtradebot, log_has, - patch_exchange) -from freqtrade.tests.test_freqtradebot import patch_get_signal + patch_exchange, patch_get_signal) class DummyCls(Telegram): @@ -192,7 +191,10 @@ def test_status(default_conf, update, mocker, fee, ticker, markets) -> None: 'trade_id': 1, 'pair': 'ETH/BTC', 'base_currency': 'BTC', - 'date': arrow.utcnow(), + 'open_date': arrow.utcnow(), + 'open_date_hum': arrow.utcnow().humanize, + 'close_date': None, + 'close_date_hum': None, 'open_rate': 1.099e-05, 'close_rate': None, 'current_rate': 1.098e-05, @@ -493,34 +495,7 @@ def test_profit_handle(default_conf, update, ticker, ticker_sell_up, fee, assert '*Best Performing:* `ETH/BTC: 6.20%`' in msg_mock.call_args_list[-1][0][0] -def test_telegram_balance_handle(default_conf, update, mocker) -> None: - mock_balance = { - 'BTC': { - 'total': 12.0, - 'free': 12.0, - 'used': 0.0 - }, - 'ETH': { - 'total': 0.0, - 'free': 0.0, - 'used': 0.0 - }, - 'USDT': { - 'total': 10000.0, - 'free': 10000.0, - 'used': 0.0 - }, - 'LTC': { - 'total': 10.0, - 'free': 10.0, - 'used': 0.0 - }, - 'XRP': { - 'total': 1.0, - 'free': 1.0, - 'used': 0.0 - } - } +def test_telegram_balance_handle(default_conf, update, mocker, rpc_balance) -> None: def mock_ticker(symbol, refresh): if symbol == 'BTC/USDT': @@ -541,7 +516,7 @@ def test_telegram_balance_handle(default_conf, update, mocker) -> None: 'last': 0.1, } - mocker.patch('freqtrade.exchange.Exchange.get_balances', return_value=mock_balance) + mocker.patch('freqtrade.exchange.Exchange.get_balances', return_value=rpc_balance) mocker.patch('freqtrade.exchange.Exchange.get_ticker', side_effect=mock_ticker) msg_mock = MagicMock() @@ -562,6 +537,7 @@ def test_telegram_balance_handle(default_conf, update, mocker) -> None: assert '*BTC:*' in result assert '*ETH:*' not in result assert '*USDT:*' in result + assert '*EUR:*' in result assert 'Balance:' in result assert 'Est. BTC:' in result assert 'BTC: 12.00000000' in result @@ -780,6 +756,7 @@ def test_forcesell_handle(default_conf, update, ticker, fee, 'gain': 'profit', 'limit': 1.172e-05, 'amount': 90.99181073703367, + 'order_type': 'limit', 'open_rate': 1.099e-05, 'current_rate': 1.172e-05, 'profit_amount': 6.126e-05, @@ -834,6 +811,7 @@ def test_forcesell_down_handle(default_conf, update, ticker, fee, 'gain': 'loss', 'limit': 1.044e-05, 'amount': 90.99181073703367, + 'order_type': 'limit', 'open_rate': 1.099e-05, 'current_rate': 1.044e-05, 'profit_amount': -5.492e-05, @@ -879,6 +857,7 @@ def test_forcesell_all_handle(default_conf, update, ticker, fee, markets, mocker 'gain': 'loss', 'limit': 1.098e-05, 'amount': 90.99181073703367, + 'order_type': 'limit', 'open_rate': 1.099e-05, 'current_rate': 1.098e-05, 'profit_amount': -5.91e-06, @@ -1212,6 +1191,7 @@ def test_send_msg_buy_notification(default_conf, mocker) -> None: 'exchange': 'Bittrex', 'pair': 'ETH/BTC', 'limit': 1.099e-05, + 'order_type': 'limit', 'stake_amount': 0.001, 'stake_amount_fiat': 0.0, 'stake_currency': 'BTC', @@ -1219,7 +1199,7 @@ def test_send_msg_buy_notification(default_conf, mocker) -> None: }) assert msg_mock.call_args[0][0] \ == '*Bittrex:* Buying ETH/BTC\n' \ - 'with limit `0.00001099\n' \ + 'at rate `0.00001099\n' \ '(0.001000 BTC,0.000 USD)`' @@ -1241,6 +1221,7 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None: 'gain': 'loss', 'limit': 3.201e-05, 'amount': 1333.3333333333335, + 'order_type': 'market', 'open_rate': 7.5e-05, 'current_rate': 3.201e-05, 'profit_amount': -0.05746268, @@ -1251,7 +1232,7 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None: }) assert msg_mock.call_args[0][0] \ == ('*Binance:* Selling KEY/ETH\n' - '*Limit:* `0.00003201`\n' + '*Rate:* `0.00003201`\n' '*Amount:* `1333.33333333`\n' '*Open Rate:* `0.00007500`\n' '*Current Rate:* `0.00003201`\n' @@ -1266,6 +1247,7 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None: 'gain': 'loss', 'limit': 3.201e-05, 'amount': 1333.3333333333335, + 'order_type': 'market', 'open_rate': 7.5e-05, 'current_rate': 3.201e-05, 'profit_amount': -0.05746268, @@ -1275,7 +1257,7 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None: }) assert msg_mock.call_args[0][0] \ == ('*Binance:* Selling KEY/ETH\n' - '*Limit:* `0.00003201`\n' + '*Rate:* `0.00003201`\n' '*Amount:* `1333.33333333`\n' '*Open Rate:* `0.00007500`\n' '*Current Rate:* `0.00003201`\n' @@ -1363,6 +1345,7 @@ def test_send_msg_buy_notification_no_fiat(default_conf, mocker) -> None: 'exchange': 'Bittrex', 'pair': 'ETH/BTC', 'limit': 1.099e-05, + 'order_type': 'limit', 'stake_amount': 0.001, 'stake_amount_fiat': 0.0, 'stake_currency': 'BTC', @@ -1370,7 +1353,7 @@ def test_send_msg_buy_notification_no_fiat(default_conf, mocker) -> None: }) assert msg_mock.call_args[0][0] \ == '*Bittrex:* Buying ETH/BTC\n' \ - 'with limit `0.00001099\n' \ + 'at rate `0.00001099\n' \ '(0.001000 BTC)`' @@ -1391,6 +1374,7 @@ def test_send_msg_sell_notification_no_fiat(default_conf, mocker) -> None: 'gain': 'loss', 'limit': 3.201e-05, 'amount': 1333.3333333333335, + 'order_type': 'limit', 'open_rate': 7.5e-05, 'current_rate': 3.201e-05, 'profit_amount': -0.05746268, @@ -1401,7 +1385,7 @@ def test_send_msg_sell_notification_no_fiat(default_conf, mocker) -> None: }) assert msg_mock.call_args[0][0] \ == '*Binance:* Selling KEY/ETH\n' \ - '*Limit:* `0.00003201`\n' \ + '*Rate:* `0.00003201`\n' \ '*Amount:* `1333.33333333`\n' \ '*Open Rate:* `0.00007500`\n' \ '*Current Rate:* `0.00003201`\n' \ diff --git a/freqtrade/tests/rpc/test_rpc_webhook.py b/freqtrade/tests/rpc/test_rpc_webhook.py index da7aec0a6..a2dcd9b31 100644 --- a/freqtrade/tests/rpc/test_rpc_webhook.py +++ b/freqtrade/tests/rpc/test_rpc_webhook.py @@ -74,6 +74,7 @@ def test_send_msg(default_conf, mocker): 'gain': "profit", 'limit': 0.005, 'amount': 0.8, + 'order_type': 'limit', 'open_rate': 0.004, 'current_rate': 0.005, 'profit_amount': 0.001, @@ -126,6 +127,7 @@ def test_exception_send_msg(default_conf, mocker, caplog): 'exchange': 'Bittrex', 'pair': 'ETH/BTC', 'limit': 0.005, + 'order_type': 'limit', 'stake_amount': 0.8, 'stake_amount_fiat': 500, 'stake_currency': 'BTC', diff --git a/freqtrade/tests/strategy/test_default_strategy.py b/freqtrade/tests/strategy/test_default_strategy.py index be514f2d1..74c81882a 100644 --- a/freqtrade/tests/strategy/test_default_strategy.py +++ b/freqtrade/tests/strategy/test_default_strategy.py @@ -10,7 +10,8 @@ from freqtrade.strategy.default_strategy import DefaultStrategy @pytest.fixture def result(): with open('freqtrade/tests/testdata/ETH_BTC-1m.json') as data_file: - return parse_ticker_dataframe(json.load(data_file), '1m', fill_missing=True) + return parse_ticker_dataframe(json.load(data_file), '1m', pair="UNITTEST/BTC", + fill_missing=True) def test_default_strategy_structure(): diff --git a/freqtrade/tests/strategy/test_interface.py b/freqtrade/tests/strategy/test_interface.py index d6ef0c8e7..fe7fd2193 100644 --- a/freqtrade/tests/strategy/test_interface.py +++ b/freqtrade/tests/strategy/test_interface.py @@ -111,7 +111,8 @@ def test_tickerdata_to_dataframe(default_conf) -> None: timerange = TimeRange(None, 'line', 0, -100) tick = load_tickerdata_file(None, 'UNITTEST/BTC', '1m', timerange=timerange) - tickerlist = {'UNITTEST/BTC': parse_ticker_dataframe(tick, '1m', True)} + tickerlist = {'UNITTEST/BTC': parse_ticker_dataframe(tick, '1m', pair="UNITTEST/BTC", + fill_missing=True)} data = strategy.tickerdata_to_dataframe(tickerlist) assert len(data['UNITTEST/BTC']) == 102 # partial candle was removed diff --git a/freqtrade/tests/strategy/test_strategy.py b/freqtrade/tests/strategy/test_strategy.py index 2ed2567f9..15d1c18ef 100644 --- a/freqtrade/tests/strategy/test_strategy.py +++ b/freqtrade/tests/strategy/test_strategy.py @@ -63,27 +63,22 @@ def test_search_strategy(): def test_load_strategy(result): resolver = StrategyResolver({'strategy': 'TestStrategy'}) - metadata = {'pair': 'ETH/BTC'} - assert 'adx' in resolver.strategy.advise_indicators(result, metadata=metadata) + assert 'adx' in resolver.strategy.advise_indicators(result, {'pair': 'ETH/BTC'}) def test_load_strategy_byte64(result): with open("freqtrade/tests/strategy/test_strategy.py", "r") as file: encoded_string = urlsafe_b64encode(file.read().encode("utf-8")).decode("utf-8") resolver = StrategyResolver({'strategy': 'TestStrategy:{}'.format(encoded_string)}) - assert 'adx' in resolver.strategy.advise_indicators(result, 'ETH/BTC') + assert 'adx' in resolver.strategy.advise_indicators(result, {'pair': 'ETH/BTC'}) def test_load_strategy_invalid_directory(result, caplog): resolver = StrategyResolver() - extra_dir = path.join('some', 'path') + extra_dir = Path.cwd() / 'some/path' resolver._load_strategy('TestStrategy', config={}, extra_dir=extra_dir) - assert ( - 'freqtrade.resolvers.strategy_resolver', - logging.WARNING, - 'Path "{}" does not exist'.format(extra_dir), - ) in caplog.record_tuples + assert log_has_re(r'Path .*' + r'some.*path.*' + r'.* does not exist', caplog.record_tuples) assert 'adx' in resolver.strategy.advise_indicators(result, {'pair': 'ETH/BTC'}) @@ -371,7 +366,7 @@ def test_deprecate_populate_indicators(result): with warnings.catch_warnings(record=True) as w: # Cause all warnings to always be triggered. warnings.simplefilter("always") - indicators = resolver.strategy.advise_indicators(result, 'ETH/BTC') + indicators = resolver.strategy.advise_indicators(result, {'pair': 'ETH/BTC'}) assert len(w) == 1 assert issubclass(w[-1].category, DeprecationWarning) assert "deprecated - check out the Sample strategy to see the current function headers!" \ @@ -380,7 +375,7 @@ def test_deprecate_populate_indicators(result): with warnings.catch_warnings(record=True) as w: # Cause all warnings to always be triggered. warnings.simplefilter("always") - resolver.strategy.advise_buy(indicators, 'ETH/BTC') + resolver.strategy.advise_buy(indicators, {'pair': 'ETH/BTC'}) assert len(w) == 1 assert issubclass(w[-1].category, DeprecationWarning) assert "deprecated - check out the Sample strategy to see the current function headers!" \ @@ -389,7 +384,7 @@ def test_deprecate_populate_indicators(result): with warnings.catch_warnings(record=True) as w: # Cause all warnings to always be triggered. warnings.simplefilter("always") - resolver.strategy.advise_sell(indicators, 'ETH_BTC') + resolver.strategy.advise_sell(indicators, {'pair': 'ETH_BTC'}) assert len(w) == 1 assert issubclass(w[-1].category, DeprecationWarning) assert "deprecated - check out the Sample strategy to see the current function headers!" \ diff --git a/freqtrade/tests/test_arguments.py b/freqtrade/tests/test_arguments.py index 0952d1c5d..d9292bdb5 100644 --- a/freqtrade/tests/test_arguments.py +++ b/freqtrade/tests/test_arguments.py @@ -1,5 +1,4 @@ # pragma pylint: disable=missing-docstring, C0103 - import argparse import pytest @@ -48,9 +47,9 @@ def test_parse_args_verbose() -> None: assert args.loglevel == 1 -def test_scripts_options() -> None: +def test_common_scripts_options() -> None: arguments = Arguments(['-p', 'ETH/BTC'], '') - arguments.scripts_options() + arguments.common_scripts_options() args = arguments.get_parsed_arg() assert args.pairs == 'ETH/BTC' @@ -171,17 +170,54 @@ def test_parse_args_hyperopt_custom() -> None: assert call_args.func is not None -def test_testdata_dl_options() -> None: +def test_download_data_options() -> None: args = [ '--pairs-file', 'file_with_pairs', - '--export', 'export/folder', + '--datadir', 'datadir/folder', '--days', '30', '--exchange', 'binance' ] arguments = Arguments(args, '') - arguments.testdata_dl_options() + arguments.common_options() + arguments.download_data_options() args = arguments.parse_args() assert args.pairs_file == 'file_with_pairs' - assert args.export == 'export/folder' + assert args.datadir == 'datadir/folder' assert args.days == 30 assert args.exchange == 'binance' + + +def test_plot_dataframe_options() -> None: + args = [ + '--indicators1', 'sma10,sma100', + '--indicators2', 'macd,fastd,fastk', + '--plot-limit', '30', + '-p', 'UNITTEST/BTC', + ] + arguments = Arguments(args, '') + arguments.common_scripts_options() + arguments.plot_dataframe_options() + pargs = arguments.parse_args(True) + assert pargs.indicators1 == "sma10,sma100" + assert pargs.indicators2 == "macd,fastd,fastk" + assert pargs.plot_limit == 30 + assert pargs.pairs == "UNITTEST/BTC" + + +def test_check_int_positive() -> None: + + assert Arguments.check_int_positive("3") == 3 + assert Arguments.check_int_positive("1") == 1 + assert Arguments.check_int_positive("100") == 100 + + with pytest.raises(argparse.ArgumentTypeError): + Arguments.check_int_positive("-2") + + with pytest.raises(argparse.ArgumentTypeError): + Arguments.check_int_positive("0") + + with pytest.raises(argparse.ArgumentTypeError): + Arguments.check_int_positive("3.5") + + with pytest.raises(argparse.ArgumentTypeError): + Arguments.check_int_positive("DeadBeef") diff --git a/freqtrade/tests/test_configuration.py b/freqtrade/tests/test_configuration.py index bcd0bd92c..38f17fbea 100644 --- a/freqtrade/tests/test_configuration.py +++ b/freqtrade/tests/test_configuration.py @@ -15,7 +15,16 @@ from freqtrade.arguments import Arguments from freqtrade.configuration import Configuration, set_loggers from freqtrade.constants import DEFAULT_DB_DRYRUN_URL, DEFAULT_DB_PROD_URL from freqtrade.state import RunMode -from freqtrade.tests.conftest import log_has +from freqtrade.tests.conftest import log_has, log_has_re + + +@pytest.fixture(scope="function") +def all_conf(): + config_file = Path(__file__).parents[2] / "config_full.json.example" + print(config_file) + configuration = Configuration(Namespace()) + conf = configuration._load_config_file(str(config_file)) + return conf def test_load_config_invalid_pair(default_conf) -> None: @@ -351,11 +360,8 @@ def test_setup_configuration_with_arguments(mocker, default_conf, caplog) -> Non caplog.record_tuples ) assert 'ticker_interval' in config - assert log_has('Parameter -i/--ticker-interval detected ...', caplog.record_tuples) - assert log_has( - 'Using ticker_interval: 1m ...', - caplog.record_tuples - ) + assert log_has('Parameter -i/--ticker-interval detected ... Using ticker_interval: 1m ...', + caplog.record_tuples) assert 'live' in config assert log_has('Parameter -l/--live detected ...', caplog.record_tuples) @@ -416,11 +422,8 @@ def test_setup_configuration_with_stratlist(mocker, default_conf, caplog) -> Non caplog.record_tuples ) assert 'ticker_interval' in config - assert log_has('Parameter -i/--ticker-interval detected ...', caplog.record_tuples) - assert log_has( - 'Using ticker_interval: 1m ...', - caplog.record_tuples - ) + assert log_has('Parameter -i/--ticker-interval detected ... Using ticker_interval: 1m ...', + caplog.record_tuples) assert 'strategy_list' in config assert log_has('Using strategy list of 2 Strategies', caplog.record_tuples) @@ -454,8 +457,8 @@ def test_hyperopt_with_arguments(mocker, default_conf, caplog) -> None: assert 'epochs' in config assert int(config['epochs']) == 10 - assert log_has('Parameter --epochs detected ...', caplog.record_tuples) - assert log_has('Will run Hyperopt with for 10 epochs ...', caplog.record_tuples) + assert log_has('Parameter --epochs detected ... Will run Hyperopt with for 10 epochs ...', + caplog.record_tuples) assert 'spaces' in config assert config['spaces'] == ['all'] @@ -467,21 +470,52 @@ def test_hyperopt_with_arguments(mocker, default_conf, caplog) -> None: def test_check_exchange(default_conf, caplog) -> None: configuration = Configuration(Namespace()) - # Test a valid exchange + # Test an officially supported by Freqtrade team exchange default_conf.get('exchange').update({'name': 'BITTREX'}) assert configuration.check_exchange(default_conf) + assert log_has_re(r"Exchange .* is officially supported by the Freqtrade development team\.", + caplog.record_tuples) + caplog.clear() - # Test a valid exchange + # Test an officially supported by Freqtrade team exchange default_conf.get('exchange').update({'name': 'binance'}) assert configuration.check_exchange(default_conf) + assert log_has_re(r"Exchange .* is officially supported by the Freqtrade development team\.", + caplog.record_tuples) + caplog.clear() - # Test a invalid exchange + # Test an available exchange, supported by ccxt + default_conf.get('exchange').update({'name': 'kraken'}) + assert configuration.check_exchange(default_conf) + assert log_has_re(r"Exchange .* is supported by ccxt and .* not officially supported " + r"by the Freqtrade development team\. .*", + caplog.record_tuples) + caplog.clear() + + # Test a 'bad' exchange, which known to have serious problems + default_conf.get('exchange').update({'name': 'bitmex'}) + assert not configuration.check_exchange(default_conf) + assert log_has_re(r"Exchange .* is known to not work with the bot yet\. " + r"Use it only for development and testing purposes\.", + caplog.record_tuples) + caplog.clear() + + # Test a 'bad' exchange with check_for_bad=False + default_conf.get('exchange').update({'name': 'bitmex'}) + assert configuration.check_exchange(default_conf, False) + assert log_has_re(r"Exchange .* is supported by ccxt and .* not officially supported " + r"by the Freqtrade development team\. .*", + caplog.record_tuples) + caplog.clear() + + # Test an invalid exchange default_conf.get('exchange').update({'name': 'unknown_exchange'}) configuration.config = default_conf with pytest.raises( OperationalException, - match=r'.*Exchange "unknown_exchange" not supported.*' + match=r'.*Exchange "unknown_exchange" is not supported by ccxt ' + r'and therefore not available for the bot.*' ): configuration.check_exchange(default_conf) @@ -608,3 +642,59 @@ def test_validate_tsl(default_conf): default_conf['trailing_stop_positive_offset'] = 0.015 Configuration(Namespace()) configuration._validate_config_consistency(default_conf) + + +def test_load_config_default_exchange(all_conf) -> None: + """ + config['exchange'] subtree has required options in it + so it cannot be omitted in the config + """ + del all_conf['exchange'] + + assert 'exchange' not in all_conf + + with pytest.raises(ValidationError, + match=r'\'exchange\' is a required property'): + configuration = Configuration(Namespace()) + configuration._validate_config_schema(all_conf) + + +def test_load_config_default_exchange_name(all_conf) -> None: + """ + config['exchange']['name'] option is required + so it cannot be omitted in the config + """ + del all_conf['exchange']['name'] + + assert 'name' not in all_conf['exchange'] + + with pytest.raises(ValidationError, + match=r'\'name\' is a required property'): + configuration = Configuration(Namespace()) + configuration._validate_config_schema(all_conf) + + +@pytest.mark.parametrize("keys", [("exchange", "sandbox", False), + ("exchange", "key", ""), + ("exchange", "secret", ""), + ("exchange", "password", ""), + ]) +def test_load_config_default_subkeys(all_conf, keys) -> None: + """ + Test for parameters with default values in sub-paths + so they can be omitted in the config and the default value + should is added to the config. + """ + # Get first level key + key = keys[0] + # get second level key + subkey = keys[1] + + del all_conf[key][subkey] + + assert subkey not in all_conf[key] + + configuration = Configuration(Namespace()) + configuration._validate_config_schema(all_conf) + assert subkey in all_conf[key] + assert all_conf[key][subkey] == keys[2] diff --git a/freqtrade/tests/test_freqtradebot.py b/freqtrade/tests/test_freqtradebot.py index 103c0777e..87b344853 100644 --- a/freqtrade/tests/test_freqtradebot.py +++ b/freqtrade/tests/test_freqtradebot.py @@ -11,64 +11,21 @@ import arrow import pytest import requests -from freqtrade import (DependencyException, OperationalException, - TemporaryError, InvalidOrderException, constants) +from freqtrade import (DependencyException, InvalidOrderException, + OperationalException, TemporaryError, constants) from freqtrade.data.dataprovider import DataProvider from freqtrade.freqtradebot import FreqtradeBot from freqtrade.persistence import Trade from freqtrade.rpc import RPCMessageType from freqtrade.state import State from freqtrade.strategy.interface import SellCheckTuple, SellType -from freqtrade.tests.conftest import (log_has, log_has_re, patch_edge, - patch_exchange, patch_wallet) +from freqtrade.tests.conftest import (get_patched_freqtradebot, + get_patched_worker, log_has, log_has_re, + patch_edge, patch_exchange, + patch_get_signal, patch_wallet) from freqtrade.worker import Worker -# Functions for recurrent object patching -def patch_freqtradebot(mocker, config) -> None: - """ - This function patches _init_modules() to not call dependencies - :param mocker: a Mocker object to apply patches - :param config: Config to pass to the bot - :return: None - """ - mocker.patch('freqtrade.freqtradebot.RPCManager', MagicMock()) - mocker.patch('freqtrade.freqtradebot.persistence.init', MagicMock()) - patch_exchange(mocker) - - -def get_patched_freqtradebot(mocker, config) -> FreqtradeBot: - """ - This function patches _init_modules() to not call dependencies - :param mocker: a Mocker object to apply patches - :param config: Config to pass to the bot - :return: FreqtradeBot - """ - patch_freqtradebot(mocker, config) - return FreqtradeBot(config) - - -def get_patched_worker(mocker, config) -> Worker: - """ - This function patches _init_modules() to not call dependencies - :param mocker: a Mocker object to apply patches - :param config: Config to pass to the bot - :return: Worker - """ - patch_freqtradebot(mocker, config) - return Worker(args=None, config=config) - - -def patch_get_signal(freqtrade: FreqtradeBot, value=(True, False)) -> None: - """ - :param mocker: mocker to patch IStrategy class - :param value: which value IStrategy.get_signal() must return - :return: None - """ - freqtrade.strategy.get_signal = lambda e, s, t: value - freqtrade.exchange.refresh_latest_ohlcv = lambda p: None - - def patch_RPCManager(mocker) -> MagicMock: """ This function mock RPC manager to avoid repeating this code in almost every tests @@ -114,6 +71,7 @@ def test_cleanup(mocker, default_conf, caplog) -> None: def test_worker_running(mocker, default_conf, caplog) -> None: mock_throttle = MagicMock() mocker.patch('freqtrade.worker.Worker._throttle', mock_throttle) + mocker.patch('freqtrade.persistence.Trade.stoploss_reinitialization', MagicMock()) worker = get_patched_worker(mocker, default_conf) @@ -1184,6 +1142,77 @@ def test_handle_stoploss_on_exchange_trailing(mocker, default_conf, fee, caplog, stop_price=0.00002344 * 0.95) +def test_handle_stoploss_on_exchange_trailing_error(mocker, default_conf, fee, caplog, + markets, limit_buy_order, + limit_sell_order) -> None: + # When trailing stoploss is set + stoploss_limit = MagicMock(return_value={'id': 13434334}) + patch_exchange(mocker) + + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + get_ticker=MagicMock(return_value={ + 'bid': 0.00001172, + 'ask': 0.00001173, + 'last': 0.00001172 + }), + buy=MagicMock(return_value={'id': limit_buy_order['id']}), + sell=MagicMock(return_value={'id': limit_sell_order['id']}), + get_fee=fee, + markets=PropertyMock(return_value=markets), + stoploss_limit=stoploss_limit + ) + + # enabling TSL + default_conf['trailing_stop'] = True + + freqtrade = get_patched_freqtradebot(mocker, default_conf) + # enabling stoploss on exchange + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + + # setting stoploss + freqtrade.strategy.stoploss = -0.05 + + # setting stoploss_on_exchange_interval to 60 seconds + freqtrade.strategy.order_types['stoploss_on_exchange_interval'] = 60 + patch_get_signal(freqtrade) + freqtrade.create_trade() + trade = Trade.query.first() + trade.is_open = True + trade.open_order_id = None + trade.stoploss_order_id = "abcd" + trade.stop_loss = 0.2 + trade.stoploss_last_update = arrow.utcnow().shift(minutes=-601).datetime.replace(tzinfo=None) + + stoploss_order_hanging = { + 'id': "abcd", + 'status': 'open', + 'type': 'stop_loss_limit', + 'price': 3, + 'average': 2, + 'info': { + 'stopPrice': '0.1' + } + } + mocker.patch('freqtrade.exchange.Exchange.cancel_order', side_effect=InvalidOrderException()) + mocker.patch('freqtrade.exchange.Exchange.get_order', stoploss_order_hanging) + freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging) + assert log_has_re(r"Could not cancel stoploss order abcd for pair ETH/BTC.*", + caplog.record_tuples) + + # Still try to create order + assert stoploss_limit.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()) + freqtrade.handle_trailing_stoploss_on_exchange(trade, stoploss_order_hanging) + assert cancel_mock.call_count == 1 + assert log_has_re(r"Could create trailing stoploss order for pair ETH/BTC\..*", + caplog.record_tuples) + + def test_tsl_on_exchange_compatible_with_edge(mocker, edge_conf, fee, caplog, markets, limit_buy_order, limit_sell_order) -> None: @@ -1407,7 +1436,8 @@ def test_update_trade_state_withorderdict(default_conf, trades_for_order, limit_ amount=amount, exchange='binance', open_rate=0.245441, - open_order_id="123456" + open_order_id="123456", + is_open=True, ) freqtrade.update_trade_state(trade, limit_buy_order) assert trade.amount != amount @@ -1432,6 +1462,35 @@ def test_update_trade_state_exception(mocker, default_conf, assert log_has('Could not update trade amount: ', caplog.record_tuples) +def test_update_trade_state_sell(default_conf, trades_for_order, limit_sell_order, mocker): + mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=trades_for_order) + # get_order should not be called!! + mocker.patch('freqtrade.exchange.Exchange.get_order', MagicMock(side_effect=ValueError)) + wallet_mock = MagicMock() + mocker.patch('freqtrade.wallets.Wallets.update', wallet_mock) + + patch_exchange(mocker) + Trade.session = MagicMock() + amount = limit_sell_order["amount"] + freqtrade = get_patched_freqtradebot(mocker, default_conf) + wallet_mock.reset_mock() + trade = Trade( + pair='LTC/ETH', + amount=amount, + exchange='binance', + open_rate=0.245441, + fee_open=0.0025, + fee_close=0.0025, + open_order_id="123456", + is_open=True, + ) + freqtrade.update_trade_state(trade, limit_sell_order) + assert trade.amount == limit_sell_order['amount'] + # Wallet needs to be updated after closing a limit-sell order to reenable buying + assert wallet_mock.call_count == 1 + assert not trade.is_open + + def test_handle_trade(default_conf, limit_buy_order, limit_sell_order, fee, markets, mocker) -> None: patch_RPCManager(mocker) @@ -1972,6 +2031,7 @@ def test_execute_sell_up(default_conf, ticker, fee, ticker_sell_up, markets, moc 'gain': 'profit', 'limit': 1.172e-05, 'amount': 90.99181073703367, + 'order_type': 'limit', 'open_rate': 1.099e-05, 'current_rate': 1.172e-05, 'profit_amount': 6.126e-05, @@ -2018,6 +2078,7 @@ def test_execute_sell_down(default_conf, ticker, fee, ticker_sell_down, markets, 'gain': 'loss', 'limit': 1.044e-05, 'amount': 90.99181073703367, + 'order_type': 'limit', 'open_rate': 1.099e-05, 'current_rate': 1.044e-05, 'profit_amount': -5.492e-05, @@ -2072,6 +2133,7 @@ def test_execute_sell_down_stoploss_on_exchange_dry_run(default_conf, ticker, fe 'gain': 'loss', 'limit': 1.08801e-05, 'amount': 90.99181073703367, + 'order_type': 'limit', 'open_rate': 1.099e-05, 'current_rate': 1.044e-05, 'profit_amount': -1.498e-05, @@ -2083,6 +2145,36 @@ def test_execute_sell_down_stoploss_on_exchange_dry_run(default_conf, ticker, fe } == last_msg +def test_execute_sell_sloe_cancel_exception(mocker, default_conf, ticker, fee, + markets, caplog) -> None: + freqtrade = get_patched_freqtradebot(mocker, default_conf) + mocker.patch('freqtrade.exchange.Exchange.cancel_order', side_effect=InvalidOrderException()) + sellmock = MagicMock() + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + _load_markets=MagicMock(return_value={}), + get_ticker=ticker, + get_fee=fee, + markets=PropertyMock(return_value=markets), + sell=sellmock + ) + + freqtrade.strategy.order_types['stoploss_on_exchange'] = True + patch_get_signal(freqtrade) + freqtrade.create_trade() + + trade = Trade.query.first() + Trade.session = MagicMock() + + freqtrade.config['dry_run'] = False + trade.stoploss_order_id = "abcd" + + freqtrade.execute_sell(trade=trade, limit=1234, + sell_reason=SellType.STOP_LOSS) + assert sellmock.call_count == 1 + assert log_has('Could not cancel stoploss order abcd', caplog.record_tuples) + + def test_execute_sell_with_stoploss_on_exchange(default_conf, ticker, fee, ticker_sell_up, markets, mocker) -> None: @@ -2243,6 +2335,7 @@ def test_execute_sell_without_conf_sell_up(default_conf, ticker, fee, 'gain': 'profit', 'limit': 1.172e-05, 'amount': 90.99181073703367, + 'order_type': 'limit', 'open_rate': 1.099e-05, 'current_rate': 1.172e-05, 'profit_amount': 6.126e-05, @@ -2290,6 +2383,7 @@ def test_execute_sell_without_conf_sell_down(default_conf, ticker, fee, 'gain': 'loss', 'limit': 1.044e-05, 'amount': 90.99181073703367, + 'order_type': 'limit', 'open_rate': 1.099e-05, 'current_rate': 1.044e-05, 'profit_amount': -5.492e-05, @@ -2463,9 +2557,9 @@ def test_trailing_stop_loss(default_conf, limit_buy_order, fee, markets, caplog, mocker.patch.multiple( 'freqtrade.exchange.Exchange', get_ticker=MagicMock(return_value={ - 'bid': 0.00000102, - 'ask': 0.00000103, - 'last': 0.00000102 + 'bid': 0.00001099, + 'ask': 0.00001099, + 'last': 0.00001099 }), buy=MagicMock(return_value={'id': limit_buy_order['id']}), get_fee=fee, @@ -2477,15 +2571,33 @@ def test_trailing_stop_loss(default_conf, limit_buy_order, fee, markets, caplog, freqtrade.strategy.min_roi_reached = MagicMock(return_value=False) freqtrade.create_trade() - trade = Trade.query.first() - trade.update(limit_buy_order) - trade.max_rate = trade.open_rate * 1.003 + assert freqtrade.handle_trade(trade) is False + + # Raise ticker above buy price + mocker.patch('freqtrade.exchange.Exchange.get_ticker', + MagicMock(return_value={ + 'bid': 0.00001099 * 1.5, + 'ask': 0.00001099 * 1.5, + 'last': 0.00001099 * 1.5 + })) + + # Stoploss should be adjusted + assert freqtrade.handle_trade(trade) is False + + # Price fell + mocker.patch('freqtrade.exchange.Exchange.get_ticker', + MagicMock(return_value={ + 'bid': 0.00001099 * 1.1, + 'ask': 0.00001099 * 1.1, + 'last': 0.00001099 * 1.1 + })) + caplog.set_level(logging.DEBUG) # Sell as trailing-stop is reached assert freqtrade.handle_trade(trade) is True assert log_has( - f'HIT STOP: current price at 0.000001, stop loss is {trade.stop_loss:.6f}, ' + f'HIT STOP: current price at 0.000012, stop loss is 0.000015, ' f'initial stop loss was at 0.000010, trade opened at 0.000011', caplog.record_tuples) assert trade.sell_reason == SellType.TRAILING_STOP_LOSS.value @@ -3105,10 +3217,27 @@ def test_get_sell_rate(default_conf, mocker, ticker, order_book_l2) -> None: assert rate == 0.043936 -def test_startup_messages(default_conf, mocker): +def test_startup_state(default_conf, mocker): default_conf['pairlist'] = {'method': 'VolumePairList', 'config': {'number_assets': 20} } mocker.patch('freqtrade.exchange.Exchange.exchange_has', MagicMock(return_value=True)) worker = get_patched_worker(mocker, default_conf) assert worker.state is State.RUNNING + + +def test_startup_trade_reinit(default_conf, edge_conf, mocker): + + mocker.patch('freqtrade.exchange.Exchange.exchange_has', MagicMock(return_value=True)) + reinit_mock = MagicMock() + mocker.patch('freqtrade.persistence.Trade.stoploss_reinitialization', reinit_mock) + + ftbot = get_patched_freqtradebot(mocker, default_conf) + ftbot.startup() + assert reinit_mock.call_count == 1 + + reinit_mock.reset_mock() + + ftbot = get_patched_freqtradebot(mocker, edge_conf) + ftbot.startup() + assert reinit_mock.call_count == 0 diff --git a/freqtrade/tests/test_main.py b/freqtrade/tests/test_main.py index fc5d2e378..e6a2006f9 100644 --- a/freqtrade/tests/test_main.py +++ b/freqtrade/tests/test_main.py @@ -7,10 +7,11 @@ import pytest from freqtrade import OperationalException from freqtrade.arguments import Arguments -from freqtrade.worker import Worker +from freqtrade.freqtradebot import FreqtradeBot from freqtrade.main import main from freqtrade.state import State from freqtrade.tests.conftest import log_has, patch_exchange +from freqtrade.worker import Worker def test_parse_args_backtesting(mocker) -> None: @@ -18,8 +19,10 @@ 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 """ - backtesting_mock = mocker.patch('freqtrade.optimize.backtesting.start', MagicMock()) - main(['backtesting']) + backtesting_mock = mocker.patch('freqtrade.optimize.start_backtesting', MagicMock()) + # it's sys.exit(0) at the end of backtesting + with pytest.raises(SystemExit): + main(['backtesting']) assert backtesting_mock.call_count == 1 call_args = backtesting_mock.call_args[0][0] assert call_args.config == ['config.json'] @@ -31,8 +34,10 @@ def test_parse_args_backtesting(mocker) -> None: def test_main_start_hyperopt(mocker) -> None: - hyperopt_mock = mocker.patch('freqtrade.optimize.hyperopt.start', MagicMock()) - main(['hyperopt']) + hyperopt_mock = mocker.patch('freqtrade.optimize.start_hyperopt', MagicMock()) + # it's sys.exit(0) at the end of hyperopt + with pytest.raises(SystemExit): + main(['hyperopt']) assert hyperopt_mock.call_count == 1 call_args = hyperopt_mock.call_args[0][0] assert call_args.config == ['config.json'] @@ -107,24 +112,30 @@ def test_main_operational_exception(mocker, default_conf, caplog) -> None: def test_main_reload_conf(mocker, default_conf, caplog) -> None: patch_exchange(mocker) mocker.patch('freqtrade.freqtradebot.FreqtradeBot.cleanup', MagicMock()) - mocker.patch('freqtrade.worker.Worker._worker', MagicMock(return_value=State.RELOAD_CONF)) + # Simulate Running, reload, running workflow + worker_mock = MagicMock(side_effect=[State.RUNNING, + State.RELOAD_CONF, + State.RUNNING, + OperationalException("Oh snap!")]) + mocker.patch('freqtrade.worker.Worker._worker', worker_mock) mocker.patch( 'freqtrade.configuration.Configuration._load_config_file', lambda *args, **kwargs: default_conf ) + reconfigure_mock = mocker.patch('freqtrade.main.Worker._reconfigure', MagicMock()) + mocker.patch('freqtrade.freqtradebot.RPCManager', MagicMock()) mocker.patch('freqtrade.freqtradebot.persistence.init', MagicMock()) - # Raise exception as side effect to avoid endless loop - reconfigure_mock = mocker.patch( - 'freqtrade.main.Worker._reconfigure', MagicMock(side_effect=Exception) - ) - + args = Arguments(['-c', 'config.json.example'], '').get_parsed_arg() + worker = Worker(args=args, config=default_conf) with pytest.raises(SystemExit): main(['-c', 'config.json.example']) - assert reconfigure_mock.call_count == 1 assert log_has('Using config: config.json.example ...', caplog.record_tuples) + assert worker_mock.call_count == 4 + assert reconfigure_mock.call_count == 1 + assert isinstance(worker.freqtrade, FreqtradeBot) def test_reconfigure(mocker, default_conf) -> None: diff --git a/freqtrade/tests/test_misc.py b/freqtrade/tests/test_misc.py index 2da6b8718..7a7b15cf2 100644 --- a/freqtrade/tests/test_misc.py +++ b/freqtrade/tests/test_misc.py @@ -6,7 +6,7 @@ from unittest.mock import MagicMock from freqtrade.data.converter import parse_ticker_dataframe from freqtrade.misc import (common_datearray, datesarray_to_datetimearray, file_dump_json, file_load_json, format_ms_time, shorten_date) -from freqtrade.data.history import load_tickerdata_file, make_testdata_path +from freqtrade.data.history import load_tickerdata_file, pair_data_filename from freqtrade.strategy.default_strategy import DefaultStrategy @@ -17,7 +17,8 @@ def test_shorten_date() -> None: def test_datesarray_to_datetimearray(ticker_history_list): - dataframes = parse_ticker_dataframe(ticker_history_list, "5m", fill_missing=True) + dataframes = parse_ticker_dataframe(ticker_history_list, "5m", pair="UNITTEST/BTC", + fill_missing=True) dates = datesarray_to_datetimearray(dataframes['date']) assert isinstance(dates[0], datetime.datetime) @@ -34,7 +35,8 @@ def test_datesarray_to_datetimearray(ticker_history_list): def test_common_datearray(default_conf) -> None: strategy = DefaultStrategy(default_conf) tick = load_tickerdata_file(None, 'UNITTEST/BTC', '1m') - tickerlist = {'UNITTEST/BTC': parse_ticker_dataframe(tick, "1m", fill_missing=True)} + tickerlist = {'UNITTEST/BTC': parse_ticker_dataframe(tick, "1m", pair="UNITTEST/BTC", + fill_missing=True)} dataframes = strategy.tickerdata_to_dataframe(tickerlist) dates = common_datearray(dataframes) @@ -60,13 +62,13 @@ def test_file_dump_json(mocker) -> None: def test_file_load_json(mocker) -> None: # 7m .json does not exist - ret = file_load_json(make_testdata_path(None).joinpath('UNITTEST_BTC-7m.json')) + ret = file_load_json(pair_data_filename(None, 'UNITTEST/BTC', '7m')) assert not ret # 1m json exists (but no .gz exists) - ret = file_load_json(make_testdata_path(None).joinpath('UNITTEST_BTC-1m.json')) + ret = file_load_json(pair_data_filename(None, 'UNITTEST/BTC', '1m')) 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(make_testdata_path(None).joinpath('UNITTEST_BTC-8m.json')) + ret = file_load_json(pair_data_filename(None, 'UNITTEST/BTC', '8m')) assert ret diff --git a/freqtrade/tests/test_persistence.py b/freqtrade/tests/test_persistence.py index f57a466e3..32425ef7b 100644 --- a/freqtrade/tests/test_persistence.py +++ b/freqtrade/tests/test_persistence.py @@ -1,7 +1,8 @@ # pragma pylint: disable=missing-docstring, C0103 -from unittest.mock import MagicMock import logging +from unittest.mock import MagicMock +import arrow import pytest from sqlalchemy import create_engine @@ -10,14 +11,53 @@ from freqtrade.persistence import Trade, clean_dry_run_db, init from freqtrade.tests.conftest import log_has -@pytest.fixture(scope='function') -def init_persistence(default_conf): - init(default_conf) +def create_mock_trades(fee): + """ + Create some fake trades ... + """ + # Simulate dry_run entries + trade = Trade( + pair='ETH/BTC', + stake_amount=0.001, + amount=123.0, + fee_open=fee.return_value, + fee_close=fee.return_value, + open_rate=0.123, + exchange='bittrex', + open_order_id='dry_run_buy_12345' + ) + Trade.session.add(trade) + + trade = Trade( + pair='ETC/BTC', + stake_amount=0.001, + amount=123.0, + fee_open=fee.return_value, + fee_close=fee.return_value, + open_rate=0.123, + exchange='bittrex', + is_open=False, + open_order_id='dry_run_sell_12345' + ) + Trade.session.add(trade) + + # Simulate prod entry + trade = Trade( + pair='ETC/BTC', + stake_amount=0.001, + amount=123.0, + fee_open=fee.return_value, + fee_close=fee.return_value, + open_rate=0.123, + exchange='bittrex', + open_order_id='prod_buy_12345' + ) + Trade.session.add(trade) def test_init_create_session(default_conf): # Check if init create a session - init(default_conf) + init(default_conf['db_url'], default_conf['dry_run']) assert hasattr(Trade, 'session') assert 'Session' in type(Trade.session).__name__ @@ -27,7 +67,7 @@ def test_init_custom_db_url(default_conf, mocker): default_conf.update({'db_url': 'sqlite:///tmp/freqtrade2_test.sqlite'}) create_engine_mock = mocker.patch('freqtrade.persistence.create_engine', MagicMock()) - init(default_conf) + init(default_conf['db_url'], default_conf['dry_run']) assert create_engine_mock.call_count == 1 assert create_engine_mock.mock_calls[0][1][0] == 'sqlite:///tmp/freqtrade2_test.sqlite' @@ -36,7 +76,7 @@ def test_init_invalid_db_url(default_conf): # Update path to a value other than default, but still in-memory default_conf.update({'db_url': 'unknown:///some.url'}) with pytest.raises(OperationalException, match=r'.*no valid database URL*'): - init(default_conf) + init(default_conf['db_url'], default_conf['dry_run']) def test_init_prod_db(default_conf, mocker): @@ -45,7 +85,7 @@ def test_init_prod_db(default_conf, mocker): create_engine_mock = mocker.patch('freqtrade.persistence.create_engine', MagicMock()) - init(default_conf) + init(default_conf['db_url'], default_conf['dry_run']) assert create_engine_mock.call_count == 1 assert create_engine_mock.mock_calls[0][1][0] == 'sqlite:///tradesv3.sqlite' @@ -56,7 +96,7 @@ def test_init_dryrun_db(default_conf, mocker): create_engine_mock = mocker.patch('freqtrade.persistence.create_engine', MagicMock()) - init(default_conf) + init(default_conf['db_url'], default_conf['dry_run']) assert create_engine_mock.call_count == 1 assert create_engine_mock.mock_calls[0][1][0] == 'sqlite://' @@ -335,8 +375,8 @@ def test_calc_profit_percent(limit_buy_order, limit_sell_order, fee): assert trade.calc_profit_percent(fee=0.003) == 0.06147824 +@pytest.mark.usefixtures("init_persistence") def test_clean_dry_run_db(default_conf, fee): - init(default_conf) # Simulate dry_run entries trade = Trade( @@ -423,7 +463,7 @@ def test_migrate_old(mocker, default_conf, fee): engine.execute(create_table_old) engine.execute(insert_table_old) # Run init to test migration - init(default_conf) + init(default_conf['db_url'], default_conf['dry_run']) assert len(Trade.query.filter(Trade.id == 1).all()) == 1 trade = Trade.query.filter(Trade.id == 1).first() @@ -496,7 +536,7 @@ def test_migrate_new(mocker, default_conf, fee, caplog): engine.execute("create table trades_bak1 as select * from trades") # Run init to test migration - init(default_conf) + init(default_conf['db_url'], default_conf['dry_run']) assert len(Trade.query.filter(Trade.id == 1).all()) == 1 trade = Trade.query.filter(Trade.id == 1).first() @@ -565,7 +605,7 @@ def test_migrate_mid_state(mocker, default_conf, fee, caplog): engine.execute(insert_table_old) # Run init to test migration - init(default_conf) + init(default_conf['db_url'], default_conf['dry_run']) assert len(Trade.query.filter(Trade.id == 1).all()) == 1 trade = Trade.query.filter(Trade.id == 1).first() @@ -667,8 +707,15 @@ def test_adjust_min_max_rates(fee): assert trade.min_rate == 0.96 +@pytest.mark.usefixtures("init_persistence") def test_get_open(default_conf, fee): - init(default_conf) + + create_mock_trades(fee) + assert len(Trade.get_open_trades()) == 2 + + +@pytest.mark.usefixtures("init_persistence") +def test_to_json(default_conf, fee): # Simulate dry_run entries trade = Trade( @@ -677,36 +724,117 @@ def test_get_open(default_conf, fee): amount=123.0, fee_open=fee.return_value, fee_close=fee.return_value, + open_date=arrow.utcnow().shift(hours=-2).datetime, open_rate=0.123, exchange='bittrex', open_order_id='dry_run_buy_12345' ) - Trade.session.add(trade) + result = trade.to_json() + assert isinstance(result, dict) + print(result) + assert result == {'trade_id': None, + 'pair': 'ETH/BTC', + 'open_date_hum': '2 hours ago', + 'open_date': trade.open_date.strftime("%Y-%m-%d %H:%M:%S"), + 'close_date_hum': None, + 'close_date': None, + 'open_rate': 0.123, + 'close_rate': None, + 'amount': 123.0, + 'stake_amount': 0.001, + 'stop_loss': None, + 'stop_loss_pct': None, + 'initial_stop_loss': None, + 'initial_stop_loss_pct': None} + + # Simulate dry_run entries trade = Trade( - pair='ETC/BTC', + pair='XRP/BTC', stake_amount=0.001, - amount=123.0, + amount=100.0, fee_open=fee.return_value, fee_close=fee.return_value, + open_date=arrow.utcnow().shift(hours=-2).datetime, + close_date=arrow.utcnow().shift(hours=-1).datetime, open_rate=0.123, + close_rate=0.125, exchange='bittrex', - is_open=False, - open_order_id='dry_run_sell_12345' ) - Trade.session.add(trade) + result = trade.to_json() + assert isinstance(result, dict) - # Simulate prod entry + assert result == {'trade_id': None, + 'pair': 'XRP/BTC', + 'open_date_hum': '2 hours ago', + 'open_date': trade.open_date.strftime("%Y-%m-%d %H:%M:%S"), + 'close_date_hum': 'an hour ago', + 'close_date': trade.close_date.strftime("%Y-%m-%d %H:%M:%S"), + 'open_rate': 0.123, + 'close_rate': 0.125, + 'amount': 100.0, + 'stake_amount': 0.001, + 'stop_loss': None, + 'stop_loss_pct': None, + 'initial_stop_loss': None, + 'initial_stop_loss_pct': None} + + +def test_stoploss_reinitialization(default_conf, fee): + init(default_conf['db_url']) trade = Trade( - pair='ETC/BTC', + pair='ETH/BTC', stake_amount=0.001, - amount=123.0, fee_open=fee.return_value, + open_date=arrow.utcnow().shift(hours=-2).datetime, + amount=10, fee_close=fee.return_value, - open_rate=0.123, exchange='bittrex', - open_order_id='prod_buy_12345' + open_rate=1, + max_rate=1, ) + + trade.adjust_stop_loss(trade.open_rate, 0.05, True) + assert trade.stop_loss == 0.95 + assert trade.stop_loss_pct == -0.05 + assert trade.initial_stop_loss == 0.95 + assert trade.initial_stop_loss_pct == -0.05 Trade.session.add(trade) - assert len(Trade.get_open_trades()) == 2 + # Lower stoploss + Trade.stoploss_reinitialization(0.06) + + trades = Trade.get_open_trades() + assert len(trades) == 1 + trade_adj = trades[0] + assert trade_adj.stop_loss == 0.94 + assert trade_adj.stop_loss_pct == -0.06 + assert trade_adj.initial_stop_loss == 0.94 + assert trade_adj.initial_stop_loss_pct == -0.06 + + # Raise stoploss + Trade.stoploss_reinitialization(0.04) + + trades = Trade.get_open_trades() + assert len(trades) == 1 + trade_adj = trades[0] + assert trade_adj.stop_loss == 0.96 + assert trade_adj.stop_loss_pct == -0.04 + assert trade_adj.initial_stop_loss == 0.96 + assert trade_adj.initial_stop_loss_pct == -0.04 + + # Trailing stoploss (move stoplos up a bit) + trade.adjust_stop_loss(1.02, 0.04) + assert trade_adj.stop_loss == 0.9792 + assert trade_adj.initial_stop_loss == 0.96 + + Trade.stoploss_reinitialization(0.04) + + trades = Trade.get_open_trades() + assert len(trades) == 1 + trade_adj = trades[0] + # Stoploss should not change in this case. + assert trade_adj.stop_loss == 0.9792 + assert trade_adj.stop_loss_pct == -0.04 + assert trade_adj.initial_stop_loss == 0.96 + assert trade_adj.initial_stop_loss_pct == -0.04 diff --git a/freqtrade/tests/test_plotting.py b/freqtrade/tests/test_plotting.py new file mode 100644 index 000000000..ec81b93b8 --- /dev/null +++ b/freqtrade/tests/test_plotting.py @@ -0,0 +1,189 @@ + +from unittest.mock import MagicMock + +from plotly import tools +import plotly.graph_objs as go +from copy import deepcopy + +from freqtrade.arguments import TimeRange +from freqtrade.data import history +from freqtrade.data.btanalysis import load_backtest_data +from freqtrade.plot.plotting import (generate_graph, generate_plot_file, + generate_row, plot_trades) +from freqtrade.strategy.default_strategy import DefaultStrategy +from freqtrade.tests.conftest import log_has, log_has_re + + +def fig_generating_mock(fig, *args, **kwargs): + """ Return Fig - used to mock generate_row and plot_trades""" + return fig + + +def find_trace_in_fig_data(data, search_string: str): + matches = filter(lambda x: x.name == search_string, data) + return next(matches) + + +def generage_empty_figure(): + return tools.make_subplots( + rows=3, + cols=1, + shared_xaxes=True, + row_width=[1, 1, 4], + vertical_spacing=0.0001, + ) + + +def test_generate_row(default_conf, caplog): + pair = "UNITTEST/BTC" + timerange = TimeRange(None, 'line', 0, -1000) + + data = history.load_pair_history(pair=pair, ticker_interval='1m', + datadir=None, timerange=timerange) + indicators1 = ["ema10"] + indicators2 = ["macd"] + + # Generate buy/sell signals and indicators + strat = DefaultStrategy(default_conf) + data = strat.analyze_ticker(data, {'pair': pair}) + fig = generage_empty_figure() + + # Row 1 + fig1 = generate_row(fig=deepcopy(fig), row=1, indicators=indicators1, data=data) + figure = fig1.layout.figure + ema10 = find_trace_in_fig_data(figure.data, "ema10") + assert isinstance(ema10, go.Scatter) + assert ema10.yaxis == "y" + + fig2 = generate_row(fig=deepcopy(fig), row=3, indicators=indicators2, data=data) + figure = fig2.layout.figure + macd = find_trace_in_fig_data(figure.data, "macd") + assert isinstance(macd, go.Scatter) + assert macd.yaxis == "y3" + + # No indicator found + fig3 = generate_row(fig=deepcopy(fig), row=3, indicators=['no_indicator'], data=data) + assert fig == fig3 + assert log_has_re(r'Indicator "no_indicator" ignored\..*', caplog.record_tuples) + + +def test_plot_trades(caplog): + fig1 = generage_empty_figure() + # nothing happens when no trades are available + fig = plot_trades(fig1, None) + assert fig == fig1 + assert log_has("No trades found.", caplog.record_tuples) + pair = "ADA/BTC" + filename = history.make_testdata_path(None) / "backtest-result_test.json" + trades = load_backtest_data(filename) + trades = trades.loc[trades['pair'] == pair] + + fig = plot_trades(fig, trades) + figure = fig1.layout.figure + + # Check buys - color, should be in first graph, ... + trade_buy = find_trace_in_fig_data(figure.data, "trade_buy") + assert isinstance(trade_buy, go.Scatter) + assert trade_buy.yaxis == 'y' + assert len(trades) == len(trade_buy.x) + assert trade_buy.marker.color == 'green' + + trade_sell = find_trace_in_fig_data(figure.data, "trade_sell") + assert isinstance(trade_sell, go.Scatter) + assert trade_sell.yaxis == 'y' + assert len(trades) == len(trade_sell.x) + assert trade_sell.marker.color == 'red' + + +def test_generate_graph_no_signals_no_trades(default_conf, mocker, caplog): + row_mock = mocker.patch('freqtrade.plot.plotting.generate_row', + MagicMock(side_effect=fig_generating_mock)) + trades_mock = mocker.patch('freqtrade.plot.plotting.plot_trades', + MagicMock(side_effect=fig_generating_mock)) + + pair = "UNITTEST/BTC" + timerange = TimeRange(None, 'line', 0, -1000) + data = history.load_pair_history(pair=pair, ticker_interval='1m', + datadir=None, timerange=timerange) + data['buy'] = 0 + data['sell'] = 0 + + indicators1 = [] + indicators2 = [] + fig = generate_graph(pair=pair, data=data, trades=None, + indicators1=indicators1, indicators2=indicators2) + assert isinstance(fig, go.Figure) + assert fig.layout.title.text == pair + figure = fig.layout.figure + + assert len(figure.data) == 2 + # Candlesticks are plotted first + candles = find_trace_in_fig_data(figure.data, "Price") + assert isinstance(candles, go.Candlestick) + + volume = find_trace_in_fig_data(figure.data, "Volume") + assert isinstance(volume, go.Bar) + + assert row_mock.call_count == 2 + assert trades_mock.call_count == 1 + + assert log_has("No buy-signals found.", caplog.record_tuples) + assert log_has("No sell-signals found.", caplog.record_tuples) + + +def test_generate_graph_no_trades(default_conf, mocker): + row_mock = mocker.patch('freqtrade.plot.plotting.generate_row', + MagicMock(side_effect=fig_generating_mock)) + trades_mock = mocker.patch('freqtrade.plot.plotting.plot_trades', + MagicMock(side_effect=fig_generating_mock)) + pair = 'UNITTEST/BTC' + timerange = TimeRange(None, 'line', 0, -1000) + data = history.load_pair_history(pair=pair, ticker_interval='1m', + datadir=None, timerange=timerange) + + # Generate buy/sell signals and indicators + strat = DefaultStrategy(default_conf) + data = strat.analyze_ticker(data, {'pair': pair}) + + indicators1 = [] + indicators2 = [] + fig = generate_graph(pair=pair, data=data, trades=None, + indicators1=indicators1, indicators2=indicators2) + assert isinstance(fig, go.Figure) + assert fig.layout.title.text == pair + figure = fig.layout.figure + + assert len(figure.data) == 6 + # Candlesticks are plotted first + candles = find_trace_in_fig_data(figure.data, "Price") + assert isinstance(candles, go.Candlestick) + + volume = find_trace_in_fig_data(figure.data, "Volume") + assert isinstance(volume, go.Bar) + + buy = find_trace_in_fig_data(figure.data, "buy") + assert isinstance(buy, go.Scatter) + # All buy-signals should be plotted + assert int(data.buy.sum()) == len(buy.x) + + sell = find_trace_in_fig_data(figure.data, "sell") + assert isinstance(sell, go.Scatter) + # All buy-signals should be plotted + assert int(data.sell.sum()) == len(sell.x) + + assert find_trace_in_fig_data(figure.data, "BB lower") + assert find_trace_in_fig_data(figure.data, "BB upper") + + assert row_mock.call_count == 2 + assert trades_mock.call_count == 1 + + +def test_generate_plot_file(mocker, caplog): + fig = generage_empty_figure() + plot_mock = mocker.patch("freqtrade.plot.plotting.plot", MagicMock()) + generate_plot_file(fig, "UNITTEST/BTC", "5m") + + assert plot_mock.call_count == 1 + assert plot_mock.call_args[0][0] == fig + assert (plot_mock.call_args_list[0][1]['filename'] + == "user_data/plots/freqtrade-plot-UNITTEST_BTC-5m.html") diff --git a/freqtrade/tests/test_utils.py b/freqtrade/tests/test_utils.py new file mode 100644 index 000000000..a12b709d7 --- /dev/null +++ b/freqtrade/tests/test_utils.py @@ -0,0 +1,42 @@ +from freqtrade.utils import setup_utils_configuration, start_list_exchanges +from freqtrade.tests.conftest import get_args +from freqtrade.state import RunMode + +import re + + +def test_setup_utils_configuration(): + args = [ + '--config', 'config.json.example', + ] + + config = setup_utils_configuration(get_args(args), RunMode.OTHER) + assert "exchange" in config + assert config['exchange']['dry_run'] is True + assert config['exchange']['key'] == '' + assert config['exchange']['secret'] == '' + + +def test_list_exchanges(capsys): + + args = [ + "list-exchanges", + ] + + start_list_exchanges(get_args(args)) + captured = capsys.readouterr() + assert re.match(r"Exchanges supported by ccxt and available.*", captured.out) + assert re.match(r".*binance,.*", captured.out) + assert re.match(r".*bittrex,.*", captured.out) + + # Test with --one-column + args = [ + "list-exchanges", + "--one-column", + ] + + start_list_exchanges(get_args(args)) + captured = capsys.readouterr() + assert not re.match(r"Exchanges supported by ccxt and available.*", captured.out) + assert re.search(r"^binance$", captured.out, re.MULTILINE) + assert re.search(r"^bittrex$", captured.out, re.MULTILINE) diff --git a/freqtrade/utils.py b/freqtrade/utils.py new file mode 100644 index 000000000..d550ef43c --- /dev/null +++ b/freqtrade/utils.py @@ -0,0 +1,41 @@ +import logging +from argparse import Namespace +from typing import Any, Dict + +from freqtrade.configuration import Configuration +from freqtrade.exchange import available_exchanges +from freqtrade.state import RunMode + + +logger = logging.getLogger(__name__) + + +def setup_utils_configuration(args: Namespace, method: RunMode) -> Dict[str, Any]: + """ + Prepare the configuration for utils subcommands + :param args: Cli args from Arguments() + :return: Configuration + """ + configuration = Configuration(args, method) + config = configuration.load_config() + + config['exchange']['dry_run'] = True + # Ensure we do not use Exchange credentials + config['exchange']['key'] = '' + config['exchange']['secret'] = '' + + return config + + +def start_list_exchanges(args: Namespace) -> None: + """ + Print available exchanges + :param args: Cli args from Arguments() + :return: None + """ + + if args.print_one_column: + print('\n'.join(available_exchanges())) + else: + print(f"Exchanges supported by ccxt and available for Freqtrade: " + f"{', '.join(available_exchanges())}") diff --git a/freqtrade/vendor/qtpylib/indicators.py b/freqtrade/vendor/qtpylib/indicators.py index 3866d36c1..6edf626f0 100644 --- a/freqtrade/vendor/qtpylib/indicators.py +++ b/freqtrade/vendor/qtpylib/indicators.py @@ -4,13 +4,13 @@ # QTPyLib: Quantitative Trading Python Library # https://github.com/ranaroussi/qtpylib # -# Copyright 2016 Ran Aroussi +# Copyright 2016-2018 Ran Aroussi # -# Licensed under the GNU Lesser General Public License, v3.0 (the "License"); +# Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # -# https://www.gnu.org/licenses/lgpl-3.0.en.html +# http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, @@ -19,8 +19,8 @@ # limitations under the License. # -import sys import warnings +import sys from datetime import datetime, timedelta import numpy as np @@ -62,19 +62,20 @@ def numpy_rolling_series(func): @numpy_rolling_series def numpy_rolling_mean(data, window, as_source=False): - return np.mean(numpy_rolling_window(data, window), -1) + return np.mean(numpy_rolling_window(data, window), axis=-1) @numpy_rolling_series def numpy_rolling_std(data, window, as_source=False): - return np.std(numpy_rolling_window(data, window), -1) + return np.std(numpy_rolling_window(data, window), axis=-1, ddof=1) + # --------------------------------------------- def session(df, start='17:00', end='16:00'): """ remove previous globex day from df """ - if len(df) == 0: + if df.empty: return df # get start/end/now as decimals @@ -103,47 +104,47 @@ def session(df, start='17:00', end='16:00'): return df.copy() - # --------------------------------------------- + def heikinashi(bars): bars = bars.copy() bars['ha_close'] = (bars['open'] + bars['high'] + bars['low'] + bars['close']) / 4 - bars['ha_open'] = (bars['open'].shift(1) + bars['close'].shift(1)) / 2 - bars.loc[:1, 'ha_open'] = bars['open'].values[0] - for x in range(2): - bars.loc[1:, 'ha_open'] = ( - (bars['ha_open'].shift(1) + bars['ha_close'].shift(1)) / 2)[1:] + # ha open + bars.at[0, 'ha_open'] = (bars.at[0, 'open'] + bars.at[0, 'close']) / 2 + for i in range(1, len(bars)): + bars.at[i, 'ha_open'] = (bars.at[i - 1, 'ha_open'] + bars.at[i - 1, 'ha_close']) / 2 bars['ha_high'] = bars.loc[:, ['high', 'ha_open', 'ha_close']].max(axis=1) bars['ha_low'] = bars.loc[:, ['low', 'ha_open', 'ha_close']].min(axis=1) - return pd.DataFrame( - index=bars.index, - data={ - 'open': bars['ha_open'], - 'high': bars['ha_high'], - 'low': bars['ha_low'], - 'close': bars['ha_close']}) - + return pd.DataFrame(index=bars.index, + data={'open': bars['ha_open'], + 'high': bars['ha_high'], + 'low': bars['ha_low'], + 'close': bars['ha_close']}) # --------------------------------------------- -def tdi(series, rsi_len=13, bollinger_len=34, rsi_smoothing=2, - rsi_signal_len=7, bollinger_std=1.6185): - rsi_series = rsi(series, rsi_len) - bb_series = bollinger_bands(rsi_series, bollinger_len, bollinger_std) - signal = sma(rsi_series, rsi_signal_len) - rsi_series = sma(rsi_series, rsi_smoothing) + +def tdi(series, rsi_lookback=13, rsi_smooth_len=2, + rsi_signal_len=7, bb_lookback=34, bb_std=1.6185): + + rsi_data = rsi(series, rsi_lookback) + rsi_smooth = sma(rsi_data, rsi_smooth_len) + rsi_signal = sma(rsi_data, rsi_signal_len) + + bb_series = bollinger_bands(rsi_data, bb_lookback, bb_std) return pd.DataFrame(index=series.index, data={ - "rsi": rsi_series, - "signal": signal, - "bbupper": bb_series['upper'], - "bblower": bb_series['lower'], - "bbmid": bb_series['mid'] + "rsi": rsi_data, + "rsi_signal": rsi_signal, + "rsi_smooth": rsi_smooth, + "rsi_bb_upper": bb_series['upper'], + "rsi_bb_lower": bb_series['lower'], + "rsi_bb_mid": bb_series['mid'] }) # --------------------------------------------- @@ -163,8 +164,8 @@ def awesome_oscillator(df, weighted=False, fast=5, slow=34): # --------------------------------------------- -def nans(len=1): - mtx = np.empty(len) +def nans(length=1): + mtx = np.empty(length) mtx[:] = np.nan return mtx @@ -222,7 +223,7 @@ def crossed(series1, series2, direction=None): if isinstance(series1, np.ndarray): series1 = pd.Series(series1) - if isinstance(series2, int) or isinstance(series2, float) or isinstance(series2, np.ndarray): + if isinstance(series2, (float, int, np.ndarray)): series2 = pd.Series(index=series1.index, data=series2) if direction is None or direction == "above": @@ -256,7 +257,7 @@ def rolling_std(series, window=200, min_periods=None): else: try: return series.rolling(window=window, min_periods=min_periods).std() - except BaseException: + except Exception as e: # noqa: F841 return pd.Series(series).rolling(window=window, min_periods=min_periods).std() # --------------------------------------------- @@ -269,7 +270,7 @@ def rolling_mean(series, window=200, min_periods=None): else: try: return series.rolling(window=window, min_periods=min_periods).mean() - except BaseException: + except Exception as e: # noqa: F841 return pd.Series(series).rolling(window=window, min_periods=min_periods).mean() # --------------------------------------------- @@ -279,7 +280,7 @@ def rolling_min(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() - except BaseException: + except Exception as e: # noqa: F841 return pd.Series(series).rolling(window=window, min_periods=min_periods).min() @@ -289,7 +290,7 @@ 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() - except BaseException: + except Exception as e: # noqa: F841 return pd.Series(series).rolling(window=window, min_periods=min_periods).min() @@ -299,16 +300,17 @@ def rolling_weighted_mean(series, window=200, min_periods=None): min_periods = window if min_periods is None else min_periods try: return series.ewm(span=window, min_periods=min_periods).mean() - except BaseException: + except Exception as e: # noqa: F841 return pd.ewma(series, span=window, min_periods=min_periods) # --------------------------------------------- -def hull_moving_average(series, window=200): - wma = (2 * rolling_weighted_mean(series, window=window / 2)) - \ - rolling_weighted_mean(series, window=window) - return rolling_weighted_mean(wma, window=np.sqrt(window)) +def hull_moving_average(series, window=200, min_periods=None): + min_periods = window if min_periods is None else min_periods + ma = (2 * rolling_weighted_mean(series, window / 2, min_periods)) - \ + rolling_weighted_mean(series, window, min_periods) + return rolling_weighted_mean(ma, np.sqrt(window), min_periods) # --------------------------------------------- @@ -325,8 +327,8 @@ def wma(series, window=200, min_periods=None): # --------------------------------------------- -def hma(series, window=200): - return hull_moving_average(series, window=window) +def hma(series, window=200, min_periods=None): + return hull_moving_average(series, window=window, min_periods=min_periods) # --------------------------------------------- @@ -361,7 +363,8 @@ def rolling_vwap(bars, window=200, min_periods=None): min_periods=min_periods).sum() right = volume.rolling(window=window, min_periods=min_periods).sum() - return pd.Series(index=bars.index, data=(left / right)) + return pd.Series(index=bars.index, data=(left / right) + ).replace([np.inf, -np.inf], float('NaN')).ffill() # --------------------------------------------- @@ -370,6 +373,7 @@ def rsi(series, window=14): """ compute the n period relative strength indicator """ + # 100-(100/relative_strength) deltas = np.diff(series) seed = deltas[:window + 1] @@ -406,13 +410,13 @@ def macd(series, fast=3, slow=10, smooth=16): using a fast and slow exponential moving avg' return value is emaslow, emafast, macd which are len(x) arrays """ - macd = rolling_weighted_mean(series, window=fast) - \ + macd_line = rolling_weighted_mean(series, window=fast) - \ rolling_weighted_mean(series, window=slow) - signal = rolling_weighted_mean(macd, window=smooth) - histogram = macd - signal - # return macd, signal, histogram + signal = rolling_weighted_mean(macd_line, window=smooth) + histogram = macd_line - signal + # return macd_line, signal, histogram return pd.DataFrame(index=series.index, data={ - 'macd': macd.values, + 'macd': macd_line.values, 'signal': signal.values, 'histogram': histogram.values }) @@ -421,14 +425,14 @@ def macd(series, fast=3, slow=10, smooth=16): # --------------------------------------------- def bollinger_bands(series, window=20, stds=2): - sma = rolling_mean(series, window=window) - std = rolling_std(series, window=window) - upper = sma + std * stds - lower = sma - std * stds + ma = rolling_mean(series, window=window, min_periods=1) + std = rolling_std(series, window=window, min_periods=1) + upper = ma + std * stds + lower = ma - std * stds return pd.DataFrame(index=series.index, data={ 'upper': upper, - 'mid': sma, + 'mid': ma, 'lower': lower }) @@ -454,7 +458,7 @@ def returns(series): try: res = (series / series.shift(1) - 1).replace([np.inf, -np.inf], float('NaN')) - except BaseException: + except Exception as e: # noqa: F841 res = nans(len(series)) return pd.Series(index=series.index, data=res) @@ -466,7 +470,7 @@ def log_returns(series): try: res = np.log(series / series.shift(1) ).replace([np.inf, -np.inf], float('NaN')) - except BaseException: + except Exception as e: # noqa: F841 res = nans(len(series)) return pd.Series(index=series.index, data=res) @@ -479,7 +483,7 @@ def implied_volatility(series, window=252): logret = np.log(series / series.shift(1) ).replace([np.inf, -np.inf], float('NaN')) res = numpy_rolling_std(logret, window) * np.sqrt(window) - except BaseException: + except Exception as e: # noqa: F841 res = nans(len(series)) return pd.Series(index=series.index, data=res) @@ -530,32 +534,55 @@ def stoch(df, window=14, d=3, k=3, fast=False): compute the n period relative strength indicator http://excelta.blogspot.co.il/2013/09/stochastic-oscillator-technical.html """ - highs_ma = pd.concat([df['high'].shift(i) - for i in np.arange(window)], 1).apply(list, 1) - highs_ma = highs_ma.T.max().T - lows_ma = pd.concat([df['low'].shift(i) - for i in np.arange(window)], 1).apply(list, 1) - lows_ma = lows_ma.T.min().T + my_df = pd.DataFrame(index=df.index) - fast_k = ((df['close'] - lows_ma) / (highs_ma - lows_ma)) * 100 - fast_d = numpy_rolling_mean(fast_k, d) + my_df['rolling_max'] = df['high'].rolling(window).max() + my_df['rolling_min'] = df['low'].rolling(window).min() + + my_df['fast_k'] = ( + 100 * (df['close'] - my_df['rolling_min']) / + (my_df['rolling_max'] - my_df['rolling_min']) + ) + my_df['fast_d'] = my_df['fast_k'].rolling(d).mean() if fast: - data = { - 'k': fast_k, - 'd': fast_d - } + return my_df.loc[:, ['fast_k', 'fast_d']] - else: - slow_k = numpy_rolling_mean(fast_k, k) - slow_d = numpy_rolling_mean(slow_k, d) - data = { - 'k': slow_k, - 'd': slow_d - } + my_df['slow_k'] = my_df['fast_k'].rolling(k).mean() + my_df['slow_d'] = my_df['slow_k'].rolling(d).mean() - return pd.DataFrame(index=df.index, data=data) + return my_df.loc[:, ['slow_k', 'slow_d']] + +# --------------------------------------------- + + +def zlma(series, window=20, min_periods=None, kind="ema"): + """ + John Ehlers' Zero lag (exponential) moving average + https://en.wikipedia.org/wiki/Zero_lag_exponential_moving_average + """ + min_periods = window if min_periods is None else min_periods + + lag = (window - 1) // 2 + series = 2 * series - series.shift(lag) + if kind in ['ewm', 'ema']: + return wma(series, lag, min_periods) + elif kind == "hma": + return hma(series, lag, min_periods) + return sma(series, lag, min_periods) + + +def zlema(series, window, min_periods=None): + return zlma(series, window, min_periods, kind="ema") + + +def zlsma(series, window, min_periods=None): + return zlma(series, window, min_periods, kind="sma") + + +def zlhma(series, window, min_periods=None): + return zlma(series, window, min_periods, kind="hma") # --------------------------------------------- @@ -571,13 +598,13 @@ def zscore(bars, window=20, stds=1, col='close'): def pvt(bars): """ Price Volume Trend """ - pvt = ((bars['close'] - bars['close'].shift(1)) / - bars['close'].shift(1)) * bars['volume'] - return pvt.cumsum() - + trend = ((bars['close'] - bars['close'].shift(1)) / + bars['close'].shift(1)) * bars['volume'] + return trend.cumsum() # ============================================= + PandasObject.session = session PandasObject.atr = atr PandasObject.bollinger_bands = bollinger_bands @@ -613,4 +640,11 @@ PandasObject.rolling_weighted_mean = rolling_weighted_mean PandasObject.sma = sma PandasObject.wma = wma +PandasObject.ema = wma PandasObject.hma = hma + +PandasObject.zlsma = zlsma +PandasObject.zlwma = zlema +PandasObject.zlema = zlema +PandasObject.zlhma = zlhma +PandasObject.zlma = zlma diff --git a/freqtrade/worker.py b/freqtrade/worker.py index c7afe5c97..c224b4ee5 100755 --- a/freqtrade/worker.py +++ b/freqtrade/worker.py @@ -39,7 +39,7 @@ class Worker(object): logger.debug("sd_notify: READY=1") self._sd_notify.notify("READY=1") - def _init(self, reconfig: bool): + def _init(self, reconfig: bool) -> None: """ Also called from the _reconfigure() method (with reconfig=True). """ @@ -63,17 +63,17 @@ class Worker(object): return self.freqtrade.state @state.setter - def state(self, value: State): + def state(self, value: State) -> None: self.freqtrade.state = value - def run(self): + def run(self) -> None: state = None while True: state = self._worker(old_state=state) if state == State.RELOAD_CONF: - self.freqtrade = self._reconfigure() + self._reconfigure() - def _worker(self, old_state: State, throttle_secs: Optional[float] = None) -> State: + def _worker(self, old_state: Optional[State], throttle_secs: Optional[float] = None) -> State: """ Trading routine that must be run at each loop :param old_state: the previous service state from the previous call @@ -91,7 +91,7 @@ class Worker(object): }) logger.info('Changing state to: %s', state.name) if state == State.RUNNING: - self.freqtrade.rpc.startup_messages(self._config, self.freqtrade.pairlists) + self.freqtrade.startup() if state == State.STOPPED: # Ping systemd watchdog before sleeping in the stopped state @@ -148,7 +148,7 @@ class Worker(object): # state_changed = True return state_changed - def _reconfigure(self): + def _reconfigure(self) -> None: """ Cleans up current freqtradebot instance, reloads the configuration and replaces it with the new instance @@ -174,7 +174,7 @@ class Worker(object): logger.debug("sd_notify: READY=1") self._sd_notify.notify("READY=1") - def exit(self): + def exit(self) -> None: # Tell systemd that we are exiting now if self._sd_notify: logger.debug("sd_notify: STOPPING=1") diff --git a/mkdocs.yml b/mkdocs.yml index ecac265c1..b5e759432 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -2,19 +2,22 @@ site_name: Freqtrade nav: - About: index.md - Installation: installation.md + - Installation Docker: docker.md - Configuration: configuration.md - - Custom Strategy: bot-optimization.md + - Strategy Customization: strategy-customization.md - Stoploss: stoploss.md - Start the bot: bot-usage.md - Control the bot: - Telegram: telegram-usage.md - Web Hook: webhook-config.md + - REST API: rest-api.md - Backtesting: backtesting.md - Hyperopt: hyperopt.md - Edge positioning: edge.md - Plotting: plotting.md - Deprecated features: deprecated.md - FAQ: faq.md + - Data Analysis: data-analysis.md - SQL Cheatsheet: sql_cheatsheet.md - Sandbox testing: sandbox-testing.md - Contributors guide: developer.md diff --git a/requirements-common.txt b/requirements-common.txt new file mode 100644 index 000000000..6913217ff --- /dev/null +++ b/requirements-common.txt @@ -0,0 +1,32 @@ +# requirements without requirements installable via conda +# mainly used for Raspberry pi installs +ccxt==1.18.805 +SQLAlchemy==1.3.5 +python-telegram-bot==11.1.0 +arrow==0.14.2 +cachetools==3.1.1 +requests==2.22.0 +urllib3==1.24.2 # pyup: ignore +wrapt==1.11.2 +scikit-learn==0.21.2 +joblib==0.13.2 +jsonschema==3.0.1 +TA-Lib==0.4.17 +tabulate==0.8.3 +coinmarketcap==5.0.3 + +# Required for hyperopt +scikit-optimize==0.5.2 +filelock==3.0.12 + +# find first, C search in arrays +py_find_1st==1.1.3 + +#Load ticker files 30% faster +python-rapidjson==0.7.2 + +# Notify systemd +sdnotify==0.3.2 + +# Api server +flask==1.0.3 diff --git a/requirements-dev.txt b/requirements-dev.txt index 9d0e99843..1232d4dd4 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -1,12 +1,13 @@ # Include all requirements to run the bot. -r requirements.txt +-r requirements-plot.txt flake8==3.7.7 flake8-type-annotations==0.1.0 flake8-tidy-imports==2.0.0 -pytest==4.4.1 -pytest-mock==1.10.3 +pytest==4.6.3 +pytest-mock==1.10.4 pytest-asyncio==0.10.0 -pytest-cov==2.6.1 -coveralls==1.7.0 -mypy==0.701 +pytest-cov==2.7.1 +coveralls==1.8.1 +mypy==0.710 diff --git a/requirements-pi.txt b/requirements-pi.txt deleted file mode 100644 index 30e4a4ce4..000000000 --- a/requirements-pi.txt +++ /dev/null @@ -1,23 +0,0 @@ -ccxt==1.18.472 -SQLAlchemy==1.3.3 -python-telegram-bot==11.1.0 -arrow==0.13.1 -cachetools==3.1.0 -requests==2.21.0 -urllib3==1.24.1 -wrapt==1.11.1 -scikit-learn==0.20.3 -joblib==0.13.2 -jsonschema==3.0.1 -TA-Lib==0.4.17 -tabulate==0.8.3 -coinmarketcap==5.0.3 - -# Required for hyperopt -scikit-optimize==0.5.2 - -# find first, C search in arrays -py_find_1st==1.1.3 - -#Load ticker files 30% faster -python-rapidjson==0.7.0 diff --git a/requirements-plot.txt b/requirements-plot.txt index 0b924b608..d4e4fc165 100644 --- a/requirements-plot.txt +++ b/requirements-plot.txt @@ -1,5 +1,5 @@ # Include all requirements to run the bot. -r requirements.txt -plotly==3.8.0 +plotly==3.10.0 diff --git a/requirements.txt b/requirements.txt index 771102e90..52442fb19 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,29 +1,6 @@ -ccxt==1.18.472 -SQLAlchemy==1.3.3 -python-telegram-bot==11.1.0 -arrow==0.13.1 -cachetools==3.1.0 -requests==2.21.0 -urllib3==1.24.1 -wrapt==1.11.1 -numpy==1.16.2 +# Load common requirements +-r requirements-common.txt + +numpy==1.16.4 pandas==0.24.2 -scikit-learn==0.20.3 -joblib==0.13.2 -scipy==1.2.1 -jsonschema==3.0.1 -TA-Lib==0.4.17 -tabulate==0.8.3 -coinmarketcap==5.0.3 - -# Required for hyperopt -scikit-optimize==0.5.2 - -# find first, C search in arrays -py_find_1st==1.1.3 - -# Load ticker files 30% faster -python-rapidjson==0.7.0 - -# Notify systemd -sdnotify==0.3.2 +scipy==1.3.0 diff --git a/scripts/download_backtest_data.py b/scripts/download_backtest_data.py index 42b305778..dd4627c14 100755 --- a/scripts/download_backtest_data.py +++ b/scripts/download_backtest_data.py @@ -1,55 +1,67 @@ #!/usr/bin/env python3 """ -This script generates json data +This script generates json files with pairs history data """ +import arrow import json import sys from pathlib import Path -import arrow -from typing import Any, Dict +from typing import Any, Dict, List -from freqtrade.arguments import Arguments -from freqtrade.arguments import TimeRange -from freqtrade.exchange import Exchange +from freqtrade.arguments import Arguments, TimeRange +from freqtrade.configuration import Configuration from freqtrade.data.history import download_pair_history -from freqtrade.configuration import Configuration, set_loggers +from freqtrade.exchange import Exchange from freqtrade.misc import deep_merge_dicts import logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', -) -set_loggers(0) + +logger = logging.getLogger('download_backtest_data') DEFAULT_DL_PATH = 'user_data/data' -arguments = Arguments(sys.argv[1:], 'download utility') -arguments.testdata_dl_options() -args = arguments.parse_args() +arguments = Arguments(sys.argv[1:], 'Download backtest data') +arguments.common_options() +arguments.download_data_options() -timeframes = args.timeframes +# Do not read the default config if config is not specified +# in the command line options explicitely +args = arguments.parse_args(no_default_config=True) + +# Use bittrex as default exchange +exchange_name = args.exchange or 'bittrex' + +pairs: List = [] + +configuration = Configuration(args) +config: Dict[str, Any] = {} if args.config: - configuration = Configuration(args) - - config: Dict[str, Any] = {} # Now expecting a list of config filenames here, not a string for path in args.config: - print(f"Using config: {path}...") + logger.info(f"Using config: {path}...") # Merge config options, overwriting old values config = deep_merge_dicts(configuration._load_config_file(path), config) config['stake_currency'] = '' # Ensure we do not use Exchange credentials + config['exchange']['dry_run'] = True config['exchange']['key'] = '' config['exchange']['secret'] = '' + + pairs = config['exchange']['pair_whitelist'] + + if config.get('ticker_interval'): + timeframes = args.timeframes or [config.get('ticker_interval')] + else: + timeframes = args.timeframes or ['1m', '5m'] + else: config = { 'stake_currency': '', 'dry_run': True, 'exchange': { - 'name': args.exchange, + 'name': exchange_name, 'key': '', 'secret': '', 'pair_whitelist': [], @@ -59,56 +71,72 @@ else: } } } + timeframes = args.timeframes or ['1m', '5m'] +configuration._load_logging_config(config) -dl_path = Path(DEFAULT_DL_PATH).joinpath(config['exchange']['name']) -if args.export: - dl_path = Path(args.export) +if args.config and args.exchange: + logger.warning("The --exchange option is ignored, " + "using exchange settings from the configuration file.") -if not dl_path.is_dir(): - sys.exit(f'Directory {dl_path} does not exist.') +# Check if the exchange set by the user is supported +configuration.check_exchange(config) + +configuration._load_datadir_config(config) + +dl_path = Path(config['datadir']) pairs_file = Path(args.pairs_file) if args.pairs_file else dl_path.joinpath('pairs.json') -if not pairs_file.exists(): - sys.exit(f'No pairs file found with path {pairs_file}.') -with pairs_file.open() as file: - PAIRS = list(set(json.load(file))) +if not pairs or args.pairs_file: + logger.info(f'Reading pairs file "{pairs_file}".') + # Download pairs from the pairs file if no config is specified + # or if pairs file is specified explicitely + if not pairs_file.exists(): + sys.exit(f'No pairs file found with path "{pairs_file}".') -PAIRS.sort() + with pairs_file.open() as file: + pairs = list(set(json.load(file))) + pairs.sort() timerange = TimeRange() if args.days: time_since = arrow.utcnow().shift(days=-args.days).strftime("%Y%m%d") timerange = arguments.parse_timerange(f'{time_since}-') +logger.info(f'About to download pairs: {pairs}, intervals: {timeframes} to {dl_path}') -print(f'About to download pairs: {PAIRS} to {dl_path}') - -# Init exchange -exchange = Exchange(config) pairs_not_available = [] -for pair in PAIRS: - if pair not in exchange._api.markets: - pairs_not_available.append(pair) - print(f"skipping pair {pair}") - continue - for ticker_interval in timeframes: - pair_print = pair.replace('/', '_') - filename = f'{pair_print}-{ticker_interval}.json' - dl_file = dl_path.joinpath(filename) - if args.erase and dl_file.exists(): - print(f'Deleting existing data for pair {pair}, interval {ticker_interval}') - dl_file.unlink() +try: + # Init exchange + exchange = Exchange(config) - print(f'downloading pair {pair}, interval {ticker_interval}') - download_pair_history(datadir=dl_path, exchange=exchange, - pair=pair, - ticker_interval=ticker_interval, - timerange=timerange) + for pair in pairs: + if pair not in exchange._api.markets: + pairs_not_available.append(pair) + logger.info(f"Skipping pair {pair}...") + continue + for ticker_interval in timeframes: + pair_print = pair.replace('/', '_') + filename = f'{pair_print}-{ticker_interval}.json' + dl_file = dl_path.joinpath(filename) + if args.erase and dl_file.exists(): + logger.info( + f'Deleting existing data for pair {pair}, interval {ticker_interval}.') + dl_file.unlink() + logger.info(f'Downloading pair {pair}, interval {ticker_interval}.') + download_pair_history(datadir=dl_path, exchange=exchange, + pair=pair, ticker_interval=str(ticker_interval), + timerange=timerange) -if pairs_not_available: - print(f"Pairs [{','.join(pairs_not_available)}] not availble.") +except KeyboardInterrupt: + sys.exit("SIGINT received, aborting ...") + +finally: + if pairs_not_available: + logger.info( + f"Pairs [{','.join(pairs_not_available)}] not available " + f"on exchange {config['exchange']['name']}.") diff --git a/scripts/plot_dataframe.py b/scripts/plot_dataframe.py index 7fdc607e0..7eaf0b337 100755 --- a/scripts/plot_dataframe.py +++ b/scripts/plot_dataframe.py @@ -26,145 +26,21 @@ Example of usage: """ import logging import sys -from argparse import Namespace from pathlib import Path from typing import Any, Dict, List import pandas as pd -import plotly.graph_objs as go -import pytz -from plotly import tools -from plotly.offline import plot -from freqtrade import persistence -from freqtrade.arguments import Arguments, TimeRange +from freqtrade.arguments import Arguments from freqtrade.data import history -from freqtrade.data.btanalysis import BT_DATA_COLUMNS, load_backtest_data -from freqtrade.exchange import Exchange -from freqtrade.optimize.backtesting import setup_configuration -from freqtrade.persistence import Trade -from freqtrade.resolvers import StrategyResolver +from freqtrade.data.btanalysis import (extract_trades_of_period, + load_backtest_data, load_trades_from_db) +from freqtrade.optimize import setup_configuration +from freqtrade.plot.plotting import generate_graph, generate_plot_file +from freqtrade.resolvers import ExchangeResolver, StrategyResolver +from freqtrade.state import RunMode logger = logging.getLogger(__name__) -_CONF: Dict[str, Any] = {} - -timeZone = pytz.UTC - - -def load_trades(args: Namespace, pair: str, timerange: TimeRange) -> pd.DataFrame: - trades: pd.DataFrame = pd.DataFrame() - if args.db_url: - persistence.init(_CONF) - columns = ["pair", "profit", "open_time", "close_time", - "open_rate", "close_rate", "duration"] - - for x in Trade.query.all(): - print("date: {}".format(x.open_date)) - - trades = pd.DataFrame([(t.pair, t.calc_profit(), - t.open_date.replace(tzinfo=timeZone), - t.close_date.replace(tzinfo=timeZone) if t.close_date else None, - t.open_rate, t.close_rate, - t.close_date.timestamp() - t.open_date.timestamp() - if t.close_date else None) - for t in Trade.query.filter(Trade.pair.is_(pair)).all()], - columns=columns) - - elif args.exportfilename: - - file = Path(args.exportfilename) - if file.exists(): - load_backtest_data(file) - - else: - trades = pd.DataFrame([], columns=BT_DATA_COLUMNS) - - return trades - - -def generate_plot_file(fig, pair, ticker_interval, is_last) -> None: - """ - Generate a plot html file from pre populated fig plotly object - :return: None - """ - logger.info('Generate plot file for %s', pair) - - pair_name = pair.replace("/", "_") - file_name = 'freqtrade-plot-' + pair_name + '-' + ticker_interval + '.html' - - Path("user_data/plots").mkdir(parents=True, exist_ok=True) - - plot(fig, filename=str(Path('user_data/plots').joinpath(file_name)), auto_open=False) - if is_last: - plot(fig, filename=str(Path('user_data').joinpath('freqtrade-plot.html')), auto_open=False) - - -def get_trading_env(args: Namespace): - """ - Initalize freqtrade Exchange and Strategy, split pairs recieved in parameter - :return: Strategy - """ - global _CONF - - # Load the configuration - _CONF.update(setup_configuration(args)) - print(_CONF) - - pairs = args.pairs.split(',') - if pairs is None: - logger.critical('Parameter --pairs mandatory;. E.g --pairs ETH/BTC,XRP/BTC') - exit() - - # Load the strategy - try: - strategy = StrategyResolver(_CONF).strategy - exchange = Exchange(_CONF) - except AttributeError: - logger.critical( - 'Impossible to load the strategy. Please check the file "user_data/strategies/%s.py"', - args.strategy - ) - exit() - - return [strategy, exchange, pairs] - - -def get_tickers_data(strategy, exchange, pairs: List[str], args): - """ - Get tickers data for each pairs on live or local, option defined in args - :return: dictinnary of tickers. output format: {'pair': tickersdata} - """ - - ticker_interval = strategy.ticker_interval - timerange = Arguments.parse_timerange(args.timerange) - - tickers = {} - if args.live: - logger.info('Downloading pairs.') - exchange.refresh_latest_ohlcv([(pair, ticker_interval) for pair in pairs]) - for pair in pairs: - tickers[pair] = exchange.klines((pair, ticker_interval)) - else: - tickers = history.load_data( - datadir=Path(str(_CONF.get("datadir"))), - pairs=pairs, - ticker_interval=ticker_interval, - refresh_pairs=_CONF.get('refresh_pairs', False), - timerange=timerange, - exchange=Exchange(_CONF) - ) - - # No ticker found, impossible to download, len mismatch - for pair, data in tickers.copy().items(): - logger.debug("checking tickers data of pair: %s", pair) - logger.debug("data.empty: %s", data.empty) - logger.debug("len(data): %s", len(data)) - if data.empty: - del tickers[pair] - logger.info( - 'An issue occured while retreiving datas of %s pair, please retry ' - 'using -l option for live or --refresh-pairs-cached', pair) - return tickers def generate_dataframe(strategy, tickers, pair) -> pd.DataFrame: @@ -181,211 +57,7 @@ def generate_dataframe(strategy, tickers, pair) -> pd.DataFrame: return dataframe -def extract_trades_of_period(dataframe, trades) -> pd.DataFrame: - """ - Compare trades and backtested pair DataFrames to get trades performed on backtested period - :return: the DataFrame of a trades of period - """ - trades = trades.loc[trades['open_time'] >= dataframe.iloc[0]['date']] - return trades - - -def generate_graph( - pair: str, - trades: pd.DataFrame, - data: pd.DataFrame, - indicators1: str, - indicators2: str - ) -> tools.make_subplots: - """ - Generate the graph from the data generated by Backtesting or from DB - :param pair: Pair to Display on the graph - :param trades: All trades created - :param data: Dataframe - :indicators1: String Main plot indicators - :indicators2: String Sub plot indicators - :return: None - """ - - # Define the graph - fig = tools.make_subplots( - rows=3, - cols=1, - shared_xaxes=True, - row_width=[1, 1, 4], - vertical_spacing=0.0001, - ) - fig['layout'].update(title=pair) - fig['layout']['yaxis1'].update(title='Price') - fig['layout']['yaxis2'].update(title='Volume') - fig['layout']['yaxis3'].update(title='Other') - fig['layout']['xaxis']['rangeslider'].update(visible=False) - - # Common information - candles = go.Candlestick( - x=data.date, - open=data.open, - high=data.high, - low=data.low, - close=data.close, - name='Price' - ) - - df_buy = data[data['buy'] == 1] - buys = go.Scattergl( - x=df_buy.date, - y=df_buy.close, - mode='markers', - name='buy', - marker=dict( - symbol='triangle-up-dot', - size=9, - line=dict(width=1), - color='green', - ) - ) - df_sell = data[data['sell'] == 1] - sells = go.Scattergl( - x=df_sell.date, - y=df_sell.close, - mode='markers', - name='sell', - marker=dict( - symbol='triangle-down-dot', - size=9, - line=dict(width=1), - color='red', - ) - ) - - trade_buys = go.Scattergl( - x=trades["open_time"], - y=trades["open_rate"], - mode='markers', - name='trade_buy', - marker=dict( - symbol='square-open', - size=11, - line=dict(width=2), - color='green' - ) - ) - trade_sells = go.Scattergl( - x=trades["close_time"], - y=trades["close_rate"], - mode='markers', - name='trade_sell', - marker=dict( - symbol='square-open', - size=11, - line=dict(width=2), - color='red' - ) - ) - - # Row 1 - fig.append_trace(candles, 1, 1) - - if 'bb_lowerband' in data and 'bb_upperband' in data: - bb_lower = go.Scatter( - x=data.date, - y=data.bb_lowerband, - name='BB lower', - line={'color': 'rgba(255,255,255,0)'}, - ) - bb_upper = go.Scatter( - x=data.date, - y=data.bb_upperband, - name='BB upper', - fill="tonexty", - fillcolor="rgba(0,176,246,0.2)", - line={'color': 'rgba(255,255,255,0)'}, - ) - fig.append_trace(bb_lower, 1, 1) - fig.append_trace(bb_upper, 1, 1) - - fig = generate_row(fig=fig, row=1, raw_indicators=indicators1, data=data) - fig.append_trace(buys, 1, 1) - fig.append_trace(sells, 1, 1) - fig.append_trace(trade_buys, 1, 1) - fig.append_trace(trade_sells, 1, 1) - - # Row 2 - volume = go.Bar( - x=data['date'], - y=data['volume'], - name='Volume' - ) - fig.append_trace(volume, 2, 1) - - # Row 3 - fig = generate_row(fig=fig, row=3, raw_indicators=indicators2, data=data) - - return fig - - -def generate_row(fig, row, raw_indicators, data) -> tools.make_subplots: - """ - Generator all the indicator selected by the user for a specific row - """ - for indicator in raw_indicators.split(','): - if indicator in data: - scattergl = go.Scattergl( - x=data['date'], - y=data[indicator], - name=indicator - ) - fig.append_trace(scattergl, row, 1) - else: - logger.info( - 'Indicator "%s" ignored. Reason: This indicator is not found ' - 'in your strategy.', - indicator - ) - - return fig - - -def plot_parse_args(args: List[str]) -> Namespace: - """ - Parse args passed to the script - :param args: Cli arguments - :return: args: Array with all arguments - """ - arguments = Arguments(args, 'Graph dataframe') - arguments.scripts_options() - arguments.parser.add_argument( - '--indicators1', - help='Set indicators from your strategy you want in the first row of the graph. Separate ' - 'them with a coma. E.g: ema3,ema5 (default: %(default)s)', - type=str, - default='sma,ema3,ema5', - dest='indicators1', - ) - - arguments.parser.add_argument( - '--indicators2', - help='Set indicators from your strategy you want in the third row of the graph. Separate ' - 'them with a coma. E.g: fastd,fastk (default: %(default)s)', - type=str, - default='macd,macdsignal', - dest='indicators2', - ) - arguments.parser.add_argument( - '--plot-limit', - help='Specify tick limit for plotting - too high values cause huge files - ' - 'Default: %(default)s', - dest='plot_limit', - default=750, - type=int, - ) - arguments.common_args_parser() - arguments.optimizer_shared_options(arguments.parser) - arguments.backtesting_options(arguments.parser) - return arguments.parse_args() - - -def analyse_and_plot_pairs(args: Namespace): +def analyse_and_plot_pairs(config: Dict[str, Any]): """ From arguments provided in cli: -Initialise backtest env @@ -396,12 +68,28 @@ def analyse_and_plot_pairs(args: Namespace): -Generate plot files :return: None """ - strategy, exchange, pairs = get_trading_env(args) + exchange = ExchangeResolver(config.get('exchange', {}).get('name'), config).exchange + + strategy = StrategyResolver(config).strategy + if "pairs" in config: + pairs = config["pairs"].split(',') + else: + pairs = config["exchange"]["pair_whitelist"] + # Set timerange to use - timerange = Arguments.parse_timerange(args.timerange) + timerange = Arguments.parse_timerange(config["timerange"]) ticker_interval = strategy.ticker_interval - tickers = get_tickers_data(strategy, exchange, pairs, args) + tickers = history.load_data( + datadir=Path(str(config.get("datadir"))), + pairs=pairs, + ticker_interval=config['ticker_interval'], + refresh_pairs=config.get('refresh_pairs', False), + timerange=timerange, + exchange=exchange, + live=config.get("live", False), + ) + pair_counter = 0 for pair, data in tickers.items(): pair_counter += 1 @@ -409,24 +97,47 @@ def analyse_and_plot_pairs(args: Namespace): tickers = {} tickers[pair] = data dataframe = generate_dataframe(strategy, tickers, pair) + if config["trade_source"] == "DB": + trades = load_trades_from_db(config["db_url"]) + elif config["trade_source"] == "file": + trades = load_backtest_data(Path(config["exportfilename"])) - trades = load_trades(args, pair, timerange) + trades = trades.loc[trades['pair'] == pair] trades = extract_trades_of_period(dataframe, trades) fig = generate_graph( pair=pair, - trades=trades, data=dataframe, - indicators1=args.indicators1, - indicators2=args.indicators2 + trades=trades, + indicators1=config["indicators1"].split(","), + indicators2=config["indicators2"].split(",") ) - is_last = (False, True)[pair_counter == len(tickers)] - generate_plot_file(fig, pair, ticker_interval, is_last) + generate_plot_file(fig, pair, ticker_interval) logger.info('End of ploting process %s plots generated', pair_counter) +def plot_parse_args(args: List[str]) -> Dict[str, Any]: + """ + Parse args passed to the script + :param args: Cli arguments + :return: args: Array with all arguments + """ + arguments = Arguments(args, 'Graph dataframe') + arguments.common_options() + arguments.main_options() + arguments.common_optimize_options() + arguments.backtesting_options() + arguments.common_scripts_options() + arguments.plot_dataframe_options() + parsed_args = arguments.parse_args() + + # Load the configuration + config = setup_configuration(parsed_args, RunMode.BACKTEST) + return config + + def main(sysargv: List[str]) -> None: """ This function will initiate the bot and start the trading loop. diff --git a/scripts/plot_profit.py b/scripts/plot_profit.py index 500d9fcde..fd98c120c 100755 --- a/scripts/plot_profit.py +++ b/scripts/plot_profit.py @@ -27,10 +27,12 @@ from plotly.offline import plot from freqtrade.arguments import Arguments from freqtrade.configuration import Configuration from freqtrade.data import history -from freqtrade.misc import common_datearray, timeframe_to_seconds +from freqtrade.exchange import timeframe_to_seconds +from freqtrade.misc import common_datearray from freqtrade.resolvers import StrategyResolver from freqtrade.state import RunMode + logger = logging.getLogger(__name__) @@ -204,10 +206,11 @@ def plot_parse_args(args: List[str]) -> Namespace: :return: args: Array with all arguments """ arguments = Arguments(args, 'Graph profits') - arguments.scripts_options() - arguments.common_args_parser() - arguments.optimizer_shared_options(arguments.parser) - arguments.backtesting_options(arguments.parser) + arguments.common_options() + arguments.main_options() + arguments.common_optimize_options() + arguments.backtesting_options() + arguments.common_scripts_options() return arguments.parse_args() diff --git a/scripts/rest_client.py b/scripts/rest_client.py new file mode 100755 index 000000000..a46b3ebfb --- /dev/null +++ b/scripts/rest_client.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +""" +Simple command line client into RPC commands +Can be used as an alternate to Telegram + +Should not import anything from freqtrade, +so it can be used as a standalone script. +""" + +import argparse +import json +import logging +import inspect +from urllib.parse import urlencode, urlparse, urlunparse +from pathlib import Path + +import requests +from requests.exceptions import ConnectionError + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', +) +logger = logging.getLogger("ft_rest_client") + + +class FtRestClient(): + + def __init__(self, serverurl, username=None, password=None): + + self._serverurl = serverurl + self._session = requests.Session() + self._session.auth = (username, password) + + def _call(self, method, apipath, params: dict = None, data=None, files=None): + + if str(method).upper() not in ('GET', 'POST', 'PUT', 'DELETE'): + raise ValueError('invalid method <{0}>'.format(method)) + basepath = f"{self._serverurl}/api/v1/{apipath}" + + hd = {"Accept": "application/json", + "Content-Type": "application/json" + } + + # Split url + schema, netloc, path, par, query, fragment = urlparse(basepath) + # URLEncode query string + query = urlencode(params) if params else "" + # recombine url + url = urlunparse((schema, netloc, path, par, query, fragment)) + + try: + resp = self._session.request(method, url, headers=hd, data=json.dumps(data)) + # return resp.text + return resp.json() + except ConnectionError: + logger.warning("Connection error") + + def _get(self, apipath, params: dict = None): + return self._call("GET", apipath, params=params) + + def _post(self, apipath, params: dict = None, data: dict = None): + return self._call("POST", apipath, params=params, data=data) + + def start(self): + """ + Start the bot if it's in stopped state. + :return: json object + """ + return self._post("start") + + def stop(self): + """ + Stop the bot. Use start to restart + :return: json object + """ + return self._post("stop") + + def stopbuy(self): + """ + Stop buying (but handle sells gracefully). + use reload_conf to reset + :return: json object + """ + return self._post("stopbuy") + + def reload_conf(self): + """ + Reload configuration + :return: json object + """ + return self._post("reload_conf") + + def balance(self): + """ + Get the account balance + :return: json object + """ + return self._get("balance") + + def count(self): + """ + Returns the amount of open trades + :return: json object + """ + return self._get("count") + + def daily(self, days=None): + """ + Returns the amount of open trades + :return: json object + """ + return self._get("daily", params={"timescale": days} if days else None) + + def edge(self): + """ + Returns information about edge + :return: json object + """ + return self._get("edge") + + def profit(self): + """ + Returns the profit summary + :return: json object + """ + return self._get("profit") + + def performance(self): + """ + Returns the performance of the different coins + :return: json object + """ + return self._get("performance") + + def status(self): + """ + Get the status of open trades + :return: json object + """ + return self._get("status") + + def version(self): + """ + Returns the version of the bot + :return: json object containing the version + """ + return self._get("version") + + def whitelist(self): + """ + Show the current whitelist + :return: json object + """ + return self._get("whitelist") + + def blacklist(self, *args): + """ + Show the current blacklist + :param add: List of coins to add (example: "BNB/BTC") + :return: json object + """ + if not args: + return self._get("blacklist") + else: + return self._post("blacklist", data={"blacklist": args}) + + def forcebuy(self, pair, price=None): + """ + Buy an asset + :param pair: Pair to buy (ETH/BTC) + :param price: Optional - price to buy + :return: json object of the trade + """ + data = {"pair": pair, + "price": price + } + return self._post("forcebuy", data=data) + + def forcesell(self, tradeid): + """ + Force-sell a trade + :param tradeid: Id of the trade (can be received via status command) + :return: json object + """ + + return self._post("forcesell", data={"tradeid": tradeid}) + + +def add_arguments(): + parser = argparse.ArgumentParser() + parser.add_argument("command", + help="Positional argument defining the command to execute.") + + parser.add_argument('--show', + help='Show possible methods with this client', + dest='show', + action='store_true', + default=False + ) + + parser.add_argument('-c', '--config', + help='Specify configuration file (default: %(default)s). ', + dest='config', + type=str, + metavar='PATH', + default='config.json' + ) + + parser.add_argument("command_arguments", + help="Positional arguments for the parameters for [command]", + nargs="*", + default=[] + ) + + args = parser.parse_args() + return vars(args) + + +def load_config(configfile): + file = Path(configfile) + if file.is_file(): + with file.open("r") as f: + config = json.load(f) + return config + return {} + + +def print_commands(): + # Print dynamic help for the different commands using the commands doc-strings + client = FtRestClient(None) + print("Possible commands:") + for x, y in inspect.getmembers(client): + if not x.startswith('_'): + print(f"{x} {getattr(client, x).__doc__}") + + +def main(args): + + if args.get("help"): + print_commands() + + config = load_config(args["config"]) + url = config.get("api_server", {}).get("server_url", "127.0.0.1") + port = config.get("api_server", {}).get("listen_port", "8080") + username = config.get("api_server", {}).get("username") + password = config.get("api_server", {}).get("password") + + server_url = f"http://{url}:{port}" + client = FtRestClient(server_url, username, password) + + m = [x for x, y in inspect.getmembers(client) if not x.startswith('_')] + command = args["command"] + if command not in m: + logger.error(f"Command {command} not defined") + print_commands() + return + + print(getattr(client, command)(*args["command_arguments"])) + + +if __name__ == "__main__": + args = add_arguments() + main(args) diff --git a/setup.py b/setup.py index 35fdb2938..ca2f81d1f 100644 --- a/setup.py +++ b/setup.py @@ -17,7 +17,6 @@ setup(name='freqtrade', author_email='michael.egger@tsn.at', license='GPLv3', packages=['freqtrade'], - scripts=['bin/freqtrade'], setup_requires=['pytest-runner', 'numpy'], tests_require=['pytest', 'pytest-mock', 'pytest-cov'], install_requires=[ @@ -43,6 +42,11 @@ setup(name='freqtrade', ], include_package_data=True, zip_safe=False, + entry_points={ + 'console_scripts': [ + 'freqtrade = freqtrade.main:main', + ], + }, classifiers=[ 'Programming Language :: Python :: 3.6', 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', diff --git a/user_data/hyperopts/sample_hyperopt.py b/user_data/hyperopts/sample_hyperopt.py index 54f65a7e6..7cb55378e 100644 --- a/user_data/hyperopts/sample_hyperopt.py +++ b/user_data/hyperopts/sample_hyperopt.py @@ -79,9 +79,10 @@ class SampleHyperOpts(IHyperOpt): dataframe['close'], dataframe['sar'] )) - dataframe.loc[ - reduce(lambda x, y: x & y, conditions), - 'buy'] = 1 + if conditions: + dataframe.loc[ + reduce(lambda x, y: x & y, conditions), + 'buy'] = 1 return dataframe @@ -138,9 +139,10 @@ class SampleHyperOpts(IHyperOpt): dataframe['sar'], dataframe['close'] )) - dataframe.loc[ - reduce(lambda x, y: x & y, conditions), - 'sell'] = 1 + if conditions: + dataframe.loc[ + reduce(lambda x, y: x & y, conditions), + 'sell'] = 1 return dataframe diff --git a/user_data/strategies/test_strategy.py b/user_data/strategies/test_strategy.py index 3cb78842f..d8ff790b2 100644 --- a/user_data/strategies/test_strategy.py +++ b/user_data/strategies/test_strategy.py @@ -44,14 +44,14 @@ class TestStrategy(IStrategy): # trailing stoploss trailing_stop = False - trailing_stop_positive = 0.01 - trailing_stop_positive_offset = 0.0 # Disabled / not configured + # trailing_stop_positive = 0.01 + # trailing_stop_positive_offset = 0.0 # Disabled / not configured # Optimal ticker interval for the strategy ticker_interval = '5m' # run "populate_indicators" only for new candle - ta_on_candle = False + process_only_new_candles = False # Experimental settings (configuration will overide these if set) use_sell_signal = False @@ -253,6 +253,17 @@ class TestStrategy(IStrategy): dataframe['ha_low'] = heikinashi['low'] """ + # Retrieve best bid and best ask + # ------------------------------------ + """ + # first check if dataprovider is available + if self.dp: + if self.dp.runmode 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] + """ + return dataframe def populate_buy_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: