diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index b333dc19d..19e09c969 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -3,13 +3,15 @@ FROM freqtradeorg/freqtrade:develop # Install dependencies COPY requirements-dev.txt /freqtrade/ RUN apt-get update \ - && apt-get -y install git sudo vim \ + && apt-get -y install git mercurial sudo vim \ && apt-get clean \ && pip install autopep8 -r docs/requirements-docs.txt -r requirements-dev.txt --no-cache-dir \ && useradd -u 1000 -U -m ftuser \ && mkdir -p /home/ftuser/.vscode-server /home/ftuser/.vscode-server-insiders /home/ftuser/commandhistory \ && echo "export PROMPT_COMMAND='history -a'" >> /home/ftuser/.bashrc \ && echo "export HISTFILE=~/commandhistory/.bash_history" >> /home/ftuser/.bashrc \ + && mv /root/.local /home/ftuser/.local/ \ + && chown ftuser:ftuser -R /home/ftuser/.local/ \ && chown ftuser: -R /home/ftuser/ USER ftuser diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index daa10fea7..3f294347a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,13 +79,13 @@ jobs: - name: Backtesting run: | - cp config.json.example config.json + cp config_bittrex.json.example config.json freqtrade create-userdir --userdir user_data freqtrade backtesting --datadir tests/testdata --strategy SampleStrategy - name: Hyperopt run: | - cp config.json.example config.json + cp config_bittrex.json.example config.json freqtrade create-userdir --userdir user_data freqtrade hyperopt --datadir tests/testdata -e 5 --strategy SampleStrategy --hyperopt SampleHyperOpt --hyperopt-loss SharpeHyperOptLossDaily --print-all @@ -117,7 +117,7 @@ jobs: strategy: matrix: os: [ macos-latest ] - python-version: [3.7, 3.8] + python-version: [3.7, 3.8, 3.9] steps: - uses: actions/checkout@v2 @@ -146,8 +146,9 @@ jobs: run: | cd build_helpers && ./install_ta-lib.sh ${HOME}/dependencies/; cd .. - - name: Installation - *nix + - name: Installation - macOS run: | + brew install hdf5 c-blosc python -m pip install --upgrade pip export LD_LIBRARY_PATH=${HOME}/dependencies/lib:$LD_LIBRARY_PATH export TA_LIBRARY_PATH=${HOME}/dependencies/lib @@ -170,13 +171,13 @@ jobs: - name: Backtesting run: | - cp config.json.example config.json + cp config_bittrex.json.example config.json freqtrade create-userdir --userdir user_data freqtrade backtesting --datadir tests/testdata --strategy SampleStrategy - name: Hyperopt run: | - cp config.json.example config.json + cp config_bittrex.json.example config.json freqtrade create-userdir --userdir user_data freqtrade hyperopt --datadir tests/testdata -e 5 --strategy SampleStrategy --hyperopt SampleHyperOpt --hyperopt-loss SharpeHyperOptLossDaily --print-all @@ -237,13 +238,13 @@ jobs: - name: Backtesting run: | - cp config.json.example config.json + cp config_bittrex.json.example config.json freqtrade create-userdir --userdir user_data freqtrade backtesting --datadir tests/testdata --strategy SampleStrategy - name: Hyperopt run: | - cp config.json.example config.json + cp config_bittrex.json.example config.json freqtrade create-userdir --userdir user_data freqtrade hyperopt --datadir tests/testdata -e 5 --strategy SampleStrategy --hyperopt SampleHyperOpt --hyperopt-loss SharpeHyperOptLossDaily --print-all diff --git a/.travis.yml b/.travis.yml index 94239e33f..03a8df49b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -26,12 +26,12 @@ jobs: # - coveralls || true name: pytest - script: - - cp config.json.example config.json + - cp config_bittrex.json.example config.json - freqtrade create-userdir --userdir user_data - freqtrade backtesting --datadir tests/testdata --strategy SampleStrategy name: backtest - script: - - cp config.json.example config.json + - cp config_bittrex.json.example config.json - freqtrade create-userdir --userdir user_data - freqtrade hyperopt --datadir tests/testdata -e 5 --strategy SampleStrategy --hyperopt SampleHyperOpt --hyperopt-loss SharpeHyperOptLossDaily name: hyperopt diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5c52a8e93..afa41ed33 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,7 @@ Few pointers for contributions: - New features need to contain unit tests, must conform to PEP8 (max-line-length = 100) and should be documented with the introduction PR. - PR's can be declared as `[WIP]` - which signify Work in Progress Pull Requests (which are not finished). -If you are unsure, discuss the feature on our [discord server](https://discord.gg/MA9v74M), on [Slack](https://join.slack.com/t/highfrequencybot/shared_invite/zt-k9o2v5ut-jX8Mc4CwNM8CDc2Dyg96YA) or in a [issue](https://github.com/freqtrade/freqtrade/issues) before a PR. +If you are unsure, discuss the feature on our [discord server](https://discord.gg/MA9v74M), on [Slack](https://join.slack.com/t/highfrequencybot/shared_invite/zt-l9d9iqgl-9cVBIeBkCBa8j6upSmd_NA) or in a [issue](https://github.com/freqtrade/freqtrade/issues) before a PR. ## Getting started diff --git a/MANIFEST.in b/MANIFEST.in index c67f5258f..2f59bcc7a 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,5 +1,4 @@ include LICENSE include README.md -include config.json.example recursive-include freqtrade *.py recursive-include freqtrade/templates/ *.j2 *.ipynb diff --git a/README.md b/README.md index 1031e4d67..db648198f 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Telegram is not mandatory. However, this is a great way to control your bot. Mor - `/start`: Starts the trader. - `/stop`: Stops the trader. - `/stopbuy`: Stop entering new trades. -- `/status [table]`: Lists all open trades. +- `/status |[table]`: Lists all or specific open trades. - `/profit`: Lists cumulative profit from all finished trades - `/forcesell |all`: Instantly sells the given trade (Ignoring `minimum_roi`). - `/performance`: Show performance of each finished trade grouped by pair @@ -138,7 +138,7 @@ For any questions not covered by the documentation or for further information ab Please check out our [discord server](https://discord.gg/MA9v74M). -You can also join our [Slack channel](https://join.slack.com/t/highfrequencybot/shared_invite/zt-k9o2v5ut-jX8Mc4CwNM8CDc2Dyg96YA). +You can also join our [Slack channel](https://join.slack.com/t/highfrequencybot/shared_invite/zt-l9d9iqgl-9cVBIeBkCBa8j6upSmd_NA). ### [Bugs / Issues](https://github.com/freqtrade/freqtrade/issues?q=is%3Aissue) diff --git a/build_helpers/publish_docker.sh b/build_helpers/publish_docker.sh index ac0cd2461..9bc1aa0a6 100755 --- a/build_helpers/publish_docker.sh +++ b/build_helpers/publish_docker.sh @@ -30,7 +30,7 @@ if [ $? -ne 0 ]; then fi # Run backtest -docker run --rm -v $(pwd)/config.json.example:/freqtrade/config.json:ro -v $(pwd)/tests:/tests freqtrade:${TAG} backtesting --datadir /tests/testdata --strategy-path /tests/strategy/strats/ --strategy DefaultStrategy +docker run --rm -v $(pwd)/config_bittrex.json.example:/freqtrade/config.json:ro -v $(pwd)/tests:/tests freqtrade:${TAG} backtesting --datadir /tests/testdata --strategy-path /tests/strategy/strats/ --strategy DefaultStrategy if [ $? -ne 0 ]; then echo "failed running backtest" diff --git a/config_binance.json.example b/config_binance.json.example index f3f8eb659..83c9748d7 100644 --- a/config_binance.json.example +++ b/config_binance.json.example @@ -84,12 +84,13 @@ "enabled": false, "listen_ip_address": "127.0.0.1", "listen_port": 8080, - "verbosity": "info", + "verbosity": "error", "jwt_secret_key": "somethingrandom", "CORS_origins": [], - "username": "", - "password": "" + "username": "freqtrader", + "password": "SuperSecurePassword" }, + "bot_name": "freqtrade", "initial_state": "running", "forcebuy_enable": false, "internals": { diff --git a/config.json.example b/config_bittrex.json.example similarity index 94% rename from config.json.example rename to config_bittrex.json.example index af45dac74..0f0bbec4b 100644 --- a/config.json.example +++ b/config_bittrex.json.example @@ -79,12 +79,13 @@ "enabled": false, "listen_ip_address": "127.0.0.1", "listen_port": 8080, - "verbosity": "info", + "verbosity": "error", "jwt_secret_key": "somethingrandom", "CORS_origins": [], - "username": "", - "password": "" + "username": "freqtrader", + "password": "SuperSecurePassword" }, + "bot_name": "freqtrade", "initial_state": "running", "forcebuy_enable": false, "internals": { diff --git a/config_full.json.example b/config_full.json.example index e69e52469..6593750b4 100644 --- a/config_full.json.example +++ b/config_full.json.example @@ -42,6 +42,7 @@ "order_book_max": 1, "use_sell_signal": true, "sell_profit_only": false, + "sell_profit_offset": 0.0, "ignore_roi_if_buy_signal": false }, "order_types": { @@ -103,7 +104,7 @@ } ], "exchange": { - "name": "bittrex", + "name": "binance", "sandbox": false, "key": "your_exchange_key", "secret": "your_exchange_secret", @@ -115,16 +116,21 @@ "aiohttp_trust_env": false }, "pair_whitelist": [ + "ALGO/BTC", + "ATOM/BTC", + "BAT/BTC", + "BCH/BTC", + "BRD/BTC", + "EOS/BTC", "ETH/BTC", + "IOTA/BTC", + "LINK/BTC", "LTC/BTC", - "ETC/BTC", - "DASH/BTC", - "ZEC/BTC", - "XLM/BTC", - "NXT/BTC", - "TRX/BTC", - "ADA/BTC", - "XMR/BTC" + "NEO/BTC", + "NXS/BTC", + "XMR/BTC", + "XRP/BTC", + "XTZ/BTC" ], "pair_blacklist": [ "DOGE/BTC" @@ -147,7 +153,7 @@ "remove_pumps": false }, "telegram": { - "enabled": true, + "enabled": false, "token": "your_telegram_token", "chat_id": "your_telegram_chat_id", "notification_settings": { @@ -164,12 +170,14 @@ "enabled": false, "listen_ip_address": "127.0.0.1", "listen_port": 8080, - "verbosity": "info", + "verbosity": "error", + "enable_openapi": false, "jwt_secret_key": "somethingrandom", "CORS_origins": [], "username": "freqtrader", "password": "SuperSecurePassword" }, + "bot_name": "freqtrade", "db_url": "sqlite:///tradesv3.sqlite", "initial_state": "running", "forcebuy_enable": false, diff --git a/config_kraken.json.example b/config_kraken.json.example index 5f3b57854..3cd90e5d3 100644 --- a/config_kraken.json.example +++ b/config_kraken.json.example @@ -89,12 +89,13 @@ "enabled": false, "listen_ip_address": "127.0.0.1", "listen_port": 8080, - "verbosity": "info", + "verbosity": "error", "jwt_secret_key": "somethingrandom", "CORS_origins": [], - "username": "", - "password": "" + "username": "freqtrader", + "password": "SuperSecurePassword" }, + "bot_name": "freqtrade", "initial_state": "running", "forcebuy_enable": false, "internals": { diff --git a/docs/bot-basics.md b/docs/bot-basics.md index 44f493456..86fb18645 100644 --- a/docs/bot-basics.md +++ b/docs/bot-basics.md @@ -49,8 +49,9 @@ This loop will be repeated again and again until the bot is stopped. [backtesting](backtesting.md) or [hyperopt](hyperopt.md) do only part of the above logic, since most of the trading operations are fully simulated. * Load historic data for configured pairlist. -* Calculate indicators (calls `populate_indicators()`). -* Calls `populate_buy_trend()` and `populate_sell_trend()` +* Calls `bot_loop_start()` once. +* Calculate indicators (calls `populate_indicators()` once per pair). +* Calculate buy / sell signals (calls `populate_buy_trend()` and `populate_sell_trend()` once per pair) * Loops per candle simulating entry and exit points. * Generate backtest report output diff --git a/docs/configuration.md b/docs/configuration.md index b70a85c04..660dd6171 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -16,8 +16,7 @@ In some advanced use cases, multiple configuration files can be specified and us If you used the [Quick start](installation.md/#quick-start) method for installing the bot, the installation script should have already created the default configuration file (`config.json`) for you. -If default configuration file is not created we recommend you to copy and use the `config.json.example` as a template -for your bot configuration. +If default configuration file is not created we recommend you to use `freqtrade new-config --config config.json` to generate a basic configuration file. The Freqtrade configuration file is to be written in the JSON format. @@ -72,8 +71,10 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `ask_strategy.order_book_min` | Bot will scan from the top min to max Order Book Asks searching for a profitable rate.
*Defaults to `1`.*
**Datatype:** Positive Integer | `ask_strategy.order_book_max` | Bot will scan from the top min to max Order Book Asks searching for a profitable rate.
*Defaults to `1`.*
**Datatype:** Positive Integer | `ask_strategy.use_sell_signal` | Use sell signals produced by the strategy in addition to the `minimal_roi`. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `true`.*
**Datatype:** Boolean -| `ask_strategy.sell_profit_only` | Wait until the bot makes a positive profit before taking a sell decision. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `false`.*
**Datatype:** Boolean +| `ask_strategy.sell_profit_only` | Wait until the bot reaches `ask_strategy.sell_profit_offset` before taking a sell decision. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `false`.*
**Datatype:** Boolean +| `ask_strategy.sell_profit_offset` | Sell-signal is only active above this value. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `0.0`.*
**Datatype:** Float (as ratio) | `ask_strategy.ignore_roi_if_buy_signal` | Do not sell if the buy signal is still active. This setting takes preference over `minimal_roi` and `use_sell_signal`. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `false`.*
**Datatype:** Boolean +| `ask_strategy.ignore_buying_expired_candle_after` | Specifies the number of seconds until a buy signal is no longer used.
**Datatype:** Integer | `order_types` | Configure order-types depending on the action (`"buy"`, `"sell"`, `"stoploss"`, `"stoploss_on_exchange"`). [More information below](#understand-order_types). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Dict | `order_time_in_force` | Configure time in force for buy and sell orders. [More information below](#understand-order_time_in_force). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Dict | `exchange.name` | **Required.** Name of the exchange class to use. [List below](#user-content-what-values-for-exchangename).
**Datatype:** String @@ -81,7 +82,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `exchange.key` | API key to use for the exchange. Only required when you are in production mode.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `exchange.secret` | API secret to use for the exchange. Only required when you are in production mode.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `exchange.password` | API password to use for the exchange. Only required when you are in production mode and for exchanges that use password for API requests.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String -| `exchange.pair_whitelist` | List of pairs to use by the bot for trading and to check for potential trades during backtesting. Not used by VolumePairList (see [below](#pairlists-and-pairlist-handlers)).
**Datatype:** List +| `exchange.pair_whitelist` | List of pairs to use by the bot for trading and to check for potential trades during backtesting. Supports regex pairs as `.*/BTC`. Not used by VolumePairList (see [below](#pairlists-and-pairlist-handlers)).
**Datatype:** List | `exchange.pair_blacklist` | List of pairs the bot must absolutely avoid for trading and backtesting (see [below](#pairlists-and-pairlist-handlers)).
**Datatype:** List | `exchange.ccxt_config` | Additional CCXT parameters passed to both ccxt instances (sync and async). This is usually the correct place for ccxt configurations. Parameters may differ from exchange to exchange and are documented in the [ccxt documentation](https://ccxt.readthedocs.io/en/latest/manual.html#instantiation)
**Datatype:** Dict | `exchange.ccxt_sync_config` | Additional CCXT parameters passed to the regular (sync) ccxt instance. Parameters may differ from exchange to exchange and are documented in the [ccxt documentation](https://ccxt.readthedocs.io/en/latest/manual.html#instantiation)
**Datatype:** Dict @@ -91,7 +92,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `edge.*` | Please refer to [edge configuration document](edge.md) for detailed explanation. | `experimental.block_bad_exchanges` | Block exchanges known to not work with freqtrade. Leave on default unless you want to test if that exchange works now.
*Defaults to `true`.*
**Datatype:** Boolean | `pairlists` | Define one or more pairlists to be used. [More information below](#pairlists-and-pairlist-handlers).
*Defaults to `StaticPairList`.*
**Datatype:** List of Dicts -| `protections` | Define one or more protections to be used. [More information below](#protections).
**Datatype:** List of Dicts +| `protections` | Define one or more protections to be used. [More information below](#protections). [Strategy Override](#parameters-in-the-strategy).
**Datatype:** List of Dicts | `telegram.enabled` | Enable the usage of Telegram.
**Datatype:** Boolean | `telegram.token` | Your Telegram bot token. Only required if `telegram.enabled` is `true`.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `telegram.chat_id` | Your personal Telegram account id. Only required if `telegram.enabled` is `true`.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String @@ -108,6 +109,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `api_server.verbosity` | Logging verbosity. `info` will print all RPC Calls, while "error" will only display errors.
**Datatype:** Enum, either `info` or `error`. Defaults to `info`. | `api_server.username` | Username for API server. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `api_server.password` | Password for API server. See the [API Server documentation](rest-api.md) for more details.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String +| `bot_name` | Name of the bot. Passed via API to a client - can be shown to distinguish / name bots.
*Defaults to `freqtrade`*
**Datatype:** String | `db_url` | Declares database URL to use. NOTE: This defaults to `sqlite:///tradesv3.dryrun.sqlite` if `dry_run` is `true`, and to `sqlite:///tradesv3.sqlite` for production instances.
**Datatype:** String, SQLAlchemy connect string | `initial_state` | Defines the initial application state. More information below.
*Defaults to `stopped`.*
**Datatype:** Enum, either `stopped` or `running` | `forcebuy_enable` | Enables the RPC Commands to force a buy. More information below.
**Datatype:** Boolean @@ -141,9 +143,12 @@ Values set in the configuration file always overwrite values set in the strategy * `stake_amount` * `unfilledtimeout` * `disable_dataframe_checks` +* `protections` * `use_sell_signal` (ask_strategy) * `sell_profit_only` (ask_strategy) +* `sell_profit_offset` (ask_strategy) * `ignore_roi_if_buy_signal` (ask_strategy) +* `ignore_buying_expired_candle_after` (ask_strategy) ### Configuring amount per trade @@ -272,6 +277,22 @@ before asking the strategy if we should buy or a sell an asset. After each wait every opened trade wether or not we should sell, and for all the remaining pairs (either the dynamic list of pairs or the static list of pairs) if we should buy. +### Ignoring expired candles + +When working with larger timeframes (for example 1h or more) and using a low `max_open_trades` value, the last candle can be processed as soon as a trade slot becomes available. When processing the last candle, this can lead to a situation where it may not be desirable to use the buy signal on that candle. For example, when using a condition in your strategy where you use a cross-over, that point may have passed too long ago for you to start a trade on it. + +In these situations, you can enable the functionality to ignore candles that are beyond a specified period by setting `ask_strategy.ignore_buying_expired_candle_after` to a positive number, indicating the number of seconds after which the buy signal becomes expired. + +For example, if your strategy is using a 1h timeframe, and you only want to buy within the first 5 minutes when a new candle comes in, you can add the following configuration to your strategy: + +``` json + "ask_strategy":{ + "ignore_buying_expired_candle_after": 300, + "price_side": "bid", + // ... + }, +``` + ### Understand order_types The `order_types` configuration parameter maps actions (`buy`, `sell`, `stoploss`, `emergencysell`) to order-types (`market`, `limit`, ...) as well as configures stoploss to be on the exchange and defines stoploss on exchange update interval in seconds. @@ -671,32 +692,6 @@ export HTTPS_PROXY="http://addr:port" freqtrade ``` -## Embedding Strategies - -Freqtrade provides you with with an easy way to embed the strategy into your configuration file. -This is done by utilizing BASE64 encoding and providing this string at the strategy configuration field, -in your chosen config file. - -### Encoding a string as BASE64 - -This is a quick example, how to generate the BASE64 string in python - -```python -from base64 import urlsafe_b64encode - -with open(file, 'r') as f: - content = f.read() -content = urlsafe_b64encode(content.encode('utf-8')) -``` - -The variable 'content', will contain the strategy file in a BASE64 encoded form. Which can now be set in your configurations file as following - -```json -"strategy": "NameOfStrategy:BASE64String" -``` - -Please ensure that 'NameOfStrategy' is identical to the strategy name! - ## Next step Now you have configured your config.json, the next step is to [start your bot](bot-usage.md). diff --git a/docs/data-download.md b/docs/data-download.md index 2d77a8a17..4c7376933 100644 --- a/docs/data-download.md +++ b/docs/data-download.md @@ -308,10 +308,13 @@ Since this data is large by default, the files use gzip by default. They are sto To use this mode, simply add `--dl-trades` to your call. This will swap the download method to download trades, and resamples the data locally. +!!! Warning "do not use" + You should not use this unless you're a kraken user. Most other exchanges provide OHLCV data with sufficient history. + Example call: ```bash -freqtrade download-data --exchange binance --pairs XRP/ETH ETH/BTC --days 20 --dl-trades +freqtrade download-data --exchange kraken --pairs XRP/EUR ETH/EUR --days 20 --dl-trades ``` !!! Note diff --git a/docs/developer.md b/docs/developer.md index 299f2f77f..831d9d2f8 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 on [discord](https://discord.gg/MA9v74M) or [slack](https://join.slack.com/t/highfrequencybot/shared_invite/zt-k9o2v5ut-jX8Mc4CwNM8CDc2Dyg96YA) 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 on [discord](https://discord.gg/MA9v74M) or [slack](https://join.slack.com/t/highfrequencybot/shared_invite/zt-l9d9iqgl-9cVBIeBkCBa8j6upSmd_NA) where you can ask questions. ## Documentation diff --git a/docs/docker.md b/docs/docker.md deleted file mode 100644 index f4699cf4c..000000000 --- a/docs/docker.md +++ /dev/null @@ -1,201 +0,0 @@ -## Freqtrade with docker without docker-compose - -!!! Warning - The below documentation is provided for completeness and assumes that you are familiar with running docker containers. If you're just starting out with Docker, we recommend to follow the [Quickstart](docker.md) instructions. - -### Download the official Freqtrade docker image - -Pull the image from docker hub. - -Branches / tags available can be checked out on [Dockerhub tags page](https://hub.docker.com/r/freqtradeorg/freqtrade/tags/). - -```bash -docker pull freqtradeorg/freqtrade:stable -# Optionally tag the repository so the run-commands remain shorter -docker tag freqtradeorg/freqtrade:stable 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). - -!!! Note "Docker image update frequency" - The official docker images with tags `stable`, `develop` and `latest` are automatically rebuild once a week to keep the base image up-to-date. - In addition to that, every merge to `develop` will trigger a rebuild for `develop` and `latest`. - -### 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 - -=== "Dry-Run" - ``` bash - touch tradesv3.dryrun.sqlite - ``` - -=== "Production" - ``` bash - touch tradesv3.sqlite - ``` - - -!!! Warning "Database File Path" - Make sure to use the path to the correct database 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/docker/Dockerfile.technical) which adds the [technical](https://github.com/freqtrade/technical) module to the image. - -```bash -docker build -t freqtrade -f docker/Dockerfile.technical . -``` - -If you are developing using Docker, use `docker/Dockerfile.develop` to build a dev Docker image, which will also set up develop dependencies: - -```bash -docker build -f docker/Dockerfile.develop -t freqtrade-dev . -``` - -!!! Warning "Include your config file manually" - 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 [5. Run a restartable docker image](#run-a-restartable-docker-image)") 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 refresh your image. - -#### Adjust timezone - -By default, the container will use UTC timezone. -If you would like to change the timezone use the following 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" - ```bash - docker run --rm -e TZ=`ls -la /etc/localtime | cut -d/ -f8-9` -v `pwd`/config.json:/freqtrade/config.json -it freqtrade - ``` - -!!! Note "MacOS Issues" - The OSX Docker versions after 17.09.1 have a known issue whereby `/etc/localtime` cannot be shared causing Docker to not start.
- A work-around for this is to start with the MacOS command above - 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). - -#### 1. Move your config file and database - -The following will assume that you place your configuration / database files to `~/.freqtrade`, which is a hidden directory in your home directory. Feel free to use a different directory and replace the directory in the upcomming commands. - -```bash -mkdir ~/.freqtrade -mv config.json ~/.freqtrade -mv tradesv3.sqlite ~/.freqtrade -``` - -#### 2. 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 trade --db-url sqlite:///tradesv3.sqlite --strategy MyAwesomeStrategy -``` - -!!! Note - When using docker, it's best to specify `--db-url` explicitly to ensure that the database URL and the mounted database file match. - -!!! Note - All available bot command line parameters can be added to the end of the `docker run` command. - -!!! Note - You can define a [restart policy](https://docs.docker.com/config/containers/start-containers-automatically/) in docker. It can be useful in some cases to use the `--restart unless-stopped` flag (crash of freqtrade or reboot of your system). - -### 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 backtesting --strategy AwsomelyProfitableStrategy -``` - -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/docker_quickstart.md b/docs/docker_quickstart.md index 48ee34954..9cccfa93d 100644 --- a/docs/docker_quickstart.md +++ b/docs/docker_quickstart.md @@ -8,9 +8,7 @@ Start by downloading and installing Docker CE for your platform: * [Windows](https://docs.docker.com/docker-for-windows/install/) * [Linux](https://docs.docker.com/install/) -Optionally, [`docker-compose`](https://docs.docker.com/compose/install/) should be installed and available to follow the [docker quick start guide](#docker-quick-start). - -Once you have Docker installed, simply prepare the config file (e.g. `config.json`) and run the image for `freqtrade` as explained below. +To simplify running freqtrade, please install [`docker-compose`](https://docs.docker.com/compose/install/) should be installed and available to follow the below [docker quick start guide](#docker-quick-start). ## Freqtrade with docker-compose @@ -71,7 +69,7 @@ The last 2 steps in the snippet create the directory with `user_data`, as well a !!! Question "How to edit the bot configuration?" You can edit the configuration at any time, which is available as `user_data/config.json` (within the directory `ft_userdata`) when using the above configuration. - You can also change the both Strategy and commands by editing the `docker-compose.yml` file. + You can also change the both Strategy and commands by editing the command section of your `docker-compose.yml` file. #### Adding a custom strategy @@ -83,7 +81,8 @@ The `SampleStrategy` is run by default. !!! Warning "`SampleStrategy` is just a demo!" The `SampleStrategy` is there for your reference and give you ideas for your own strategy. - Please always backtest the strategy and use dry-run for some time before risking real money! + Please always backtest your strategy and use dry-run for some time before risking real money! + You will find more information about Strategy development in the [Strategy documentation](strategy-customization.md). Once this is done, you're ready to launch the bot in trading mode (Dry-run or Live-trading, depending on your answer to the corresponding question you made above). @@ -91,18 +90,23 @@ Once this is done, you're ready to launch the bot in trading mode (Dry-run or Li docker-compose up -d ``` +#### Monitoring the bot + +You can check for running instances with `docker-compose ps`. +This should list the service `freqtrade` as `running`. If that's not the case, best check the logs (see next point). + #### Docker-compose logs -Logs will be located at: `user_data/logs/freqtrade.log`. -You can check the latest log with the command `docker-compose logs -f`. +Logs will be written to: `user_data/logs/freqtrade.log`. +You can also check the latest log with the command `docker-compose logs -f`. #### Database -The database will be at: `user_data/tradesv3.sqlite` +The database will be located at: `user_data/tradesv3.sqlite` #### Updating freqtrade with docker-compose -To update freqtrade when using `docker-compose` is as simple as running the following 2 commands: +Updating freqtrade when using `docker-compose` is as simple as running the following 2 commands: ``` bash # Download the latest image @@ -120,10 +124,10 @@ This will first pull the latest image, and will then restart the container with Advanced users may edit the docker-compose file further to include all possible options or arguments. -All possible freqtrade arguments will be available by running `docker-compose run --rm freqtrade `. +All freqtrade arguments will be available by running `docker-compose run --rm freqtrade `. !!! Note "`docker-compose run --rm`" - Including `--rm` will clean up the container after completion, and is highly recommended for all modes except trading mode (running with `freqtrade trade` command). + Including `--rm` will remove the container after completion, and is highly recommended for all modes except trading mode (running with `freqtrade trade` command). #### Example: Download data with docker-compose @@ -172,19 +176,19 @@ docker-compose run --rm freqtrade plot-dataframe --strategy AwesomeStrategy -p B The output will be stored in the `user_data/plot` directory, and can be opened with any modern browser. -## Data analayis using docker compose +## Data analysis using docker compose Freqtrade provides a docker-compose file which starts up a jupyter lab server. You can run this server using the following command: ``` bash -docker-compose --rm -f docker/docker-compose-jupyter.yml up +docker-compose -f docker/docker-compose-jupyter.yml up ``` -This will create a dockercontainer running jupyter lab, which will be accessible using `https://127.0.0.1:8888/lab`. +This will create a docker-container running jupyter lab, which will be accessible using `https://127.0.0.1:8888/lab`. Please use the link that's printed in the console after startup for simplified login. -Since part of this image is built on your machine, it is recommended to rebuild the image from time to time to keep freqtrade (and dependencies) uptodate. +Since part of this image is built on your machine, it is recommended to rebuild the image from time to time to keep freqtrade (and dependencies) up-to-date. ``` bash docker-compose -f docker/docker-compose-jupyter.yml build --no-cache diff --git a/docs/edge.md b/docs/edge.md index fd6d2cf7d..6f01fcf65 100644 --- a/docs/edge.md +++ b/docs/edge.md @@ -1,6 +1,6 @@ # Edge positioning -The `Edge Positioning` module uses probability to calculate your win rate and risk reward ration. It will use these statistics to control your strategy trade entry points, position side and, stoploss. +The `Edge Positioning` module uses probability to calculate your win rate and risk reward ratio. It will use these statistics to control your strategy trade entry points, position size and, stoploss. !!! Warning `Edge positioning` is not compatible with dynamic (volume-based) whitelist. @@ -55,7 +55,7 @@ Similarly, we can discover the set of losing trades $T_{lose}$ as follows: $$ T_{lose} = \{o \in O | o \leq 0\} $$ !!! Example - In a section where a strategy made three transactions $O = \{3.5, -1, 15, 0\}$:
+ In a section where a strategy made four transactions $O = \{3.5, -1, 15, 0\}$:
$T_{win} = \{3.5, 15\}$
$T_{lose} = \{-1, 0\}$
@@ -206,7 +206,7 @@ Let's say the stake currency is **ETH** and there is $10$ **ETH** on the wallet. - The strategy detects a sell signal in the **XLM/ETH** market. The bot exits **Trade 1** for a profit of $1$ **ETH**. The total capital in the wallet becomes $11$ **ETH** and the available capital for trading becomes $5.5$ **ETH**. -- **Trade 4** The strategy detects a new buy signal int the **XLM/ETH** market. `Edge Positioning` calculates the stoploss of $2%$, and the position size of $0.055 / 0.02 = 2.75$ **ETH**. +- **Trade 4** The strategy detects a new buy signal int the **XLM/ETH** market. `Edge Positioning` calculates the stoploss of $2\%$, and the position size of $0.055 / 0.02 = 2.75$ **ETH**. ## Configurations diff --git a/docs/faq.md b/docs/faq.md index 5742f512a..8a0c61b29 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -143,7 +143,7 @@ freqtrade hyperopt --hyperopt SampleHyperopt --hyperopt-loss SharpeHyperOptLossD ### Why does it take a long time to run hyperopt? -* Discovering a great strategy with Hyperopt takes time. Study www.freqtrade.io, the Freqtrade Documentation page, join the Freqtrade [Slack community](https://join.slack.com/t/highfrequencybot/shared_invite/zt-k9o2v5ut-jX8Mc4CwNM8CDc2Dyg96YA) - or the Freqtrade [discord community](https://discord.gg/X89cVG). While you patiently wait for the most advanced, free crypto bot in the world, to hand you a possible golden strategy specially designed just for you. +* Discovering a great strategy with Hyperopt takes time. Study www.freqtrade.io, the Freqtrade Documentation page, join the Freqtrade [Slack community](https://join.slack.com/t/highfrequencybot/shared_invite/zt-l9d9iqgl-9cVBIeBkCBa8j6upSmd_NA) - or the Freqtrade [discord community](https://discord.gg/X89cVG). While you patiently wait for the most advanced, free crypto bot in the world, to hand you a possible golden strategy specially designed just for you. * If you wonder why it can take from 20 minutes to days to do 1000 epochs here are some answers: diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index 732dfa5bb..2653406e7 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -10,6 +10,14 @@ If multiple Pairlist Handlers are used, they are chained and a combination of al Inactive markets are always removed from the resulting pairlist. Explicitly blacklisted pairs (those in the `pair_blacklist` configuration setting) are also always removed from the resulting pairlist. +### Pair blacklist + +The pair blacklist (configured via `exchange.pair_blacklist` in the configuration) disallows certain pairs from trading. +This can be as simple as excluding `DOGE/BTC` - which will remove exactly this pair. + +The pair-blacklist does also support wildcards (in regex-style) - so `BNB/.*` will exclude ALL pairs that start with BNB. +You may also use something like `.*DOWN/BTC` or `.*UP/BTC` to exclude leveraged tokens (check Pair naming conventions for your exchange!) + ### Available Pairlist Handlers * [`StaticPairList`](#static-pair-list) (default, if not configured differently) @@ -27,7 +35,7 @@ Inactive markets are always removed from the resulting pairlist. Explicitly blac #### Static Pair List -By default, the `StaticPairList` method is used, which uses a statically defined pair whitelist from the configuration. +By default, the `StaticPairList` method is used, which uses a statically defined pair whitelist from the configuration. The pairlist also supports wildcards (in regex-style) - so `.*/BTC` will include all pairs with BTC as a stake. It uses configuration from `exchange.pair_whitelist` and `exchange.pair_blacklist`. diff --git a/docs/includes/protections.md b/docs/includes/protections.md index 87db17fd8..de34383ac 100644 --- a/docs/includes/protections.md +++ b/docs/includes/protections.md @@ -7,7 +7,8 @@ Protections will protect your strategy from unexpected events and market conditi All protection end times are rounded up to the next candle to avoid sudden, unexpected intra-candle buys. !!! Note - Not all Protections will work for all strategies, and parameters will need to be tuned for your strategy to improve performance. + Not all Protections will work for all strategies, and parameters will need to be tuned for your strategy to improve performance. + To align your protection with your strategy, you can define protections in the strategy. !!! Tip Each Protection can be configured multiple times with different parameters, to allow different levels of protection (short-term / long-term). @@ -39,7 +40,7 @@ All protection end times are rounded up to the next candle to avoid sudden, unex #### Stoploss Guard -`StoplossGuard` selects all trades within `lookback_period`, and determines if the amount of trades that resulted in stoploss are above `trade_limit` - in which case trading will stop for `stop_duration`. +`StoplossGuard` selects all trades within `lookback_period` in minutes (or in candles when using `lookback_period_candles`), and determines if the amount of trades that resulted in stoploss are above `trade_limit` - in which case trading will stop for `stop_duration` in minutes (or in candles when using `stop_duration_candles`). This applies across all pairs, unless `only_per_pair` is set to true, which will then only look at one pair at a time. The below example stops trading for all pairs for 4 candles after the last trade if the bot hit stoploss 4 times within the last 24 candles. @@ -57,14 +58,14 @@ The below example stops trading for all pairs for 4 candles after the last trade ``` !!! Note - `StoplossGuard` considers all trades with the results `"stop_loss"` and `"trailing_stop_loss"` if the resulting profit was negative. + `StoplossGuard` considers all trades with the results `"stop_loss"`, `"stoploss_on_exchange"` and `"trailing_stop_loss"` if the resulting profit was negative. `trade_limit` and `lookback_period` will need to be tuned for your strategy. #### MaxDrawdown -`MaxDrawdown` uses all trades within `lookback_period` (in minutes) to determine the maximum drawdown. If the drawdown is below `max_allowed_drawdown`, trading will stop for `stop_duration` (in minutes) after the last trade - assuming that the bot needs some time to let markets recover. +`MaxDrawdown` uses all trades within `lookback_period` in minutes (or in candles when using `lookback_period_candles`) to determine the maximum drawdown. If the drawdown is below `max_allowed_drawdown`, trading will stop for `stop_duration` in minutes (or in candles when using `stop_duration_candles`) after the last trade - assuming that the bot needs some time to let markets recover. -The below sample stops trading for 12 candles if max-drawdown is > 20% considering all trades within the last 48 candles. +The below sample stops trading for 12 candles if max-drawdown is > 20% considering all pairs - with a minimum of `trade_limit` trades - within the last 48 candles. If desired, `lookback_period` and/or `stop_duration` can be used. ```json "protections": [ @@ -76,13 +77,12 @@ The below sample stops trading for 12 candles if max-drawdown is > 20% consideri "max_allowed_drawdown": 0.2 }, ], - ``` #### Low Profit Pairs -`LowProfitPairs` uses all trades for a pair within `lookback_period` (in minutes) to determine the overall profit ratio. -If that ratio is below `required_profit`, that pair will be locked for `stop_duration` (in minutes). +`LowProfitPairs` uses all trades for a pair within `lookback_period` in minutes (or in candles when using `lookback_period_candles`) to determine the overall profit ratio. +If that ratio is below `required_profit`, that pair will be locked for `stop_duration` in minutes (or in candles when using `stop_duration_candles`). The below example will stop trading a pair for 60 minutes if the pair does not have a required profit of 2% (and a minimum of 2 trades) within the last 6 candles. @@ -100,7 +100,7 @@ The below example will stop trading a pair for 60 minutes if the pair does not h #### Cooldown Period -`CooldownPeriod` locks a pair for `stop_duration` (in minutes) after selling, avoiding a re-entry for this pair for `stop_duration` minutes. +`CooldownPeriod` locks a pair for `stop_duration` in minutes (or in candles when using `stop_duration_candles`) after selling, avoiding a re-entry for this pair for `stop_duration` minutes. The below example will stop trading a pair for 2 candles after closing a trade, allowing this pair to "cool down". @@ -167,3 +167,47 @@ The below example assumes a timeframe of 1 hour: } ], ``` + +You can use the same in your strategy, the syntax is only slightly different: + +``` python +from freqtrade.strategy import IStrategy + +class AwesomeStrategy(IStrategy) + timeframe = '1h' + protections = [ + { + "method": "CooldownPeriod", + "stop_duration_candles": 5 + }, + { + "method": "MaxDrawdown", + "lookback_period_candles": 48, + "trade_limit": 20, + "stop_duration_candles": 4, + "max_allowed_drawdown": 0.2 + }, + { + "method": "StoplossGuard", + "lookback_period_candles": 24, + "trade_limit": 4, + "stop_duration_candles": 2, + "only_per_pair": False + }, + { + "method": "LowProfitPairs", + "lookback_period_candles": 6, + "trade_limit": 2, + "stop_duration_candles": 60, + "required_profit": 0.02 + }, + { + "method": "LowProfitPairs", + "lookback_period_candles": 24, + "trade_limit": 4, + "stop_duration_candles": 2, + "required_profit": 0.01 + } + ] + # ... +``` diff --git a/docs/index.md b/docs/index.md index 38e040d7a..b489861f0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -65,7 +65,7 @@ For any questions not covered by the documentation or for further information ab Please check out our [discord server](https://discord.gg/MA9v74M). -You can also join our [Slack channel](https://join.slack.com/t/highfrequencybot/shared_invite/zt-k9o2v5ut-jX8Mc4CwNM8CDc2Dyg96YA). +You can also join our [Slack channel](https://join.slack.com/t/highfrequencybot/shared_invite/zt-l9d9iqgl-9cVBIeBkCBa8j6upSmd_NA). ## Ready to try? diff --git a/docs/installation.md b/docs/installation.md index be98c45a8..8cb6724cb 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -2,7 +2,7 @@ This page explains how to prepare your environment for running the bot. -Please consider using the prebuilt [docker images](docker.md) to get started quickly while trying out freqtrade evaluating how it operates. +Please consider using the prebuilt [docker images](docker_quickstart.md) to get started quickly while evaluating how freqtrade works. ## Prerequisite @@ -34,7 +34,8 @@ The easiest way to install and run Freqtrade is to clone the bot Github reposito When cloning the repository the default working branch has the name `develop`. This branch contains all last features (can be considered as relatively stable, thanks to automated tests). The `stable` branch contains the code of the last release (done usually once per month on an approximately one week old snapshot of the `develop` branch to prevent packaging bugs, so potentially it's more stable). !!! Note - Python3.7 or higher and the corresponding `pip` are assumed to be available. The install-script will warn you and stop if that's not the case. `git` is also needed to clone the Freqtrade repository. + Python3.7 or higher and the corresponding `pip` are assumed to be available. The install-script will warn you and stop if that's not the case. `git` is also needed to clone the Freqtrade repository. + Also, python headers (`python-dev` / `python-devel`) must be available for the installation to complete successfully. This can be achieved with the following commands: @@ -209,7 +210,7 @@ If this is the first time you run the bot, ensure you are running it in Dry-run freqtrade trade -c config.json ``` -*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. +*Note*: If you run the bot on a server, you should consider using [Docker compose](docker_quickstart.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) Post-installation Tasks @@ -244,6 +245,19 @@ open /Library/Developer/CommandLineTools/Packages/macOS_SDK_headers_for_macOS_10 If this file is inexistent, then you're probably on a different version of MacOS, so you may need to consult the internet for specific resolution details. +### MacOS installation error with python 3.9 + +When using python 3.9 on macOS, it's currently necessary to install some os-level modules to allow dependencies to compile. +The errors you'll see happen during installation and are related to the installation of `tables` or `blosc`. + +You can install the necessary libraries with the following command: + +``` bash +brew install hdf5 c-blosc +``` + +After this, please run the installation (script) again. + ----- Now you have an environment ready, the next step is diff --git a/docs/plotting.md b/docs/plotting.md index ed682e44b..19ddb4f57 100644 --- a/docs/plotting.md +++ b/docs/plotting.md @@ -208,6 +208,7 @@ Sample configuration with inline comments explaining the process: } ``` + !!! Note The above configuration assumes that `ema10`, `ema50`, `senkou_a`, `senkou_b`, `macd`, `macdsignal`, `macdhist` and `rsi` are columns in the DataFrame created by the strategy. diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index 2db336f4a..6fef05f0c 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,3 +1,3 @@ -mkdocs-material==6.2.3 +mkdocs-material==6.2.5 mdx_truly_sane_lists==1.2 pymdown-extensions==8.1 diff --git a/docs/rest-api.md b/docs/rest-api.md index 9bb35ce91..2c7142c61 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -11,7 +11,8 @@ Sample configuration: "enabled": true, "listen_ip_address": "127.0.0.1", "listen_port": 8080, - "verbosity": "info", + "verbosity": "error", + "enable_openapi": false, "jwt_secret_key": "somethingrandom", "CORS_origins": [], "username": "Freqtrader", @@ -263,6 +264,11 @@ whitelist ``` +## OpenAPI interface + +To enable the builtin openAPI interface (Swagger UI), specify `"enable_openapi": true` in the api_server configuration. +This will enable the Swagger UI at the `/docs` endpoint. By default, that's running at http://localhost:8080/docs/ - but it'll depend on your settings. + ## Advanced API usage using JWT tokens !!! Note diff --git a/docs/stoploss.md b/docs/stoploss.md index 1e21fc50d..671e643b0 100644 --- a/docs/stoploss.md +++ b/docs/stoploss.md @@ -78,6 +78,7 @@ At this stage the bot contains the following stoploss support modes: 2. Trailing stop loss. 3. Trailing stop loss, custom positive loss. 4. Trailing stop loss only once the trade has reached a certain offset. +5. [Custom stoploss function](strategy-advanced.md#custom-stoploss) ### Static Stop Loss diff --git a/docs/strategy-advanced.md b/docs/strategy-advanced.md index 359280694..25d217d34 100644 --- a/docs/strategy-advanced.md +++ b/docs/strategy-advanced.md @@ -8,11 +8,185 @@ If you're just getting started, please be familiar with the methods described in !!! Note All callback methods described below should only be implemented in a strategy if they are actually used. +!!! Tip + You can get a strategy template containing all below methods by running `freqtrade new-strategy --strategy MyAwesomeStrategy --template advanced` + +## Custom stoploss + +A stoploss can only ever move upwards - so if you set it to an absolute profit of 2%, you can never move it below this price. +Also, the traditional `stoploss` value serves as an absolute lower level and will be instated as the initial stoploss. + +The usage of the custom stoploss method must be enabled by setting `use_custom_stoploss=True` on the strategy object. +The method must return a stoploss value (float / number) with a relative ratio below the current price. +E.g. `current_profit = 0.05` (5% profit) - stoploss returns `0.02` - then you "locked in" a profit of 3% (`0.05 - 0.02 = 0.03`). + +To simulate a regular trailing stoploss of 4% (trailing 4% behind the maximum reached price) you would use the following very simple method: + +``` python +# additional imports required +from datetime import datetime +from freqtrade.persistence import Trade + +class AwesomeStrategy(IStrategy): + + # ... populate_* methods + + use_custom_stoploss = True + + def custom_stoploss(self, pair: str, trade: 'Trade', current_time: 'datetime', current_rate: float, + current_profit: float, **kwargs) -> float: + """ + Custom stoploss logic, returning the new distance relative to current_rate (as ratio). + e.g. returning -0.05 would create a stoploss 5% below current_rate. + The custom stoploss can never be below self.stoploss, which serves as a hard maximum loss. + + For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ + + When not implemented by a strategy, returns the initial stoploss value + Only called when use_custom_stoploss is set to True. + + :param pair: Pair that's currently analyzed + :param trade: trade object. + :param current_time: datetime object, containing the current datetime + :param current_rate: Rate, calculated based on pricing settings in ask_strategy. + :param current_profit: Current profit (as ratio), calculated based on current_rate. + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return float: New stoploss value, relative to the currentrate + """ + return -0.04 +``` + +Stoploss on exchange works similar to `trailing_stop`, and the stoploss on exchange is updated as configured in `stoploss_on_exchange_interval` ([More details about stoploss on exchange](stoploss.md#stop-loss-on-exchange-freqtrade)). + +!!! Note "Use of dates" + All time-based calculations should be done based on `current_time` - using `datetime.now()` or `datetime.utcnow()` is discouraged, as this will break backtesting support. + +!!! Tip "Trailing stoploss" + It's recommended to disable `trailing_stop` when using custom stoploss values. Both can work in tandem, but you might encounter the trailing stop to move the price higher while your custom function would not want this, causing conflicting behavior. + +### Custom stoploss examples + +The next section will show some examples on what's possible with the custom stoploss function. +Of course, many more things are possible, and all examples can be combined at will. + +#### Time based trailing stop + +Use the initial stoploss for the first 60 minutes, after this change to 10% trailing stoploss, and after 2 hours (120 minutes) we use a 5% trailing stoploss. + +``` python +from datetime import datetime, timedelta +from freqtrade.persistence import Trade + +class AwesomeStrategy(IStrategy): + + # ... populate_* methods + + use_custom_stoploss = True + + def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime, current_rate: float, + current_profit: float, **kwargs) -> float: + + # Make sure you have the longest interval first - these conditions are evaluated from top to bottom. + if current_time - timedelta(minutes=120) > trade.open_date: + return -0.05 + elif current_time - timedelta(minutes=60) > trade.open_date: + return -0.10 + return 1 +``` + +#### Different stoploss per pair + +Use a different stoploss depending on the pair. +In this example, we'll trail the highest price with 10% trailing stoploss for `ETH/BTC` and `XRP/BTC`, with 5% trailing stoploss for `LTC/BTC` and with 15% for all other pairs. + +``` python +from datetime import datetime +from freqtrade.persistence import Trade + +class AwesomeStrategy(IStrategy): + + # ... populate_* methods + + use_custom_stoploss = True + + def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime, current_rate: float, + current_profit: float, **kwargs) -> float: + + if pair in ('ETH/BTC', 'XRP/BTC'): + return -0.10 + elif pair in ('LTC/BTC'): + return -0.05 + return -0.15 +``` + +#### Trailing stoploss with positive offset + +Use the initial stoploss until the profit is above 4%, then use a trailing stoploss of 50% of the current profit with a minimum of 2.5% and a maximum of 5%. + +Please note that the stoploss can only increase, values lower than the current stoploss are ignored. + +``` python +from datetime import datetime, timedelta +from freqtrade.persistence import Trade + +class AwesomeStrategy(IStrategy): + + # ... populate_* methods + + use_custom_stoploss = True + + def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime, current_rate: float, + current_profit: float, **kwargs) -> float: + + if current_profit < 0.04: + return -1 # return a value bigger than the inital stoploss to keep using the inital stoploss + + # After reaching the desired offset, allow the stoploss to trail by half the profit + desired_stoploss = current_profit / 2 + + # Use a minimum of 2.5% and a maximum of 5% + return max(min(desired_stoploss, 0.05), 0.025) +``` + +#### Absolute stoploss + +The below example sets absolute profit levels based on the current profit. + +* Use the regular stoploss until 20% profit is reached +* Once profit is > 40%, stoploss will be at 25%, locking in at least 25% of the profit. +* Once profit is > 25% - stoploss will be 15%. +* Once profit is > 20% - stoploss will be set to 7%. + +``` python +from datetime import datetime +from freqtrade.persistence import Trade + +class AwesomeStrategy(IStrategy): + + # ... populate_* methods + + use_custom_stoploss = True + + def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime, current_rate: float, + current_profit: float, **kwargs) -> float: + + # Calculate as `-desired_stop_from_open + current_profit` to get the distance between current_profit and initial price + if current_profit > 0.40: + return (-0.25 + current_profit) + if current_profit > 0.25: + return (-0.15 + current_profit) + if current_profit > 0.20: + return (-0.7 + current_profit) + return 1 +``` + +--- + ## Custom order timeout rules -Simple, timebased order-timeouts can be configured either via strategy or in the configuration in the `unfilledtimeout` section. +Simple, time-based order-timeouts can be configured either via strategy or in the configuration in the `unfilledtimeout` section. -However, freqtrade also offers a custom callback for both ordertypes, which allows you to decide based on custom criteria if a order did time out or not. +However, freqtrade also offers a custom callback for both order types, which allows you to decide based on custom criteria if a order did time out or not. !!! Note Unfilled order timeouts are not relevant during backtesting or hyperopt, and are only relevant during real (live) trading. Therefore these methods are only called in these circumstances. @@ -28,7 +202,7 @@ The function must return either `True` (cancel order) or `False` (keep order ali from datetime import datetime, timedelta from freqtrade.persistence import Trade -class Awesomestrategy(IStrategy): +class AwesomeStrategy(IStrategy): # ... populate_* methods @@ -67,7 +241,7 @@ class Awesomestrategy(IStrategy): from datetime import datetime from freqtrade.persistence import Trade -class Awesomestrategy(IStrategy): +class AwesomeStrategy(IStrategy): # ... populate_* methods @@ -95,6 +269,8 @@ class Awesomestrategy(IStrategy): return False ``` +--- + ## Bot loop start callback A simple callback which is called once at the start of every bot throttling iteration. @@ -103,7 +279,7 @@ This can be used to perform calculations which are pair independent (apply to al ``` python import requests -class Awesomestrategy(IStrategy): +class AwesomeStrategy(IStrategy): # ... populate_* methods @@ -128,7 +304,7 @@ class Awesomestrategy(IStrategy): `confirm_trade_entry()` can be used to abort a trade entry at the latest second (maybe because the price is not what we expect). ``` python -class Awesomestrategy(IStrategy): +class AwesomeStrategy(IStrategy): # ... populate_* methods @@ -164,7 +340,7 @@ class Awesomestrategy(IStrategy): from freqtrade.persistence import Trade -class Awesomestrategy(IStrategy): +class AwesomeStrategy(IStrategy): # ... populate_* methods @@ -200,6 +376,8 @@ class Awesomestrategy(IStrategy): ``` +--- + ## Derived strategies The strategies can be derived from other strategies. This avoids duplication of your custom strategy code. You can use this technique to override small parts of your main strategy, leaving the rest untouched: @@ -219,4 +397,30 @@ class MyAwesomeStrategy2(MyAwesomeStrategy): trailing_stop = True ``` -Both attributes and methods may be overriden, altering behavior of the original strategy in a way you need. +Both attributes and methods may be overridden, altering behavior of the original strategy in a way you need. + +## Embedding Strategies + +Freqtrade provides you with with an easy way to embed the strategy into your configuration file. +This is done by utilizing BASE64 encoding and providing this string at the strategy configuration field, +in your chosen config file. + +### Encoding a string as BASE64 + +This is a quick example, how to generate the BASE64 string in python + +```python +from base64 import urlsafe_b64encode + +with open(file, 'r') as f: + content = f.read() +content = urlsafe_b64encode(content.encode('utf-8')) +``` + +The variable 'content', will contain the strategy file in a BASE64 encoded form. Which can now be set in your configurations file as following + +```json +"strategy": "NameOfStrategy:BASE64String" +``` + +Please ensure that 'NameOfStrategy' is identical to the strategy name! diff --git a/docs/strategy-customization.md b/docs/strategy-customization.md index ab64d3a67..7e998570f 100644 --- a/docs/strategy-customization.md +++ b/docs/strategy-customization.md @@ -309,7 +309,7 @@ Storing information can be accomplished by creating a new dictionary within the The name of the variable can be chosen at will, but should be prefixed with `cust_` to avoid naming collisions with predefined strategy variables. ```python -class Awesomestrategy(IStrategy): +class AwesomeStrategy(IStrategy): # Create custom dictionary cust_info = {} @@ -653,7 +653,7 @@ The following example queries for the current pair and trades from today, howeve if self.config['runmode'].value in ('live', 'dry_run'): trades = Trade.get_trades([Trade.pair == metadata['pair'], Trade.open_date > datetime.utcnow() - timedelta(days=1), - Trade.is_open == False, + Trade.is_open.is_(False), ]).order_by(Trade.close_date).all() # Summarize profit for this pair. curdayprofit = sum(trade.close_profit for trade in trades) @@ -719,7 +719,7 @@ if self.config['runmode'].value in ('live', 'dry_run'): # fetch closed trades for the last 2 days trades = Trade.get_trades([Trade.pair == metadata['pair'], Trade.open_date > datetime.utcnow() - timedelta(days=2), - Trade.is_open == False, + Trade.is_open.is_(False), ]).all() # Analyze the conditions you'd like to lock the pair .... will probably be different for every strategy sumprofit = sum(trade.close_profit for trade in trades) diff --git a/docs/telegram-usage.md b/docs/telegram-usage.md index 40481684d..57f2e98bd 100644 --- a/docs/telegram-usage.md +++ b/docs/telegram-usage.md @@ -137,6 +137,7 @@ official commands. You can ask at any moment for help with `/help`. | `/show_config` | Shows part of the current configuration with relevant settings to operation | `/logs [limit]` | Show last log messages. | `/status` | Lists all open trades +| `/status ` | Lists one or more specific trade. Separate multiple with a blank space. | `/status table` | List all open trades in a table format. Pending buy orders are marked with an asterisk (*) Pending sell orders are marked with a double asterisk (**) | `/trades [limit]` | List all recently closed trades in a table format. | `/delete ` | Delete a specific trade from the Database. Tries to close open orders. Requires manual handling of this trade on the exchange. diff --git a/docs/windows_installation.md b/docs/windows_installation.md index 5341ce96b..168938973 100644 --- a/docs/windows_installation.md +++ b/docs/windows_installation.md @@ -1,4 +1,4 @@ -We **strongly** recommend that Windows users use [Docker](docker.md) as this will work much easier and smoother (also more secure). +We **strongly** recommend that Windows users use [Docker](docker_quickstart.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. Otherwise, try the instructions below. @@ -52,6 +52,6 @@ error: Microsoft Visual C++ 14.0 is required. Get it with "Microsoft Visual C++ Unfortunately, many packages requiring compilation don't provide a pre-built 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](docker.md) 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 compose](docker_quickstart.md) first. --- diff --git a/freqtrade/__init__.py b/freqtrade/__init__.py index 170f95015..74c8c412c 100644 --- a/freqtrade/__init__.py +++ b/freqtrade/__init__.py @@ -1,5 +1,5 @@ """ Freqtrade bot """ -__version__ = '2020.12' +__version__ = '2021.1' if __version__ == 'develop': diff --git a/freqtrade/commands/data_commands.py b/freqtrade/commands/data_commands.py index 25c7d0436..1ce02eee5 100644 --- a/freqtrade/commands/data_commands.py +++ b/freqtrade/commands/data_commands.py @@ -10,6 +10,7 @@ from freqtrade.data.history import (convert_trades_to_ohlcv, refresh_backtest_oh refresh_backtest_trades_data) from freqtrade.exceptions import OperationalException from freqtrade.exchange import timeframe_to_minutes +from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist from freqtrade.resolvers import ExchangeResolver from freqtrade.state import RunMode @@ -42,15 +43,17 @@ def start_download_data(args: Dict[str, Any]) -> None: "Downloading data requires a list of pairs. " "Please check the documentation on how to configure this.") - logger.info(f"About to download pairs: {config['pairs']}, " - f"intervals: {config['timeframes']} to {config['datadir']}") - pairs_not_available: List[str] = [] # Init exchange exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config, validate=False) # Manual validations of relevant settings exchange.validate_pairs(config['pairs']) + expanded_pairs = expand_pairlist(config['pairs'], list(exchange.markets)) + + logger.info(f"About to download pairs: {expanded_pairs}, " + f"intervals: {config['timeframes']} to {config['datadir']}") + for timeframe in config['timeframes']: exchange.validate_timeframes(timeframe) @@ -58,20 +61,20 @@ def start_download_data(args: Dict[str, Any]) -> None: if config.get('download_trades'): pairs_not_available = refresh_backtest_trades_data( - exchange, pairs=config['pairs'], datadir=config['datadir'], + exchange, pairs=expanded_pairs, datadir=config['datadir'], timerange=timerange, erase=bool(config.get('erase')), data_format=config['dataformat_trades']) # Convert downloaded trade data to different timeframes convert_trades_to_ohlcv( - pairs=config['pairs'], timeframes=config['timeframes'], + pairs=expanded_pairs, timeframes=config['timeframes'], datadir=config['datadir'], timerange=timerange, erase=bool(config.get('erase')), data_format_ohlcv=config['dataformat_ohlcv'], data_format_trades=config['dataformat_trades'], - ) + ) else: pairs_not_available = refresh_backtest_ohlcv_data( - exchange, pairs=config['pairs'], timeframes=config['timeframes'], + exchange, pairs=expanded_pairs, timeframes=config['timeframes'], datadir=config['datadir'], timerange=timerange, erase=bool(config.get('erase')), data_format=config['dataformat_ohlcv']) diff --git a/freqtrade/configuration/config_validation.py b/freqtrade/configuration/config_validation.py index b8829b80f..187b2e3c7 100644 --- a/freqtrade/configuration/config_validation.py +++ b/freqtrade/configuration/config_validation.py @@ -54,7 +54,7 @@ def validate_config_schema(conf: Dict[str, Any]) -> Dict[str, Any]: return conf except ValidationError as e: logger.critical( - f"Invalid configuration. See config.json.example. Reason: {e}" + f"Invalid configuration. Reason: {e}" ) raise ValidationError( best_match(Draft4Validator(conf_schema).iter_errors(conf)).message diff --git a/freqtrade/constants.py b/freqtrade/constants.py index e7d7e80f6..69301ca0e 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -116,6 +116,7 @@ CONF_SCHEMA = { 'trailing_stop_positive': {'type': 'number', 'minimum': 0, 'maximum': 1}, 'trailing_stop_positive_offset': {'type': 'number', 'minimum': 0, 'maximum': 1}, 'trailing_only_offset_is_reached': {'type': 'boolean'}, + 'bot_name': {'type': 'string'}, 'unfilledtimeout': { 'type': 'object', 'properties': { @@ -154,6 +155,7 @@ CONF_SCHEMA = { 'order_book_max': {'type': 'integer', 'minimum': 1, 'maximum': 50}, 'use_sell_signal': {'type': 'boolean'}, 'sell_profit_only': {'type': 'boolean'}, + 'sell_profit_offset': {'type': 'number', 'minimum': 0.0}, 'ignore_roi_if_buy_signal': {'type': 'boolean'} } }, diff --git a/freqtrade/data/btanalysis.py b/freqtrade/data/btanalysis.py index 513fba9e7..2b51f5371 100644 --- a/freqtrade/data/btanalysis.py +++ b/freqtrade/data/btanalysis.py @@ -347,7 +347,7 @@ def create_cum_profit(df: pd.DataFrame, trades: pd.DataFrame, col_name: str, # Resample to timeframe to make sure trades match candles _trades_sum = trades.resample(f'{timeframe_minutes}min', on='close_date' )[['profit_percent']].sum() - df.loc[:, col_name] = _trades_sum.cumsum() + df.loc[:, col_name] = _trades_sum['profit_percent'].cumsum() # Set first value to 0 df.loc[df.iloc[0].name, col_name] = 0 # FFill to get continuous diff --git a/freqtrade/edge/edge_positioning.py b/freqtrade/edge/edge_positioning.py index 037717c68..e549a3701 100644 --- a/freqtrade/edge/edge_positioning.py +++ b/freqtrade/edge/edge_positioning.py @@ -12,6 +12,7 @@ from freqtrade.configuration import TimeRange from freqtrade.constants import DATETIME_PRINT_FORMAT, UNLIMITED_STAKE_AMOUNT from freqtrade.data.history import get_timerange, load_data, refresh_data from freqtrade.exceptions import OperationalException +from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist from freqtrade.strategy.interface import SellType @@ -80,10 +81,12 @@ class Edge: if config.get('fee'): self.fee = config['fee'] else: - self.fee = self.exchange.get_fee(symbol=self.config['exchange']['pair_whitelist'][0]) + self.fee = self.exchange.get_fee(symbol=expand_pairlist( + self.config['exchange']['pair_whitelist'], list(self.exchange.markets))[0]) def calculate(self) -> bool: - pairs = self.config['exchange']['pair_whitelist'] + pairs = expand_pairlist(self.config['exchange']['pair_whitelist'], + list(self.exchange.markets)) heartbeat = self.edge_config.get('process_throttle_secs') if (self._last_updated > 0) and ( diff --git a/freqtrade/exchange/common.py b/freqtrade/exchange/common.py index ce0fde9e4..c66db860f 100644 --- a/freqtrade/exchange/common.py +++ b/freqtrade/exchange/common.py @@ -21,6 +21,7 @@ BAD_EXCHANGES = { "hitbtc": "This API cannot be used with Freqtrade. " "Use `hitbtc2` exchange id to access this exchange.", "phemex": "Does not provide history. ", + "poloniex": "Does not provide fetch_order endpoint to fetch both open and closed orders.", **dict.fromkeys([ 'adara', 'anxpro', diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 6f495e605..436c8e4e9 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -25,6 +25,7 @@ from freqtrade.exceptions import (DDosProtection, ExchangeError, InsufficientFun from freqtrade.exchange.common import (API_FETCH_ORDER_RETRY_COUNT, BAD_EXCHANGES, retrier, retrier_async) from freqtrade.misc import deep_merge_dicts, safe_value_fallback2 +from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist CcxtModuleType = Any @@ -208,7 +209,7 @@ class Exchange: return self._api.precisionMode def get_markets(self, base_currencies: List[str] = None, quote_currencies: List[str] = None, - pairs_only: bool = False, active_only: bool = False) -> Dict: + pairs_only: bool = False, active_only: bool = False) -> Dict[str, Any]: """ Return exchange ccxt markets, filtered out by base currency and quote currency if this was requested in parameters. @@ -335,8 +336,9 @@ class Exchange: if not self.markets: logger.warning('Unable to validate pairs (assuming they are correct).') return + extended_pairs = expand_pairlist(pairs, list(self.markets), keep_invalid=True) invalid_pairs = [] - for pair in pairs: + for pair in extended_pairs: # Note: ccxt has BaseCurrency/QuoteCurrency format for pairs # TODO: add a support for having coins in BTC/USDT format if self.markets and pair not in self.markets: @@ -936,7 +938,7 @@ class Exchange: while True: t = await self._async_fetch_trades(pair, since=since) if len(t): - since = t[-1][1] + since = t[-1][0] trades.extend(t) # Reached the end of the defined-download period if until and t[-1][0] > until: diff --git a/freqtrade/exchange/kraken.py b/freqtrade/exchange/kraken.py index 6dbb751e5..724b11189 100644 --- a/freqtrade/exchange/kraken.py +++ b/freqtrade/exchange/kraken.py @@ -48,7 +48,7 @@ class Kraken(Exchange): orders = self._api.fetch_open_orders() order_list = [(x["symbol"].split("/")[0 if x["side"] == "sell" else 1], - x["remaining"], + x["remaining"] if x["side"] == "sell" else x["remaining"] * x["price"], # Don't remove the below comment, this can be important for debuggung # x["side"], x["amount"], ) for x in orders] diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index d60b111f2..f45d4cacc 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -200,7 +200,7 @@ class FreqtradeBot(LoggingMixin): Notify the user when the bot is stopped and there are still open trades active. """ - open_trades = Trade.get_trades([Trade.is_open == 1]).all() + open_trades = Trade.get_trades([Trade.is_open.is_(True)]).all() if len(open_trades) != 0: msg = { @@ -246,6 +246,10 @@ class FreqtradeBot(LoggingMixin): Updates open orders based on order list kept in the database. Mainly updates the state of orders - but may also close trades """ + if self.config['dry_run']: + # Updating open orders in dry-run does not make sense and will fail. + return + orders = Order.get_open_orders() logger.info(f"Updating {len(orders)} open orders.") for order in orders: @@ -256,6 +260,7 @@ class FreqtradeBot(LoggingMixin): self.update_trade_state(order.trade, order.order_id, fo) except ExchangeError as e: + logger.warning(f"Error updating Order {order.order_id} due to {e}") def update_closed_trades_without_assigned_fees(self): @@ -263,6 +268,10 @@ class FreqtradeBot(LoggingMixin): Update closed trades without close fees assigned. Only acts when Orders are in the database, otherwise the last orderid is unknown. """ + if self.config['dry_run']: + # Updating open orders in dry-run does not make sense and will fail. + return + trades: List[Trade] = Trade.get_sold_trades_without_assigned_fees() for trade in trades: diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index a689786ec..106d0f200 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -6,7 +6,7 @@ This module contains the backtesting logic import logging from collections import defaultdict from copy import deepcopy -from datetime import datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any, Dict, List, NamedTuple, Optional, Tuple from pandas import DataFrame @@ -26,6 +26,7 @@ from freqtrade.plugins.pairlistmanager import PairListManager from freqtrade.plugins.protectionmanager import ProtectionManager from freqtrade.resolvers import ExchangeResolver, StrategyResolver from freqtrade.strategy.interface import IStrategy, SellCheckTuple, SellType +from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper logger = logging.getLogger(__name__) @@ -76,6 +77,8 @@ class Backtesting: # Reset keys for backtesting remove_credentials(self.config) self.strategylist: List[IStrategy] = [] + self.all_results: Dict[str, Dict] = {} + self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config) dataprovider = DataProvider(self.config, self.exchange) @@ -150,6 +153,10 @@ class Backtesting: self.strategy.order_types['stoploss_on_exchange'] = False def load_bt_data(self) -> Tuple[Dict[str, DataFrame], TimeRange]: + """ + Loads backtest data and returns the data combined with the timerange + as tuple. + """ timerange = TimeRange.parse_timerange(None if self.config.get( 'timerange') is None else str(self.config.get('timerange'))) @@ -180,6 +187,7 @@ class Backtesting: Backtesting setup method - called once for every call to "backtest()". """ PairLocks.use_db = False + PairLocks.timeframe = self.config['timeframe'] Trade.use_db = False if enable_protections: # Reset persisted data - used for protections only @@ -423,6 +431,53 @@ class Backtesting: return DataFrame.from_records(trades, columns=BacktestResult._fields) + def backtest_one_strategy(self, strat: IStrategy, data: Dict[str, Any], timerange: TimeRange): + logger.info("Running backtesting for Strategy %s", strat.get_strategy_name()) + backtest_start_time = datetime.now(timezone.utc) + self._set_strategy(strat) + + strategy_safe_wrapper(self.strategy.bot_loop_start, supress_error=True)() + + # Use max_open_trades in backtesting, except --disable-max-market-positions is set + if self.config.get('use_max_market_positions', True): + # Must come from strategy config, as the strategy may modify this setting. + max_open_trades = self.strategy.config['max_open_trades'] + else: + logger.info( + 'Ignoring max_open_trades (--disable-max-market-positions was used) ...') + max_open_trades = 0 + + # need to reprocess data every time to populate signals + preprocessed = self.strategy.ohlcvdata_to_dataframe(data) + + # Trim startup period from analyzed dataframe + for pair, df in preprocessed.items(): + preprocessed[pair] = trim_dataframe(df, timerange) + min_date, max_date = history.get_timerange(preprocessed) + + logger.info(f'Backtesting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} ' + f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} ' + f'({(max_date - min_date).days} days)..') + # Execute backtest and store results + results = self.backtest( + processed=preprocessed, + stake_amount=self.config['stake_amount'], + start_date=min_date.datetime, + end_date=max_date.datetime, + max_open_trades=max_open_trades, + position_stacking=self.config.get('position_stacking', False), + enable_protections=self.config.get('enable_protections', False), + ) + backtest_end_time = datetime.now(timezone.utc) + self.all_results[self.strategy.get_strategy_name()] = { + 'results': results, + 'config': self.strategy.config, + 'locks': PairLocks.locks, + 'backtest_start_time': int(backtest_start_time.timestamp()), + 'backtest_end_time': int(backtest_end_time.timestamp()), + } + return min_date, max_date + def start(self) -> None: """ Run backtesting end-to-end @@ -430,55 +485,15 @@ class Backtesting: """ data: Dict[str, Any] = {} - logger.info('Using stake_currency: %s ...', self.config['stake_currency']) - logger.info('Using stake_amount: %s ...', self.config['stake_amount']) - - position_stacking = self.config.get('position_stacking', False) - data, timerange = self.load_bt_data() - all_results = {} + min_date = None + max_date = None for strat in self.strategylist: - logger.info("Running backtesting for Strategy %s", strat.get_strategy_name()) - self._set_strategy(strat) + min_date, max_date = self.backtest_one_strategy(strat, data, timerange) - # Use max_open_trades in backtesting, except --disable-max-market-positions is set - if self.config.get('use_max_market_positions', True): - # Must come from strategy config, as the strategy may modify this setting. - max_open_trades = self.strategy.config['max_open_trades'] - else: - logger.info( - 'Ignoring max_open_trades (--disable-max-market-positions was used) ...') - max_open_trades = 0 - - # need to reprocess data every time to populate signals - preprocessed = self.strategy.ohlcvdata_to_dataframe(data) - - # Trim startup period from analyzed dataframe - for pair, df in preprocessed.items(): - preprocessed[pair] = trim_dataframe(df, timerange) - min_date, max_date = history.get_timerange(preprocessed) - - logger.info(f'Backtesting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} ' - f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} ' - f'({(max_date - min_date).days} days)..') - # Execute backtest and print results - results = self.backtest( - processed=preprocessed, - stake_amount=self.config['stake_amount'], - start_date=min_date.datetime, - end_date=max_date.datetime, - max_open_trades=max_open_trades, - position_stacking=position_stacking, - enable_protections=self.config.get('enable_protections', False), - ) - all_results[self.strategy.get_strategy_name()] = { - 'results': results, - 'config': self.strategy.config, - 'locks': PairLocks.locks, - } - - stats = generate_backtest_stats(data, all_results, min_date=min_date, max_date=max_date) + stats = generate_backtest_stats(data, self.all_results, + min_date=min_date, max_date=max_date) if self.config.get('export', False): store_backtest_stats(self.config['exportfilename'], stats) diff --git a/freqtrade/optimize/hyperopt.py b/freqtrade/optimize/hyperopt.py index 2a2f5b472..d4b9f4c3b 100644 --- a/freqtrade/optimize/hyperopt.py +++ b/freqtrade/optimize/hyperopt.py @@ -650,7 +650,7 @@ class Hyperopt: # Trim startup period from analyzed dataframe for pair, df in preprocessed.items(): preprocessed[pair] = trim_dataframe(df, timerange) - min_date, max_date = get_timerange(data) + min_date, max_date = get_timerange(preprocessed) logger.info(f'Hyperopting with data from {min_date.strftime(DATETIME_PRINT_FORMAT)} ' f'up to {max_date.strftime(DATETIME_PRINT_FORMAT)} ' diff --git a/freqtrade/optimize/optimize_reports.py b/freqtrade/optimize/optimize_reports.py index d029ecd13..96ddb91a0 100644 --- a/freqtrade/optimize/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports.py @@ -282,6 +282,9 @@ def generate_backtest_stats(btdata: Dict[str, DataFrame], 'backtest_end_ts': max_date.int_timestamp * 1000, 'backtest_days': backtest_days, + 'backtest_run_start_ts': content['backtest_start_time'], + 'backtest_run_end_ts': content['backtest_end_time'], + 'trades_per_day': round(len(results) / backtest_days, 2) if backtest_days > 0 else 0, 'market_change': market_change, 'pairlist': list(btdata.keys()), @@ -290,15 +293,20 @@ def generate_backtest_stats(btdata: Dict[str, DataFrame], 'max_open_trades': (config['max_open_trades'] if config['max_open_trades'] != float('inf') else -1), 'timeframe': config['timeframe'], + 'timerange': config.get('timerange', ''), + 'enable_protections': config.get('enable_protections', False), + 'strategy_name': strategy, # Parameters relevant for backtesting 'stoploss': config['stoploss'], 'trailing_stop': config.get('trailing_stop', False), 'trailing_stop_positive': config.get('trailing_stop_positive'), 'trailing_stop_positive_offset': config.get('trailing_stop_positive_offset', 0.0), 'trailing_only_offset_is_reached': config.get('trailing_only_offset_is_reached', False), + 'use_custom_stoploss': config.get('use_custom_stoploss', False), 'minimal_roi': config['minimal_roi'], 'use_sell_signal': config['ask_strategy']['use_sell_signal'], 'sell_profit_only': config['ask_strategy']['sell_profit_only'], + 'sell_profit_offset': config['ask_strategy']['sell_profit_offset'], 'ignore_roi_if_buy_signal': config['ask_strategy']['ignore_roi_if_buy_signal'], **daily_stats, } diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index 7fa894e9c..e803b4383 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -342,6 +342,12 @@ class Trade(_DECL_BASE): self.max_rate = max(current_price, self.max_rate or self.open_rate) self.min_rate = min(current_price, self.min_rate or self.open_rate) + def _set_new_stoploss(self, new_loss: float, stoploss: float): + """Assign new stop value""" + self.stop_loss = new_loss + self.stop_loss_pct = -1 * abs(stoploss) + self.stoploss_last_update = datetime.utcnow() + def adjust_stop_loss(self, current_price: float, stoploss: float, initial: bool = False) -> None: """ @@ -360,19 +366,15 @@ class Trade(_DECL_BASE): # no stop loss assigned yet if not self.stop_loss: logger.debug(f"{self.pair} - Assigning new stoploss...") - self.stop_loss = new_loss - self.stop_loss_pct = -1 * abs(stoploss) + self._set_new_stoploss(new_loss, stoploss) self.initial_stop_loss = new_loss self.initial_stop_loss_pct = -1 * abs(stoploss) - self.stoploss_last_update = datetime.utcnow() # evaluate if the stop loss needs to be updated else: if new_loss > self.stop_loss: # stop losses only walk up, never down! logger.debug(f"{self.pair} - Adjusting stoploss...") - self.stop_loss = new_loss - self.stop_loss_pct = -1 * abs(stoploss) - self.stoploss_last_update = datetime.utcnow() + self._set_new_stoploss(new_loss, stoploss) else: logger.debug(f"{self.pair} - Keeping current stoploss...") diff --git a/freqtrade/plot/plotting.py b/freqtrade/plot/plotting.py index 497218deb..996c5276c 100644 --- a/freqtrade/plot/plotting.py +++ b/freqtrade/plot/plotting.py @@ -13,6 +13,7 @@ from freqtrade.data.history import get_timerange, load_data from freqtrade.exceptions import OperationalException from freqtrade.exchange import timeframe_to_prev_date, timeframe_to_seconds from freqtrade.misc import pair_to_filename +from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist from freqtrade.resolvers import ExchangeResolver, StrategyResolver from freqtrade.strategy import IStrategy @@ -29,16 +30,16 @@ except ImportError: exit(1) -def init_plotscript(config, startup_candles: int = 0): +def init_plotscript(config, markets: List, startup_candles: int = 0): """ Initialize objects needed for plotting :return: Dict with candle (OHLCV) data, trades and pairs """ if "pairs" in config: - pairs = config['pairs'] + pairs = expand_pairlist(config['pairs'], markets) else: - pairs = config['exchange']['pair_whitelist'] + pairs = expand_pairlist(config['exchange']['pair_whitelist'], markets) # Set timerange to use timerange = TimeRange.parse_timerange(config.get('timerange')) @@ -177,7 +178,7 @@ def plot_trades(fig, trades: pd.DataFrame) -> make_subplots: trades['desc'] = trades.apply(lambda row: f"{round(row['profit_percent'] * 100, 1)}%, " f"{row['sell_reason']}, " f"{row['trade_duration']} min", - axis=1) + axis=1) trade_buys = go.Scatter( x=trades["open_date"], y=trades["open_rate"], @@ -444,6 +445,8 @@ def generate_profit_graph(pairs: str, data: Dict[str, pd.DataFrame], # Trim trades to available OHLCV data trades = extract_trades_of_period(df_comb, trades, date_index=True) + if len(trades) == 0: + raise OperationalException('No trades found in selected timerange.') # Add combined cumulative profit df_comb = create_cum_profit(df_comb, trades, 'cum_profit', timeframe) @@ -525,7 +528,7 @@ def load_and_plot_trades(config: Dict[str, Any]): exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config) IStrategy.dp = DataProvider(config, exchange) - plot_elements = init_plotscript(config, strategy.startup_candle_count) + plot_elements = init_plotscript(config, list(exchange.markets), strategy.startup_candle_count) timerange = plot_elements['timerange'] trades = plot_elements['trades'] pair_counter = 0 @@ -560,7 +563,8 @@ def plot_profit(config: Dict[str, Any]) -> None: But should be somewhat proportional, and therefor useful in helping out to find a good algorithm. """ - plot_elements = init_plotscript(config) + exchange = ExchangeResolver.load_exchange(config['exchange']['name'], config) + plot_elements = init_plotscript(config, list(exchange.markets)) trades = plot_elements['trades'] # Filter trades to relevant pairs # Remove open pairs - we don't know the profit yet so can't calculate profit for these. diff --git a/freqtrade/plugins/pairlist/IPairList.py b/freqtrade/plugins/pairlist/IPairList.py index 865aa90d6..95d776ae6 100644 --- a/freqtrade/plugins/pairlist/IPairList.py +++ b/freqtrade/plugins/pairlist/IPairList.py @@ -124,10 +124,21 @@ class IPairList(LoggingMixin, ABC): """ return self._pairlistmanager.verify_blacklist(pairlist, logmethod) + def verify_whitelist(self, pairlist: List[str], logmethod, + keep_invalid: bool = False) -> List[str]: + """ + Proxy method to verify_whitelist for easy access for child classes. + :param pairlist: Pairlist to validate + :param logmethod: Function that'll be called, `logger.info` or `logger.warning` + :param keep_invalid: If sets to True, drops invalid pairs silently while expanding regexes. + :return: pairlist - whitelisted pairs + """ + return self._pairlistmanager.verify_whitelist(pairlist, logmethod, keep_invalid) + def _whitelist_for_active_markets(self, pairlist: List[str]) -> List[str]: """ Check available markets and remove pair from whitelist if necessary - :param whitelist: the sorted list of pairs the user might want to trade + :param pairlist: the sorted list of pairs the user might want to trade :return: the list of pairs the user wants to trade without those unavailable or black_listed """ diff --git a/freqtrade/plugins/pairlist/SpreadFilter.py b/freqtrade/plugins/pairlist/SpreadFilter.py index 2f3fe47e3..9fa211750 100644 --- a/freqtrade/plugins/pairlist/SpreadFilter.py +++ b/freqtrade/plugins/pairlist/SpreadFilter.py @@ -43,7 +43,7 @@ class SpreadFilter(IPairList): :param ticker: ticker dict as returned from ccxt.load_markets() :return: True if the pair can stay, false if it should be removed """ - if 'bid' in ticker and 'ask' in ticker: + if 'bid' in ticker and 'ask' in ticker and ticker['ask']: spread = 1 - ticker['bid'] / ticker['ask'] if spread > self._max_spread_ratio: self.log_once(f"Removed {pair} from whitelist, because spread " @@ -52,4 +52,6 @@ class SpreadFilter(IPairList): return False else: return True + self.log_once(f"Removed {pair} from whitelist due to invalid ticker data: {ticker}", + logger.info) return False diff --git a/freqtrade/plugins/pairlist/StaticPairList.py b/freqtrade/plugins/pairlist/StaticPairList.py index dd592e0ca..c5ced48c9 100644 --- a/freqtrade/plugins/pairlist/StaticPairList.py +++ b/freqtrade/plugins/pairlist/StaticPairList.py @@ -50,9 +50,12 @@ class StaticPairList(IPairList): :return: List of pairs """ if self._allow_inactive: - return self._config['exchange']['pair_whitelist'] + return self.verify_whitelist( + self._config['exchange']['pair_whitelist'], logger.info, keep_invalid=True + ) else: - return self._whitelist_for_active_markets(self._config['exchange']['pair_whitelist']) + return self._whitelist_for_active_markets( + self.verify_whitelist(self._config['exchange']['pair_whitelist'], logger.info)) def filter_pairlist(self, pairlist: List[str], tickers: Dict) -> List[str]: """ diff --git a/freqtrade/plugins/pairlist/pairlist_helpers.py b/freqtrade/plugins/pairlist/pairlist_helpers.py new file mode 100644 index 000000000..924bfb293 --- /dev/null +++ b/freqtrade/plugins/pairlist/pairlist_helpers.py @@ -0,0 +1,42 @@ +import re +from typing import List + + +def expand_pairlist(wildcardpl: List[str], available_pairs: List[str], + keep_invalid: bool = False) -> List[str]: + """ + Expand pairlist potentially containing wildcards based on available markets. + This will implicitly filter all pairs in the wildcard-list which are not in available_pairs. + :param wildcardpl: List of Pairlists, which may contain regex + :param available_pairs: List of all available pairs (`exchange.get_markets().keys()`) + :param keep_invalid: If sets to True, drops invalid pairs silently while expanding regexes + :return expanded pairlist, with Regexes from wildcardpl applied to match all available pairs. + :raises: ValueError if a wildcard is invalid (like '*/BTC' - which should be `.*/BTC`) + """ + result = [] + if keep_invalid: + for pair_wc in wildcardpl: + try: + comp = re.compile(pair_wc) + result_partial = [ + pair for pair in available_pairs if re.fullmatch(comp, pair) + ] + # Add all matching pairs. + # If there are no matching pairs (Pair not on exchange) keep it. + result += result_partial or [pair_wc] + except re.error as err: + raise ValueError(f"Wildcard error in {pair_wc}, {err}") + + for element in result: + if not re.fullmatch(r'^[A-Za-z0-9/-]+$', element): + result.remove(element) + else: + for pair_wc in wildcardpl: + try: + comp = re.compile(pair_wc) + result += [ + pair for pair in available_pairs if re.fullmatch(comp, pair) + ] + except re.error as err: + raise ValueError(f"Wildcard error in {pair_wc}, {err}") + return result diff --git a/freqtrade/plugins/pairlistmanager.py b/freqtrade/plugins/pairlistmanager.py index b71f02898..7ce77da59 100644 --- a/freqtrade/plugins/pairlistmanager.py +++ b/freqtrade/plugins/pairlistmanager.py @@ -10,6 +10,7 @@ from cachetools import TTLCache, cached from freqtrade.constants import ListPairsWithTimeframes from freqtrade.exceptions import OperationalException from freqtrade.plugins.pairlist.IPairList import IPairList +from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist from freqtrade.resolvers import PairListResolver @@ -42,30 +43,40 @@ class PairListManager(): @property def whitelist(self) -> List[str]: - """ - Has the current whitelist - """ + """The current whitelist""" return self._whitelist @property def blacklist(self) -> List[str]: """ - Has the current blacklist + The current blacklist -> no need to overwrite in subclasses """ return self._blacklist + @property + def expanded_blacklist(self) -> List[str]: + """The expanded blacklist (including wildcard expansion)""" + return expand_pairlist(self._blacklist, self._exchange.get_markets().keys()) + + @property + def expanded_whitelist_keep_invalid(self) -> List[str]: + """The expanded whitelist (including wildcard expansion), maintaining invalid pairs""" + return expand_pairlist(self._whitelist, self._exchange.get_markets().keys(), + keep_invalid=True) + + @property + def expanded_whitelist(self) -> List[str]: + """The expanded whitelist (including wildcard expansion), filtering invalid pairs""" + return expand_pairlist(self._whitelist, self._exchange.get_markets().keys()) + @property def name_list(self) -> List[str]: - """ - Get list of loaded Pairlist Handler names - """ + """Get list of loaded Pairlist Handler names""" return [p.name for p in self._pairlist_handlers] def short_desc(self) -> List[Dict]: - """ - List of short_desc for each Pairlist Handler - """ + """List of short_desc for each Pairlist Handler""" return [{p.name: p.short_desc()} for p in self._pairlist_handlers] @cached(TTLCache(maxsize=1, ttl=1800)) @@ -73,9 +84,7 @@ class PairListManager(): return self._exchange.get_tickers() def refresh_pairlist(self) -> None: - """ - Run pairlist through all configured Pairlist Handlers. - """ + """Run pairlist through all configured Pairlist Handlers.""" # Tickers should be cached to avoid calling the exchange on each call. tickers: Dict = {} if self._tickers_needed: @@ -120,12 +129,39 @@ class PairListManager(): :param logmethod: Function that'll be called, `logger.info` or `logger.warning`. :return: pairlist - blacklisted pairs """ + try: + blacklist = self.expanded_blacklist + except ValueError as err: + logger.error(f"Pair blacklist contains an invalid Wildcard: {err}") + return [] for pair in deepcopy(pairlist): - if pair in self._blacklist: + if pair in blacklist: logmethod(f"Pair {pair} in your blacklist. Removing it from whitelist...") pairlist.remove(pair) return pairlist + def verify_whitelist(self, pairlist: List[str], logmethod, + keep_invalid: bool = False) -> List[str]: + """ + Verify and remove items from pairlist - returning a filtered pairlist. + Logs a warning or info depending on `aswarning`. + Pairlist Handlers explicitly using this method shall use + `logmethod=logger.info` to avoid spamming with warning messages + :param pairlist: Pairlist to validate + :param logmethod: Function that'll be called, `logger.info` or `logger.warning` + :param keep_invalid: If sets to True, drops invalid pairs silently while expanding regexes. + :return: pairlist - whitelisted pairs + """ + try: + if keep_invalid: + whitelist = self.expanded_whitelist_keep_invalid + else: + whitelist = self.expanded_whitelist + except ValueError as err: + logger.error(f"Pair whitelist contains an invalid Wildcard: {err}") + return [] + return whitelist + def create_pair_list(self, pairs: List[str], timeframe: str = None) -> ListPairsWithTimeframes: """ Create list of pair tuples with (pair, timeframe) diff --git a/freqtrade/plugins/protections/stoploss_guard.py b/freqtrade/plugins/protections/stoploss_guard.py index 193907ddc..92fae54cb 100644 --- a/freqtrade/plugins/protections/stoploss_guard.py +++ b/freqtrade/plugins/protections/stoploss_guard.py @@ -53,8 +53,9 @@ class StoplossGuard(IProtection): # trades = Trade.get_trades(filters).all() trades1 = Trade.get_trades_proxy(pair=pair, is_open=False, close_date=look_back_until) - trades = [trade for trade in trades1 if str(trade.sell_reason) == SellType.STOP_LOSS.value - or (str(trade.sell_reason) == SellType.TRAILING_STOP_LOSS.value + trades = [trade for trade in trades1 if (str(trade.sell_reason) in ( + SellType.TRAILING_STOP_LOSS.value, SellType.STOP_LOSS.value, + SellType.STOPLOSS_ON_EXCHANGE.value) and trade.close_profit < 0)] if len(trades) > self._trade_limit: diff --git a/freqtrade/resolvers/strategy_resolver.py b/freqtrade/resolvers/strategy_resolver.py index 73af00fee..26a316873 100644 --- a/freqtrade/resolvers/strategy_resolver.py +++ b/freqtrade/resolvers/strategy_resolver.py @@ -73,12 +73,15 @@ class StrategyResolver(IResolver): ("order_time_in_force", None, None), ("stake_currency", None, None), ("stake_amount", None, None), + ("protections", None, None), ("startup_candle_count", None, None), ("unfilledtimeout", None, None), ("use_sell_signal", True, 'ask_strategy'), ("sell_profit_only", False, 'ask_strategy'), ("ignore_roi_if_buy_signal", False, 'ask_strategy'), + ("sell_profit_offset", 0.0, 'ask_strategy'), ("disable_dataframe_checks", False, None), + ("ignore_buying_expired_candle_after", 0, 'ask_strategy') ] for attribute, default, subkey in attributes: if subkey: diff --git a/freqtrade/rpc/api_server.py b/freqtrade/rpc/api_server.py deleted file mode 100644 index b489586c8..000000000 --- a/freqtrade/rpc/api_server.py +++ /dev/null @@ -1,665 +0,0 @@ -import logging -import threading -from copy import deepcopy -from datetime import date, datetime -from ipaddress import IPv4Address -from pathlib import Path -from typing import Any, Callable, Dict - -from arrow import Arrow -from flask import Flask, jsonify, request -from flask.json import JSONEncoder -from flask_cors import CORS -from flask_jwt_extended import (JWTManager, create_access_token, create_refresh_token, - get_jwt_identity, jwt_refresh_token_required, - verify_jwt_in_request_optional) -from werkzeug.security import safe_str_cmp -from werkzeug.serving import make_server - -from freqtrade.__init__ import __version__ -from freqtrade.constants import DATETIME_PRINT_FORMAT, USERPATH_STRATEGIES -from freqtrade.exceptions import OperationalException -from freqtrade.persistence import Trade -from freqtrade.rpc.rpc import RPC, RPCException, RPCHandler - - -logger = logging.getLogger(__name__) - -BASE_URI = "/api/v1" - - -class FTJSONEncoder(JSONEncoder): - def default(self, obj): - try: - if isinstance(obj, Arrow): - return obj.for_json() - elif isinstance(obj, datetime): - return obj.strftime(DATETIME_PRINT_FORMAT) - elif isinstance(obj, date): - return obj.strftime("%Y-%m-%d") - iterable = iter(obj) - except TypeError: - pass - else: - return list(iterable) - return JSONEncoder.default(self, obj) - - -# Type should really be Callable[[ApiServer, Any], Any], but that will create a circular dependency -def require_login(func: Callable[[Any, Any], Any]): - - def func_wrapper(obj, *args, **kwargs): - verify_jwt_in_request_optional() - auth = request.authorization - if get_jwt_identity() or auth and obj.check_auth(auth.username, auth.password): - return func(obj, *args, **kwargs) - else: - return jsonify({"error": "Unauthorized"}), 401 - - return func_wrapper - - -# Type should really be Callable[[ApiServer], Any], but that will create a circular dependency -def rpc_catch_errors(func: Callable[..., Any]): - - def func_wrapper(obj, *args, **kwargs): - - try: - return func(obj, *args, **kwargs) - except RPCException as e: - logger.exception("API Error calling %s: %s", func.__name__, e) - return obj.rest_error(f"Error querying {func.__name__}: {e}") - - return func_wrapper - - -def shutdown_session(exception=None): - # Remove scoped session - Trade.session.remove() - - -class ApiServer(RPCHandler): - """ - This class runs api server and provides rpc.rpc functionality to it - - This class starts a non-blocking thread the api server runs within - """ - - def check_auth(self, username, password): - return (safe_str_cmp(username, self._config['api_server'].get('username')) and - safe_str_cmp(password, self._config['api_server'].get('password'))) - - def __init__(self, rpc: RPC, config: Dict[str, Any]) -> None: - """ - Init the api server, and init the super class RPCHandler - :param rpc: instance of RPC Helper class - :param config: Configuration object - :return: None - """ - super().__init__(rpc, config) - - self.app = Flask(__name__) - self._cors = CORS(self.app, - resources={r"/api/*": { - "supports_credentials": True, - "origins": self._config['api_server'].get('CORS_origins', [])}} - ) - - # Setup the Flask-JWT-Extended extension - self.app.config['JWT_SECRET_KEY'] = self._config['api_server'].get( - 'jwt_secret_key', 'super-secret') - - self.jwt = JWTManager(self.app) - self.app.json_encoder = FTJSONEncoder - - self.app.teardown_appcontext(shutdown_session) - - # 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_error(self, error_msg, error_code=502): - return jsonify({"error": error_msg}), error_code - - def register_rest_rpc_urls(self): - """ - Registers flask app URLs that are calls to functionality 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}/token/login', 'login', - view_func=self._token_login, methods=['POST']) - self.app.add_url_rule(f'{BASE_URI}/token/refresh', 'token_refresh', - view_func=self._token_refresh, methods=['POST']) - self.app.add_url_rule(f'{BASE_URI}/start', 'start', - view_func=self._start, methods=['POST']) - self.app.add_url_rule(f'{BASE_URI}/stop', 'stop', view_func=self._stop, methods=['POST']) - self.app.add_url_rule(f'{BASE_URI}/stopbuy', 'stopbuy', - view_func=self._stopbuy, methods=['POST']) - self.app.add_url_rule(f'{BASE_URI}/reload_config', 'reload_config', - view_func=self._reload_config, methods=['POST']) - # Info commands - self.app.add_url_rule(f'{BASE_URI}/balance', 'balance', - view_func=self._balance, methods=['GET']) - self.app.add_url_rule(f'{BASE_URI}/count', 'count', view_func=self._count, methods=['GET']) - self.app.add_url_rule(f'{BASE_URI}/locks', 'locks', view_func=self._locks, 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}/logs', 'log', view_func=self._get_logs, 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}/stats', 'stats', - view_func=self._stats, 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']) - self.app.add_url_rule(f'{BASE_URI}/show_config', 'show_config', - view_func=self._show_config, methods=['GET']) - self.app.add_url_rule(f'{BASE_URI}/ping', 'ping', - view_func=self._ping, methods=['GET']) - self.app.add_url_rule(f'{BASE_URI}/trades', 'trades', - view_func=self._trades, methods=['GET']) - self.app.add_url_rule(f'{BASE_URI}/trades/', 'trades_delete', - view_func=self._trades_delete, methods=['DELETE']) - - self.app.add_url_rule(f'{BASE_URI}/pair_candles', 'pair_candles', - view_func=self._analysed_candles, methods=['GET']) - self.app.add_url_rule(f'{BASE_URI}/pair_history', 'pair_history', - view_func=self._analysed_history, methods=['GET']) - self.app.add_url_rule(f'{BASE_URI}/plot_config', 'plot_config', - view_func=self._plot_config, methods=['GET']) - self.app.add_url_rule(f'{BASE_URI}/strategies', 'strategies', - view_func=self._list_strategies, methods=['GET']) - self.app.add_url_rule(f'{BASE_URI}/strategy/', 'strategy', - view_func=self._get_strategy, methods=['GET']) - self.app.add_url_rule(f'{BASE_URI}/available_pairs', 'pairs', - view_func=self._list_available_pairs, 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']) - - @require_login - def page_not_found(self, error): - """ - Return "404 not found", 404. - """ - return jsonify({ - 'status': 'error', - 'reason': f"There's no API call for {request.base_url}.", - 'code': 404 - }), 404 - - @require_login - @rpc_catch_errors - def _token_login(self): - """ - Handler for /token/login - Returns a JWT token - """ - auth = request.authorization - if auth and self.check_auth(auth.username, auth.password): - keystuff = {'u': auth.username} - ret = { - 'access_token': create_access_token(identity=keystuff), - 'refresh_token': create_refresh_token(identity=keystuff), - } - return jsonify(ret) - - return jsonify({"error": "Unauthorized"}), 401 - - @jwt_refresh_token_required - @rpc_catch_errors - def _token_refresh(self): - """ - Handler for /token/refresh - Returns a JWT token based on a JWT refresh token - """ - current_user = get_jwt_identity() - new_token = create_access_token(identity=current_user, fresh=False) - - ret = {'access_token': new_token} - return jsonify(ret) - - @require_login - @rpc_catch_errors - def _start(self): - """ - Handler for /start. - Starts TradeThread in bot if stopped. - """ - msg = self._rpc._rpc_start() - return jsonify(msg) - - @require_login - @rpc_catch_errors - def _stop(self): - """ - Handler for /stop. - Stops TradeThread in bot if running - """ - msg = self._rpc._rpc_stop() - return jsonify(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._rpc_stopbuy() - return jsonify(msg) - - @rpc_catch_errors - def _ping(self): - """ - simple ping version - """ - return jsonify({"status": "pong"}) - - @require_login - @rpc_catch_errors - def _version(self): - """ - Prints the bot's version - """ - return jsonify({"version": __version__}) - - @require_login - @rpc_catch_errors - def _show_config(self): - """ - Prints the bot's version - """ - return jsonify(RPC._rpc_show_config(self._config, self._rpc._freqtrade.state)) - - @require_login - @rpc_catch_errors - def _reload_config(self): - """ - Handler for /reload_config. - Triggers a config file reload - """ - msg = self._rpc._rpc_reload_config() - return jsonify(msg) - - @require_login - @rpc_catch_errors - def _count(self): - """ - Handler for /count. - Returns the number of trades running - """ - msg = self._rpc._rpc_count() - return jsonify(msg) - - @require_login - @rpc_catch_errors - def _locks(self): - """ - Handler for /locks. - Returns the currently active locks. - """ - return jsonify(self._rpc._rpc_locks()) - - @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._rpc_daily_profit(timescale, - self._config['stake_currency'], - self._config.get('fiat_display_currency', '') - ) - - return jsonify(stats) - - @require_login - @rpc_catch_errors - def _get_logs(self): - """ - Returns latest logs - get: - param: - limit: Only get a certain number of records - """ - limit = int(request.args.get('limit', 0)) or None - return jsonify(RPC._rpc_get_logs(limit)) - - @require_login - @rpc_catch_errors - def _edge(self): - """ - Returns information related to Edge. - :return: edge stats - """ - stats = self._rpc._rpc_edge() - - return jsonify(stats) - - @require_login - @rpc_catch_errors - def _profit(self): - """ - Handler for /profit. - - Returns a cumulative profit statistics - :return: stats - """ - - stats = self._rpc._rpc_trade_statistics(self._config['stake_currency'], - self._config.get('fiat_display_currency') - ) - - return jsonify(stats) - - @require_login - @rpc_catch_errors - def _stats(self): - """ - Handler for /stats. - Returns a Object with "durations" and "sell_reasons" as keys. - """ - - stats = self._rpc._rpc_stats() - - return jsonify(stats) - - @require_login - @rpc_catch_errors - def _performance(self): - """ - Handler for /performance. - - Returns a cumulative performance statistics - :return: stats - """ - stats = self._rpc._rpc_performance() - - return jsonify(stats) - - @require_login - @rpc_catch_errors - def _status(self): - """ - Handler for /status. - - Returns the current status of the trades in json format - """ - try: - results = self._rpc._rpc_trade_status() - return jsonify(results) - except RPCException: - return jsonify([]) - - @require_login - @rpc_catch_errors - def _balance(self): - """ - Handler for /balance. - - Returns the current status of the trades in json format - """ - results = self._rpc._rpc_balance(self._config['stake_currency'], - self._config.get('fiat_display_currency', '')) - return jsonify(results) - - @require_login - @rpc_catch_errors - def _trades(self): - """ - Handler for /trades. - - Returns the X last trades in json format - """ - limit = int(request.args.get('limit', 0)) - results = self._rpc._rpc_trade_history(limit) - return jsonify(results) - - @require_login - @rpc_catch_errors - def _trades_delete(self, tradeid: int): - """ - Handler for DELETE /trades/ endpoint. - Removes the trade from the database (tries to cancel open orders first!) - get: - param: - tradeid: Numeric trade-id assigned to the trade. - """ - result = self._rpc._rpc_delete(tradeid) - return jsonify(result) - - @require_login - @rpc_catch_errors - def _whitelist(self): - """ - Handler for /whitelist. - """ - results = self._rpc._rpc_whitelist() - return jsonify(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._rpc_blacklist(add) - return jsonify(results) - - @require_login - @rpc_catch_errors - def _forcebuy(self): - """ - Handler for /forcebuy. - """ - asset = request.json.get("pair") - price = request.json.get("price", None) - price = float(price) if price is not None else price - - trade = self._rpc._rpc_forcebuy(asset, price) - if trade: - return jsonify(trade.to_json()) - else: - return jsonify({"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._rpc_forcesell(tradeid) - return jsonify(results) - - @require_login - @rpc_catch_errors - def _analysed_candles(self): - """ - Handler for /pair_candles. - Returns the dataframe the bot is using during live/dry operations. - Takes the following get arguments: - get: - parameters: - - pair: Pair - - timeframe: Timeframe to get data for (should be aligned to strategy.timeframe) - - limit: Limit return length to the latest X candles - """ - pair = request.args.get("pair") - timeframe = request.args.get("timeframe") - limit = request.args.get("limit", type=int) - if not pair or not timeframe: - return self.rest_error("Mandatory parameter missing.", 400) - - results = self._rpc._rpc_analysed_dataframe(pair, timeframe, limit) - return jsonify(results) - - @require_login - @rpc_catch_errors - def _analysed_history(self): - """ - Handler for /pair_history. - Returns the dataframe of a given timerange - Takes the following get arguments: - get: - parameters: - - pair: Pair - - timeframe: Timeframe to get data for (should be aligned to strategy.timeframe) - - strategy: Strategy to use - Must exist in configured strategy-path! - - timerange: timerange in the format YYYYMMDD-YYYYMMDD (YYYYMMDD- or (-YYYYMMDD)) - are als possible. If omitted uses all available data. - """ - pair = request.args.get("pair") - timeframe = request.args.get("timeframe") - timerange = request.args.get("timerange") - strategy = request.args.get("strategy") - - if not pair or not timeframe or not timerange or not strategy: - return self.rest_error("Mandatory parameter missing.", 400) - - config = deepcopy(self._config) - config.update({ - 'strategy': strategy, - }) - results = RPC._rpc_analysed_history_full(config, pair, timeframe, timerange) - return jsonify(results) - - @require_login - @rpc_catch_errors - def _plot_config(self): - """ - Handler for /plot_config. - """ - return jsonify(self._rpc._rpc_plot_config()) - - @require_login - @rpc_catch_errors - def _list_strategies(self): - directory = Path(self._config.get( - 'strategy_path', self._config['user_data_dir'] / USERPATH_STRATEGIES)) - from freqtrade.resolvers.strategy_resolver import StrategyResolver - strategy_objs = StrategyResolver.search_all_objects(directory, False) - strategy_objs = sorted(strategy_objs, key=lambda x: x['name']) - - return jsonify({'strategies': [x['name'] for x in strategy_objs]}) - - @require_login - @rpc_catch_errors - def _get_strategy(self, strategy: str): - """ - Get a single strategy - get: - parameters: - - strategy: Only get this strategy - """ - config = deepcopy(self._config) - from freqtrade.resolvers.strategy_resolver import StrategyResolver - try: - strategy_obj = StrategyResolver._load_strategy(strategy, config, - extra_dir=config.get('strategy_path')) - except OperationalException: - return self.rest_error("Strategy not found.", 404) - - return jsonify({ - 'strategy': strategy_obj.get_strategy_name(), - 'code': strategy_obj.__source__, - }) - - @require_login - @rpc_catch_errors - def _list_available_pairs(self): - """ - Handler for /available_pairs. - Returns an object, with pairs, available pair length and pair_interval combinations - Takes the following get arguments: - get: - parameters: - - stake_currency: Filter on this stake currency - - timeframe: Timeframe to get data for Filter elements to this timeframe - """ - timeframe = request.args.get("timeframe") - stake_currency = request.args.get("stake_currency") - - from freqtrade.data.history import get_datahandler - dh = get_datahandler(self._config['datadir'], self._config.get('dataformat_ohlcv', None)) - - pair_interval = dh.ohlcv_get_available_data(self._config['datadir']) - - if timeframe: - pair_interval = [pair for pair in pair_interval if pair[1] == timeframe] - if stake_currency: - pair_interval = [pair for pair in pair_interval if pair[0].endswith(stake_currency)] - pair_interval = sorted(pair_interval, key=lambda x: x[0]) - - pairs = list({x[0] for x in pair_interval}) - - result = { - 'length': len(pairs), - 'pairs': pairs, - 'pair_interval': pair_interval, - } - return jsonify(result) diff --git a/freqtrade/rpc/api_server/__init__.py b/freqtrade/rpc/api_server/__init__.py new file mode 100644 index 000000000..df255c186 --- /dev/null +++ b/freqtrade/rpc/api_server/__init__.py @@ -0,0 +1,2 @@ +# flake8: noqa: F401 +from .webserver import ApiServer diff --git a/freqtrade/rpc/api_server/api_auth.py b/freqtrade/rpc/api_server/api_auth.py new file mode 100644 index 000000000..a39e31b85 --- /dev/null +++ b/freqtrade/rpc/api_server/api_auth.py @@ -0,0 +1,106 @@ +import secrets +from datetime import datetime, timedelta + +import jwt +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.security import OAuth2PasswordBearer +from fastapi.security.http import HTTPBasic, HTTPBasicCredentials + +from freqtrade.rpc.api_server.api_schemas import AccessAndRefreshToken, AccessToken +from freqtrade.rpc.api_server.deps import get_api_config + + +ALGORITHM = "HS256" + +router_login = APIRouter() + + +def verify_auth(api_config, username: str, password: str): + """Verify username/password""" + return (secrets.compare_digest(username, api_config.get('username')) and + secrets.compare_digest(password, api_config.get('password'))) + + +httpbasic = HTTPBasic(auto_error=False) +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token", auto_error=False) + + +def get_user_from_token(token, secret_key: str, token_type: str = "access"): + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode(token, secret_key, algorithms=[ALGORITHM]) + username: str = payload.get("identity", {}).get('u') + if username is None: + raise credentials_exception + if payload.get("type") != token_type: + raise credentials_exception + + except jwt.PyJWTError: + raise credentials_exception + return username + + +def create_token(data: dict, secret_key: str, token_type: str = "access") -> str: + to_encode = data.copy() + if token_type == "access": + expire = datetime.utcnow() + timedelta(minutes=15) + elif token_type == "refresh": + expire = datetime.utcnow() + timedelta(days=30) + else: + raise ValueError() + to_encode.update({ + "exp": expire, + "iat": datetime.utcnow(), + "type": token_type, + }) + encoded_jwt = jwt.encode(to_encode, secret_key, algorithm=ALGORITHM) + return encoded_jwt + + +def http_basic_or_jwt_token(form_data: HTTPBasicCredentials = Depends(httpbasic), + token: str = Depends(oauth2_scheme), + api_config=Depends(get_api_config)): + if token: + return get_user_from_token(token, api_config.get('jwt_secret_key', 'super-secret')) + elif form_data and verify_auth(api_config, form_data.username, form_data.password): + return form_data.username + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Unauthorized", + ) + + +@router_login.post('/token/login', response_model=AccessAndRefreshToken) +def token_login(form_data: HTTPBasicCredentials = Depends(HTTPBasic()), + api_config=Depends(get_api_config)): + + if verify_auth(api_config, form_data.username, form_data.password): + token_data = {'identity': {'u': form_data.username}} + access_token = create_token(token_data, api_config.get('jwt_secret_key', 'super-secret')) + refresh_token = create_token(token_data, api_config.get('jwt_secret_key', 'super-secret'), + token_type="refresh") + return { + "access_token": access_token, + "refresh_token": refresh_token, + } + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Incorrect username or password", + ) + + +@router_login.post('/token/refresh', response_model=AccessToken) +def token_refresh(token: str = Depends(oauth2_scheme), api_config=Depends(get_api_config)): + # Refresh token + u = get_user_from_token(token, api_config.get( + 'jwt_secret_key', 'super-secret'), 'refresh') + token_data = {'identity': {'u': u}} + access_token = create_token(token_data, api_config.get('jwt_secret_key', 'super-secret'), + token_type="access") + return {'access_token': access_token} diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py new file mode 100644 index 000000000..c9e8aaceb --- /dev/null +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -0,0 +1,307 @@ +from datetime import date, datetime +from typing import Any, Dict, List, Optional, TypeVar, Union + +from pydantic import BaseModel + +from freqtrade.constants import DATETIME_PRINT_FORMAT + + +class Ping(BaseModel): + status: str + + +class AccessToken(BaseModel): + access_token: str + + +class AccessAndRefreshToken(AccessToken): + refresh_token: str + + +class Version(BaseModel): + version: str + + +class StatusMsg(BaseModel): + status: str + + +class ResultMsg(BaseModel): + result: str + + +class Balance(BaseModel): + currency: str + free: float + balance: float + used: float + est_stake: float + stake: str + + +class Balances(BaseModel): + currencies: List[Balance] + total: float + symbol: str + value: float + stake: str + note: str + + +class Count(BaseModel): + current: int + max: int + total_stake: float + + +class PerformanceEntry(BaseModel): + pair: str + profit: float + count: int + + +class Profit(BaseModel): + profit_closed_coin: float + profit_closed_percent: float + profit_closed_percent_mean: float + profit_closed_ratio_mean: float + profit_closed_percent_sum: float + profit_closed_ratio_sum: float + profit_closed_fiat: float + profit_all_coin: float + profit_all_percent: float + profit_all_percent_mean: float + profit_all_ratio_mean: float + profit_all_percent_sum: float + profit_all_ratio_sum: float + profit_all_fiat: float + trade_count: int + closed_trade_count: int + first_trade_date: str + first_trade_timestamp: int + latest_trade_date: str + latest_trade_timestamp: int + avg_duration: str + best_pair: str + best_rate: float + winning_trades: int + losing_trades: int + + +class SellReason(BaseModel): + wins: int + losses: int + draws: int + + +class Stats(BaseModel): + sell_reasons: Dict[str, SellReason] + durations: Dict[str, Union[str, float]] + + +class DailyRecord(BaseModel): + date: date + abs_profit: float + fiat_value: float + trade_count: int + + +class Daily(BaseModel): + data: List[DailyRecord] + fiat_display_currency: str + stake_currency: str + + +class ShowConfig(BaseModel): + dry_run: str + stake_currency: str + stake_amount: Union[float, str] + max_open_trades: int + minimal_roi: Dict[str, Any] + stoploss: float + trailing_stop: bool + trailing_stop_positive: Optional[float] + trailing_stop_positive_offset: Optional[float] + trailing_only_offset_is_reached: Optional[bool] + timeframe: str + timeframe_ms: int + timeframe_min: int + exchange: str + strategy: str + forcebuy_enabled: bool + ask_strategy: Dict[str, Any] + bid_strategy: Dict[str, Any] + bot_name: str + state: str + runmode: str + + +class TradeSchema(BaseModel): + trade_id: int + pair: str + is_open: bool + exchange: str + amount: float + amount_requested: float + stake_amount: float + strategy: str + timeframe: int + fee_open: Optional[float] + fee_open_cost: Optional[float] + fee_open_currency: Optional[str] + fee_close: Optional[float] + fee_close_cost: Optional[float] + fee_close_currency: Optional[str] + open_date_hum: str + open_date: str + open_timestamp: int + open_rate: float + open_rate_requested: Optional[float] + open_trade_value: float + close_date_hum: Optional[str] + close_date: Optional[str] + close_timestamp: Optional[int] + close_rate: Optional[float] + close_rate_requested: Optional[float] + close_profit: Optional[float] + close_profit_pct: Optional[float] + close_profit_abs: Optional[float] + profit_ratio: Optional[float] + profit_pct: Optional[float] + profit_abs: Optional[float] + sell_reason: Optional[str] + sell_order_status: Optional[str] + stop_loss_abs: Optional[float] + stop_loss_ratio: Optional[float] + stop_loss_pct: Optional[float] + stoploss_order_id: Optional[str] + stoploss_last_update: Optional[str] + stoploss_last_update_timestamp: Optional[int] + initial_stop_loss_abs: Optional[float] + initial_stop_loss_ratio: Optional[float] + initial_stop_loss_pct: Optional[float] + min_rate: Optional[float] + max_rate: Optional[float] + open_order_id: Optional[str] + + +class OpenTradeSchema(TradeSchema): + stoploss_current_dist: Optional[float] + stoploss_current_dist_pct: Optional[float] + stoploss_current_dist_ratio: Optional[float] + stoploss_entry_dist: Optional[float] + stoploss_entry_dist_ratio: Optional[float] + base_currency: str + current_profit: float + current_profit_abs: float + current_profit_pct: float + current_rate: float + open_order: Optional[str] + + +class TradeResponse(BaseModel): + trades: List[TradeSchema] + trades_count: int + + +ForceBuyResponse = TypeVar('ForceBuyResponse', TradeSchema, StatusMsg) + + +class LockModel(BaseModel): + active: bool + lock_end_time: str + lock_end_timestamp: int + lock_time: str + lock_timestamp: int + pair: str + reason: str + + +class Locks(BaseModel): + lock_count: int + locks: List[LockModel] + + +class Logs(BaseModel): + log_count: int + logs: List[List] + + +class ForceBuyPayload(BaseModel): + pair: str + price: Optional[float] + + +class ForceSellPayload(BaseModel): + tradeid: str + + +class BlacklistPayload(BaseModel): + blacklist: List[str] + + +class BlacklistResponse(BaseModel): + blacklist: List[str] + blacklist_expanded: List[str] + errors: Dict + length: int + method: List[str] + + +class WhitelistResponse(BaseModel): + whitelist: List[str] + length: int + method: List[str] + + +class DeleteTrade(BaseModel): + cancel_order_count: int + result: str + result_msg: str + trade_id: int + + +class PlotConfig_(BaseModel): + main_plot: Dict[str, Any] + subplots: Optional[Dict[str, Any]] + + +PlotConfig = TypeVar('PlotConfig', PlotConfig_, Dict) + + +class StrategyListResponse(BaseModel): + strategies: List[str] + + +class StrategyResponse(BaseModel): + strategy: str + code: str + + +class AvailablePairs(BaseModel): + length: int + pairs: List[str] + pair_interval: List[List[str]] + + +class PairHistory(BaseModel): + strategy: str + pair: str + timeframe: str + timeframe_ms: int + columns: List[str] + data: List[Any] + length: int + buy_signals: int + sell_signals: int + last_analyzed: datetime + last_analyzed_ts: int + data_start_ts: int + data_start: str + data_stop: str + data_stop_ts: int + + class Config: + json_encoders = { + datetime: lambda v: v.strftime(DATETIME_PRINT_FORMAT), + } diff --git a/freqtrade/rpc/api_server/api_v1.py b/freqtrade/rpc/api_server/api_v1.py new file mode 100644 index 000000000..a2082103b --- /dev/null +++ b/freqtrade/rpc/api_server/api_v1.py @@ -0,0 +1,238 @@ +from copy import deepcopy +from pathlib import Path +from typing import List, Optional + +from fastapi import APIRouter, Depends +from fastapi.exceptions import HTTPException + +from freqtrade import __version__ +from freqtrade.constants import USERPATH_STRATEGIES +from freqtrade.data.history import get_datahandler +from freqtrade.exceptions import OperationalException +from freqtrade.rpc import RPC +from freqtrade.rpc.api_server.api_schemas import (AvailablePairs, Balances, BlacklistPayload, + BlacklistResponse, Count, Daily, DeleteTrade, + ForceBuyPayload, ForceBuyResponse, + ForceSellPayload, Locks, Logs, OpenTradeSchema, + PairHistory, PerformanceEntry, Ping, PlotConfig, + Profit, ResultMsg, ShowConfig, Stats, StatusMsg, + StrategyListResponse, StrategyResponse, + TradeResponse, Version, WhitelistResponse) +from freqtrade.rpc.api_server.deps import get_config, get_rpc, get_rpc_optional +from freqtrade.rpc.rpc import RPCException + + +# Public API, requires no auth. +router_public = APIRouter() +# Private API, protected by authentication +router = APIRouter() + + +@router_public.get('/ping', response_model=Ping) +def ping(): + """simple ping""" + return {"status": "pong"} + + +@router.get('/version', response_model=Version, tags=['info']) +def version(): + """ Bot Version info""" + return {"version": __version__} + + +@router.get('/balance', response_model=Balances, tags=['info']) +def balance(rpc: RPC = Depends(get_rpc), config=Depends(get_config)): + """Account Balances""" + return rpc._rpc_balance(config['stake_currency'], config.get('fiat_display_currency', ''),) + + +@router.get('/count', response_model=Count, tags=['info']) +def count(rpc: RPC = Depends(get_rpc)): + return rpc._rpc_count() + + +@router.get('/performance', response_model=List[PerformanceEntry], tags=['info']) +def performance(rpc: RPC = Depends(get_rpc)): + return rpc._rpc_performance() + + +@router.get('/profit', response_model=Profit, tags=['info']) +def profit(rpc: RPC = Depends(get_rpc), config=Depends(get_config)): + return rpc._rpc_trade_statistics(config['stake_currency'], + config.get('fiat_display_currency') + ) + + +@router.get('/stats', response_model=Stats, tags=['info']) +def stats(rpc: RPC = Depends(get_rpc)): + return rpc._rpc_stats() + + +@router.get('/daily', response_model=Daily, tags=['info']) +def daily(timescale: int = 7, rpc: RPC = Depends(get_rpc), config=Depends(get_config)): + return rpc._rpc_daily_profit(timescale, config['stake_currency'], + config.get('fiat_display_currency', '')) + + +@router.get('/status', response_model=List[OpenTradeSchema], tags=['info']) +def status(rpc: RPC = Depends(get_rpc)): + try: + return rpc._rpc_trade_status() + except RPCException: + return [] + + +@router.get('/trades', response_model=TradeResponse, tags=['info', 'trading']) +def trades(limit: int = 0, rpc: RPC = Depends(get_rpc)): + return rpc._rpc_trade_history(limit) + + +@router.delete('/trades/{tradeid}', response_model=DeleteTrade, tags=['info', 'trading']) +def trades_delete(tradeid: int, rpc: RPC = Depends(get_rpc)): + return rpc._rpc_delete(tradeid) + + +# TODO: Missing response model +@router.get('/edge', tags=['info']) +def edge(rpc: RPC = Depends(get_rpc)): + return rpc._rpc_edge() + + +@router.get('/show_config', response_model=ShowConfig, tags=['info']) +def show_config(rpc: Optional[RPC] = Depends(get_rpc_optional), config=Depends(get_config)): + state = '' + if rpc: + state = rpc._freqtrade.state + return RPC._rpc_show_config(config, state) + + +@router.post('/forcebuy', response_model=ForceBuyResponse, tags=['trading']) +def forcebuy(payload: ForceBuyPayload, rpc: RPC = Depends(get_rpc)): + trade = rpc._rpc_forcebuy(payload.pair, payload.price) + + if trade: + return trade.to_json() + else: + return {"status": f"Error buying pair {payload.pair}."} + + +@router.post('/forcesell', response_model=ResultMsg, tags=['trading']) +def forcesell(payload: ForceSellPayload, rpc: RPC = Depends(get_rpc)): + return rpc._rpc_forcesell(payload.tradeid) + + +@router.get('/blacklist', response_model=BlacklistResponse, tags=['info', 'pairlist']) +def blacklist(rpc: RPC = Depends(get_rpc)): + return rpc._rpc_blacklist() + + +@router.post('/blacklist', response_model=BlacklistResponse, tags=['info', 'pairlist']) +def blacklist_post(payload: BlacklistPayload, rpc: RPC = Depends(get_rpc)): + return rpc._rpc_blacklist(payload.blacklist) + + +@router.get('/whitelist', response_model=WhitelistResponse, tags=['info', 'pairlist']) +def whitelist(rpc: RPC = Depends(get_rpc)): + return rpc._rpc_whitelist() + + +@router.get('/locks', response_model=Locks, tags=['info']) +def locks(rpc: RPC = Depends(get_rpc)): + return rpc._rpc_locks() + + +@router.get('/logs', response_model=Logs, tags=['info']) +def logs(limit: Optional[int] = None, rpc: RPC = Depends(get_rpc)): + return rpc._rpc_get_logs(limit) + + +@router.post('/start', response_model=StatusMsg, tags=['botcontrol']) +def start(rpc: RPC = Depends(get_rpc)): + return rpc._rpc_start() + + +@router.post('/stop', response_model=StatusMsg, tags=['botcontrol']) +def stop(rpc: RPC = Depends(get_rpc)): + return rpc._rpc_stop() + + +@router.post('/stopbuy', response_model=StatusMsg, tags=['botcontrol']) +def stop_buy(rpc: RPC = Depends(get_rpc)): + return rpc._rpc_stopbuy() + + +@router.post('/reload_config', response_model=StatusMsg, tags=['botcontrol']) +def reload_config(rpc: RPC = Depends(get_rpc)): + return rpc._rpc_reload_config() + + +@router.get('/pair_candles', response_model=PairHistory, tags=['candle data']) +def pair_candles(pair: str, timeframe: str, limit: Optional[int], rpc=Depends(get_rpc)): + return rpc._rpc_analysed_dataframe(pair, timeframe, limit) + + +@router.get('/pair_history', response_model=PairHistory, tags=['candle data']) +def pair_history(pair: str, timeframe: str, timerange: str, strategy: str, + config=Depends(get_config)): + config = deepcopy(config) + config.update({ + 'strategy': strategy, + }) + return RPC._rpc_analysed_history_full(config, pair, timeframe, timerange) + + +@router.get('/plot_config', response_model=PlotConfig, tags=['candle data']) +def plot_config(rpc: RPC = Depends(get_rpc)): + return rpc._rpc_plot_config() + + +@router.get('/strategies', response_model=StrategyListResponse, tags=['strategy']) +def list_strategies(config=Depends(get_config)): + directory = Path(config.get( + 'strategy_path', config['user_data_dir'] / USERPATH_STRATEGIES)) + from freqtrade.resolvers.strategy_resolver import StrategyResolver + strategies = StrategyResolver.search_all_objects(directory, False) + strategies = sorted(strategies, key=lambda x: x['name']) + + return {'strategies': [x['name'] for x in strategies]} + + +@router.get('/strategy/{strategy}', response_model=StrategyResponse, tags=['strategy']) +def get_strategy(strategy: str, config=Depends(get_config)): + + config = deepcopy(config) + from freqtrade.resolvers.strategy_resolver import StrategyResolver + try: + strategy_obj = StrategyResolver._load_strategy(strategy, config, + extra_dir=config.get('strategy_path')) + except OperationalException: + raise HTTPException(status_code=404, detail='Strategy not found') + + return { + 'strategy': strategy_obj.get_strategy_name(), + 'code': strategy_obj.__source__, + } + + +@router.get('/available_pairs', response_model=AvailablePairs, tags=['candle data']) +def list_available_pairs(timeframe: Optional[str] = None, stake_currency: Optional[str] = None, + config=Depends(get_config)): + + dh = get_datahandler(config['datadir'], config.get('dataformat_ohlcv', None)) + + pair_interval = dh.ohlcv_get_available_data(config['datadir']) + + if timeframe: + pair_interval = [pair for pair in pair_interval if pair[1] == timeframe] + if stake_currency: + pair_interval = [pair for pair in pair_interval if pair[0].endswith(stake_currency)] + pair_interval = sorted(pair_interval, key=lambda x: x[0]) + + pairs = list({x[0] for x in pair_interval}) + + result = { + 'length': len(pairs), + 'pairs': pairs, + 'pair_interval': pair_interval, + } + return result diff --git a/freqtrade/rpc/api_server/deps.py b/freqtrade/rpc/api_server/deps.py new file mode 100644 index 000000000..d2459010f --- /dev/null +++ b/freqtrade/rpc/api_server/deps.py @@ -0,0 +1,27 @@ +from typing import Any, Dict, Optional + +from freqtrade.rpc.rpc import RPC, RPCException + +from .webserver import ApiServer + + +def get_rpc_optional() -> Optional[RPC]: + if ApiServer._has_rpc: + return ApiServer._rpc + return None + + +def get_rpc() -> Optional[RPC]: + _rpc = get_rpc_optional() + if _rpc: + return _rpc + else: + raise RPCException('Bot is not in the correct state') + + +def get_config() -> Dict[str, Any]: + return ApiServer._config + + +def get_api_config() -> Dict[str, Any]: + return ApiServer._config['api_server'] diff --git a/freqtrade/rpc/api_server/uvicorn_threaded.py b/freqtrade/rpc/api_server/uvicorn_threaded.py new file mode 100644 index 000000000..1554a8e52 --- /dev/null +++ b/freqtrade/rpc/api_server/uvicorn_threaded.py @@ -0,0 +1,27 @@ +import contextlib +import threading +import time + +import uvicorn + + +class UvicornServer(uvicorn.Server): + """ + Multithreaded server - as found in https://github.com/encode/uvicorn/issues/742 + """ + def install_signal_handlers(self): + """ + In the parent implementation, this starts the thread, therefore we must patch it away here. + """ + pass + + @contextlib.contextmanager + def run_in_thread(self): + self.thread = threading.Thread(target=self.run) + self.thread.start() + while not self.started: + time.sleep(1e-3) + + def cleanup(self): + self.should_exit = True + self.thread.join() diff --git a/freqtrade/rpc/api_server/webserver.py b/freqtrade/rpc/api_server/webserver.py new file mode 100644 index 000000000..9c0779274 --- /dev/null +++ b/freqtrade/rpc/api_server/webserver.py @@ -0,0 +1,115 @@ +import logging +from ipaddress import IPv4Address +from typing import Any, Dict + +import uvicorn +from fastapi import Depends, FastAPI +from fastapi.middleware.cors import CORSMiddleware +from starlette.responses import JSONResponse + +from freqtrade.rpc.api_server.uvicorn_threaded import UvicornServer +from freqtrade.rpc.rpc import RPC, RPCException, RPCHandler + + +logger = logging.getLogger(__name__) + + +class ApiServer(RPCHandler): + + _rpc: RPC + _has_rpc: bool = False + _config: Dict[str, Any] = {} + + def __init__(self, rpc: RPC, config: Dict[str, Any]) -> None: + super().__init__(rpc, config) + self._server = None + + ApiServer._rpc = rpc + ApiServer._has_rpc = True + ApiServer._config = config + api_config = self._config['api_server'] + + self.app = FastAPI(title="Freqtrade API", + docs_url='/docs' if api_config.get('enable_openapi', False) else None, + redoc_url=None, + ) + self.configure_app(self.app, self._config) + + self.start_api() + + def cleanup(self) -> None: + """ Cleanup pending module resources """ + if self._server: + logger.info("Stopping API Server") + self._server.cleanup() + + def send_msg(self, msg: Dict[str, str]) -> None: + pass + + def handle_rpc_exception(self, request, exc): + logger.exception(f"API Error calling: {exc}") + return JSONResponse( + status_code=502, + content={'error': f"Error querying {request.url.path}: {exc.message}"} + ) + + def configure_app(self, app: FastAPI, config): + from freqtrade.rpc.api_server.api_auth import http_basic_or_jwt_token, router_login + from freqtrade.rpc.api_server.api_v1 import router as api_v1 + from freqtrade.rpc.api_server.api_v1 import router_public as api_v1_public + app.include_router(api_v1_public, prefix="/api/v1") + + app.include_router(api_v1, prefix="/api/v1", + dependencies=[Depends(http_basic_or_jwt_token)], + ) + app.include_router(router_login, prefix="/api/v1", tags=["auth"]) + + app.add_middleware( + CORSMiddleware, + allow_origins=config['api_server'].get('CORS_origins', []), + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.add_exception_handler(RPCException, self.handle_rpc_exception) + + def start_api(self): + """ + Start API ... should be run in thread. + """ + 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!") + + if (self._config['api_server'].get('jwt_secret_key', 'super-secret') + in ('super-secret, somethingrandom')): + logger.warning("SECURITY WARNING - `jwt_secret_key` seems to be default." + "Others may be able to log into your bot.") + + logger.info('Starting Local Rest Server.') + verbosity = self._config['api_server'].get('verbosity', 'error') + log_config = uvicorn.config.LOGGING_CONFIG + # Change logging of access logs to stderr + log_config["handlers"]["access"]["stream"] = log_config["handlers"]["default"]["stream"] + uvconfig = uvicorn.Config(self.app, + port=rest_port, + host=rest_ip, + use_colors=False, + log_config=log_config, + access_log=True if verbosity != 'error' else False, + ) + try: + self._server = UvicornServer(uvconfig) + self._server.run_in_thread() + except Exception: + logger.exception("Api server failed to start.") diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 42ab76622..92cd6caf9 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -20,6 +20,7 @@ from freqtrade.exchange import timeframe_to_minutes, timeframe_to_msecs from freqtrade.loggers import bufferHandler from freqtrade.misc import shorten_date from freqtrade.persistence import PairLocks, Trade +from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist from freqtrade.rpc.fiat_convert import CryptoToFiatConverter from freqtrade.state import State from freqtrade.strategy.interface import SellType @@ -110,7 +111,7 @@ class RPC: self._fiat_converter = CryptoToFiatConverter() @staticmethod - def _rpc_show_config(config, botstate: State) -> Dict[str, Any]: + def _rpc_show_config(config, botstate: Union[State, str]) -> Dict[str, Any]: """ Return a dict of config options. Explicitly does NOT return the full config to avoid leakage of sensitive @@ -120,13 +121,15 @@ class RPC: 'dry_run': config['dry_run'], 'stake_currency': config['stake_currency'], 'stake_amount': config['stake_amount'], - 'max_open_trades': config['max_open_trades'], + 'max_open_trades': (config['max_open_trades'] + if config['max_open_trades'] != float('inf') else -1), 'minimal_roi': config['minimal_roi'].copy() if 'minimal_roi' in config else {}, 'stoploss': config.get('stoploss'), 'trailing_stop': config.get('trailing_stop'), 'trailing_stop_positive': config.get('trailing_stop_positive'), 'trailing_stop_positive_offset': config.get('trailing_stop_positive_offset'), 'trailing_only_offset_is_reached': config.get('trailing_only_offset_is_reached'), + 'bot_name': config.get('bot_name', 'freqtrade'), 'timeframe': config.get('timeframe'), 'timeframe_ms': timeframe_to_msecs(config['timeframe'] ) if 'timeframe' in config else '', @@ -142,13 +145,17 @@ class RPC: } return val - def _rpc_trade_status(self) -> List[Dict[str, Any]]: + def _rpc_trade_status(self, trade_ids: List[int] = []) -> List[Dict[str, Any]]: """ Below follows the RPC backend it is prefixed with rpc_ to raise awareness that it is a remotely exposed function """ - # Fetch open trade - trades = Trade.get_open_trades() + # Fetch open trades + if trade_ids: + trades = Trade.get_trades(trade_filter=Trade.id.in_(trade_ids)).all() + else: + trades = Trade.get_open_trades() + if not trades: raise RPCException('no active trade') else: @@ -648,7 +655,8 @@ class RPC: trades = Trade.get_open_trades() return { 'current': len(trades), - 'max': float(self._freqtrade.config['max_open_trades']), + 'max': (int(self._freqtrade.config['max_open_trades']) + if self._freqtrade.config['max_open_trades'] != float('inf') else -1), 'total_stake': sum((trade.open_rate * trade.amount) for trade in trades) } @@ -673,23 +681,23 @@ class RPC: """ Returns the currently active blacklist""" errors = {} if add: - stake_currency = self._freqtrade.config.get('stake_currency') for pair in add: - if self._freqtrade.exchange.get_pair_quote_currency(pair) == stake_currency: - if pair not in self._freqtrade.pairlists.blacklist: + if pair not in self._freqtrade.pairlists.blacklist: + try: + expand_pairlist([pair], self._freqtrade.exchange.get_markets().keys()) self._freqtrade.pairlists.blacklist.append(pair) - else: - errors[pair] = { - 'error_msg': f'Pair {pair} already in pairlist.'} + except ValueError: + errors[pair] = { + 'error_msg': f'Pair {pair} is not a valid wildcard.'} else: errors[pair] = { - 'error_msg': f"Pair {pair} does not match stake currency." - } + 'error_msg': f'Pair {pair} already in pairlist.'} res = {'method': self._freqtrade.pairlists.name_list, 'length': len(self._freqtrade.pairlists.blacklist), 'blacklist': self._freqtrade.pairlists.blacklist, + 'blacklist_expanded': self._freqtrade.pairlists.expanded_blacklist, 'errors': errors, } return res @@ -787,6 +795,8 @@ class RPC: timerange=timerange_parsed, data_format=config.get('dataformat_ohlcv', 'json'), ) + if pair not in _data: + raise RPCException(f"No data for {pair}, {timeframe} in {timerange} found.") from freqtrade.resolvers.strategy_resolver import StrategyResolver strategy = StrategyResolver.load_strategy(config) df_analyzed = strategy.analyze_ticker(_data[pair], {'pair': pair}) diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py index 38a4e95fd..7977d68de 100644 --- a/freqtrade/rpc/rpc_manager.py +++ b/freqtrade/rpc/rpc_manager.py @@ -35,6 +35,7 @@ class RPCManager: if 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(self._rpc, config)) def cleanup(self) -> None: diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 7ec67e5d0..99f9a8a91 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -277,7 +277,14 @@ class Telegram(RPCHandler): return try: - results = self._rpc._rpc_trade_status() + + # Check if there's at least one numerical ID provided. + # If so, try to get only these trades. + trade_ids = [] + if context.args and len(context.args) > 0: + trade_ids = [int(i) for i in context.args if i.isnumeric()] + + results = self._rpc._rpc_trade_status(trade_ids=trade_ids) messages = [] for r in results: @@ -815,7 +822,9 @@ class Telegram(RPCHandler): "Optionally takes a rate at which to buy.` \n") message = ("*/start:* `Starts the trader`\n" "*/stop:* `Stops the trader`\n" - "*/status [table]:* `Lists all open trades`\n" + "*/status |[table]:* `Lists all open trades`\n" + " * :* `Lists one or more specific trades.`\n" + " `Separate multiple with a blank space.`\n" " *table :* `will display trades in a table`\n" " `pending buy orders are marked with an asterisk (*)`\n" " `pending sell orders are marked with a double asterisk (**)`\n" diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 027c5d36e..c58d9aa5d 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -5,7 +5,7 @@ This module defines the interface to apply for strategies import logging import warnings from abc import ABC, abstractmethod -from datetime import datetime, timezone +from datetime import datetime, timedelta, timezone from enum import Enum from typing import Dict, List, NamedTuple, Optional, Tuple @@ -15,7 +15,7 @@ from pandas import DataFrame from freqtrade.constants import ListPairsWithTimeframes from freqtrade.data.dataprovider import DataProvider from freqtrade.exceptions import OperationalException, StrategyError -from freqtrade.exchange import timeframe_to_minutes +from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds from freqtrade.exchange.exchange import timeframe_to_next_date from freqtrade.persistence import PairLocks, Trade from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper @@ -89,6 +89,7 @@ class IStrategy(ABC): trailing_stop_positive: Optional[float] = None trailing_stop_positive_offset: float = 0.0 trailing_only_offset_is_reached = False + use_custom_stoploss: bool = False # associated timeframe ticker_interval: str # DEPRECATED @@ -112,12 +113,18 @@ class IStrategy(ABC): # run "populate_indicators" only for new candle process_only_new_candles: bool = False + # Number of seconds after which the candle will no longer result in a buy on expired candles + ignore_buying_expired_candle_after: int = 0 + # Disable checking the dataframe (converts the error into a warning message) disable_dataframe_checks: bool = False # Count of candles the strategy requires before producing valid signals startup_candle_count: int = 0 + # Protections + protections: List + # Class level variables (intentional) containing # the dataprovider (dp) (access to other candles, historic data, ...) # and wallets - access to the current balance. @@ -254,6 +261,28 @@ class IStrategy(ABC): """ return True + def custom_stoploss(self, pair: str, trade: Trade, current_time: datetime, current_rate: float, + current_profit: float, **kwargs) -> float: + """ + Custom stoploss logic, returning the new distance relative to current_rate (as ratio). + e.g. returning -0.05 would create a stoploss 5% below current_rate. + The custom stoploss can never be below self.stoploss, which serves as a hard maximum loss. + + For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ + + When not implemented by a strategy, returns the initial stoploss value + Only called when use_custom_stoploss is set to True. + + :param pair: Pair that's currently analyzed + :param trade: trade object. + :param current_time: datetime object, containing the current datetime + :param current_rate: Rate, calculated based on pricing settings in ask_strategy. + :param current_profit: Current profit (as ratio), calculated based on current_rate. + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return float: New stoploss value, relative to the currentrate + """ + return self.stoploss + def informative_pairs(self) -> ListPairsWithTimeframes: """ Define additional, informative pair/interval combinations to be cached from the exchange. @@ -453,8 +482,22 @@ class IStrategy(ABC): (buy, sell) = latest[SignalType.BUY.value] == 1, latest[SignalType.SELL.value] == 1 logger.debug('trigger: %s (pair=%s) buy=%s sell=%s', latest['date'], pair, str(buy), str(sell)) + timeframe_seconds = timeframe_to_seconds(timeframe) + if self.ignore_expired_candle(latest_date=latest_date, + current_time=datetime.now(timezone.utc), + timeframe_seconds=timeframe_seconds, + buy=buy): + return False, sell return buy, sell + def ignore_expired_candle(self, latest_date: datetime, current_time: datetime, + timeframe_seconds: int, buy: bool): + if self.ignore_buying_expired_candle_after and buy: + time_delta = current_time - (latest_date + timedelta(seconds=timeframe_seconds)) + return time_delta.total_seconds() > self.ignore_buying_expired_candle_after + else: + return False + def should_sell(self, trade: Trade, rate: float, date: datetime, buy: bool, sell: bool, low: float = None, high: float = None, force_stoploss: float = 0) -> SellCheckTuple: @@ -479,18 +522,19 @@ class IStrategy(ABC): # Set current rate to high for backtesting sell current_rate = high or rate current_profit = trade.calc_profit_ratio(current_rate) - config_ask_strategy = self.config.get('ask_strategy', {}) + ask_strategy = self.config.get('ask_strategy', {}) # if buy signal and ignore_roi is set, we don't need to evaluate min_roi. - roi_reached = (not (buy and config_ask_strategy.get('ignore_roi_if_buy_signal', False)) + roi_reached = (not (buy and ask_strategy.get('ignore_roi_if_buy_signal', False)) and self.min_roi_reached(trade=trade, current_profit=current_profit, current_time=date)) - if config_ask_strategy.get('sell_profit_only', False) and trade.calc_profit(rate=rate) <= 0: + if (ask_strategy.get('sell_profit_only', False) + and trade.calc_profit(rate=rate) <= ask_strategy.get('sell_profit_offset', 0)): # Negative profits and sell_profit_only - ignore sell signal sell_signal = False else: - sell_signal = sell and not buy and config_ask_strategy.get('use_sell_signal', True) + sell_signal = sell and not buy and ask_strategy.get('use_sell_signal', True) # TODO: return here if sell-signal should be favored over ROI # Start evaluations @@ -531,6 +575,19 @@ class IStrategy(ABC): # Initiate stoploss with open_rate. Does nothing if stoploss is already set. trade.adjust_stop_loss(trade.open_rate, stop_loss_value, initial=True) + if self.use_custom_stoploss: + stop_loss_value = strategy_safe_wrapper(self.custom_stoploss, default_retval=None + )(pair=trade.pair, trade=trade, + current_time=current_time, + current_rate=current_rate, + current_profit=current_profit) + # Sanity check - error cases will return None + if stop_loss_value: + # logger.info(f"{trade.pair} {stop_loss_value=} {current_profit=}") + trade.adjust_stop_loss(current_rate, stop_loss_value) + else: + logger.warning("CustomStoploss function did not return valid stoploss") + if self.trailing_stop: # trailing stoploss handling sl_offset = self.trailing_stop_positive_offset @@ -636,6 +693,7 @@ class IStrategy(ABC): :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) diff --git a/freqtrade/strategy/strategy_helper.py b/freqtrade/strategy/strategy_helper.py index ea0e234ec..d7b1327d9 100644 --- a/freqtrade/strategy/strategy_helper.py +++ b/freqtrade/strategy/strategy_helper.py @@ -24,15 +24,24 @@ def merge_informative_pair(dataframe: pd.DataFrame, informative: pd.DataFrame, :param timeframe: Timeframe of the original pair sample. :param timeframe_inf: Timeframe of the informative pair sample. :param ffill: Forwardfill missing values - optional but usually required + :return: Merged dataframe + :raise: ValueError if the secondary timeframe is shorter than the dataframe timeframe """ minutes_inf = timeframe_to_minutes(timeframe_inf) minutes = timeframe_to_minutes(timeframe) - if minutes >= minutes_inf: + if minutes == minutes_inf: # No need to forwardshift if the timeframes are identical informative['date_merge'] = informative["date"] + elif minutes < minutes_inf: + # Subtract "small" timeframe so merging is not delayed by 1 small candle + # Detailed explanation in https://github.com/freqtrade/freqtrade/issues/4073 + informative['date_merge'] = ( + informative["date"] + pd.to_timedelta(minutes_inf, 'm') - pd.to_timedelta(minutes, 'm') + ) else: - informative['date_merge'] = informative["date"] + pd.to_timedelta(minutes_inf, 'm') + raise ValueError("Tried to merge a faster timeframe to a slower timeframe." + "This would create new rows, and can throw off your regular indicators.") # Rename columns to be unique informative.columns = [f"{col}_{timeframe_inf}" for col in informative.columns] diff --git a/freqtrade/templates/base_config.json.j2 b/freqtrade/templates/base_config.json.j2 index b362690f9..f920843b2 100644 --- a/freqtrade/templates/base_config.json.j2 +++ b/freqtrade/templates/base_config.json.j2 @@ -63,6 +63,7 @@ "username": "", "password": "" }, + "bot_name": "freqtrade", "initial_state": "running", "forcebuy_enable": false, "internals": { diff --git a/freqtrade/templates/subtemplates/strategy_methods_advanced.j2 b/freqtrade/templates/subtemplates/strategy_methods_advanced.j2 index 5ca6e6971..53ededa19 100644 --- a/freqtrade/templates/subtemplates/strategy_methods_advanced.j2 +++ b/freqtrade/templates/subtemplates/strategy_methods_advanced.j2 @@ -12,6 +12,30 @@ def bot_loop_start(self, **kwargs) -> None: """ pass +use_custom_stoploss = True + +def custom_stoploss(self, pair: str, trade: 'Trade', current_time: 'datetime', current_rate: float, + current_profit: float, **kwargs) -> float: + """ + Custom stoploss logic, returning the new distance relative to current_rate (as ratio). + e.g. returning -0.05 would create a stoploss 5% below current_rate. + The custom stoploss can never be below self.stoploss, which serves as a hard maximum loss. + + For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ + + When not implemented by a strategy, returns the initial stoploss value + Only called when use_custom_stoploss is set to True. + + :param pair: Pair that's about to be sold. + :param trade: trade object. + :param current_time: datetime object, containing the current datetime + :param current_rate: Rate, calculated based on pricing settings in ask_strategy. + :param current_profit: Current profit (as ratio), calculated based on current_rate. + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return float: New stoploss value, relative to the currentrate + """ + return self.stoploss + def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float, time_in_force: str, **kwargs) -> bool: """ @@ -45,7 +69,7 @@ def confirm_trade_exit(self, pair: str, trade: 'Trade', order_type: str, amount: When not implemented by a strategy, returns True (always confirming). - :param pair: Pair that's about to be sold. + :param pair: Pair that's currently analyzed :param trade: trade object. :param order_type: Order type (as configured in order_types). usually limit or market. :param amount: Amount in quote currency. diff --git a/mkdocs.yml b/mkdocs.yml index 96cfa7651..4545e8d84 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -3,7 +3,6 @@ nav: - Home: index.md - Quickstart with Docker: docker_quickstart.md - Installation: - - Docker without docker-compose: docker.md - Linux/MacOS/Raspberry: installation.md - Windows: windows_installation.md - Freqtrade Basics: bot-basics.md diff --git a/requirements-dev.txt b/requirements-dev.txt index a2da87430..01066959a 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,17 +3,17 @@ -r requirements-plot.txt -r requirements-hyperopt.txt -coveralls==2.2.0 +coveralls==3.0.0 flake8==3.8.4 flake8-type-annotations==0.1.0 flake8-tidy-imports==4.2.1 mypy==0.790 pytest==6.2.1 pytest-asyncio==0.14.0 -pytest-cov==2.10.1 -pytest-mock==3.4.0 +pytest-cov==2.11.1 +pytest-mock==3.5.1 pytest-random-order==1.0.4 -isort==5.6.4 +isort==5.7.0 # Convert jupyter notebooks to markdown documents nbconvert==6.0.7 diff --git a/requirements-hyperopt.txt b/requirements-hyperopt.txt index c51062bf7..104fbf454 100644 --- a/requirements-hyperopt.txt +++ b/requirements-hyperopt.txt @@ -2,8 +2,8 @@ -r requirements.txt # Required for hyperopt -scipy==1.5.4 -scikit-learn==0.23.2 +scipy==1.6.0 +scikit-learn==0.24.1 scikit-optimize==0.8.1 filelock==3.0.12 joblib==1.0.0 diff --git a/requirements-plot.txt b/requirements-plot.txt index 3e31a24ae..6693a593d 100644 --- a/requirements-plot.txt +++ b/requirements-plot.txt @@ -1,5 +1,5 @@ # Include all requirements to run the bot. -r requirements.txt -plotly==4.14.1 +plotly==4.14.3 diff --git a/requirements.txt b/requirements.txt index 594c22b74..cc8861d82 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,12 @@ -numpy==1.19.4 -pandas==1.1.5 +numpy==1.19.5 +pandas==1.2.1 -ccxt==1.39.79 +ccxt==1.40.99 aiohttp==3.7.3 SQLAlchemy==1.3.22 python-telegram-bot==13.1 arrow==0.17.0 -cachetools==4.2.0 +cachetools==4.2.1 requests==2.25.1 urllib3==1.26.2 wrapt==1.12.1 @@ -16,7 +16,7 @@ tabulate==0.8.7 pycoingecko==1.4.0 jinja2==2.11.2 tables==3.6.1 -blosc==1.10.1 +blosc==1.10.2 # find first, C search in arrays py_find_1st==1.1.4 @@ -27,13 +27,13 @@ python-rapidjson==1.0 # Notify systemd sdnotify==0.3.2 -# Api server -flask==1.1.2 -flask-jwt-extended==3.25.0 -flask-cors==3.0.9 +# API Server +fastapi==0.63.0 +uvicorn==0.13.3 +pyjwt==2.0.1 # Support for colorized terminal output colorama==0.4.4 # Building config files interactively questionary==1.9.0 -prompt-toolkit==3.0.8 +prompt-toolkit==3.0.14 diff --git a/setup.sh b/setup.sh index b270146c1..d0ca1f643 100755 --- a/setup.sh +++ b/setup.sh @@ -202,52 +202,6 @@ function test_and_fix_python_on_mac() { fi } -function config_generator() { - - echo "Starting to generate config.json" - echo - echo "Generating General configuration" - echo "-------------------------" - default_max_trades=3 - read -p "Max open trades: (Default: $default_max_trades) " max_trades - max_trades=${max_trades:-$default_max_trades} - - default_stake_amount=0.05 - read -p "Stake amount: (Default: $default_stake_amount) " stake_amount - stake_amount=${stake_amount:-$default_stake_amount} - - default_stake_currency="BTC" - read -p "Stake currency: (Default: $default_stake_currency) " stake_currency - stake_currency=${stake_currency:-$default_stake_currency} - - default_fiat_currency="USD" - read -p "Fiat currency: (Default: $default_fiat_currency) " fiat_currency - fiat_currency=${fiat_currency:-$default_fiat_currency} - - echo - echo "Generating exchange config " - echo "------------------------" - read -p "Exchange API key: " api_key - read -p "Exchange API Secret: " api_secret - - echo - echo "Generating Telegram config" - echo "-------------------------" - read -p "Telegram Token: " token - read -p "Telegram Chat_id: " chat_id - - sed -e "s/\"max_open_trades\": 3,/\"max_open_trades\": $max_trades,/g" \ - -e "s/\"stake_amount\": 0.05,/\"stake_amount\": $stake_amount,/g" \ - -e "s/\"stake_currency\": \"BTC\",/\"stake_currency\": \"$stake_currency\",/g" \ - -e "s/\"fiat_display_currency\": \"USD\",/\"fiat_display_currency\": \"$fiat_currency\",/g" \ - -e "s/\"your_exchange_key\"/\"$api_key\"/g" \ - -e "s/\"your_exchange_secret\"/\"$api_secret\"/g" \ - -e "s/\"your_telegram_token\"/\"$token\"/g" \ - -e "s/\"your_telegram_chat_id\"/\"$chat_id\"/g" \ - -e "s/\"dry_run\": false,/\"dry_run\": true,/g" config.json.example > config.json - -} - function config() { echo "-------------------------" diff --git a/tests/commands/test_commands.py b/tests/commands/test_commands.py index 26e0c4a79..2284209a0 100644 --- a/tests/commands/test_commands.py +++ b/tests/commands/test_commands.py @@ -21,7 +21,7 @@ from tests.conftest_trades import MOCK_TRADE_COUNT def test_setup_utils_configuration(): args = [ - 'list-exchanges', '--config', 'config.json.example', + 'list-exchanges', '--config', 'config_bittrex.json.example', ] config = setup_utils_configuration(get_args(args), RunMode.OTHER) @@ -40,7 +40,7 @@ def test_start_trading_fail(mocker, caplog): exitmock = mocker.patch("freqtrade.worker.Worker.exit", MagicMock()) args = [ 'trade', - '-c', 'config.json.example' + '-c', 'config_bittrex.json.example' ] start_trading(get_args(args)) assert exitmock.call_count == 1 @@ -122,10 +122,10 @@ def test_list_timeframes(mocker, capsys): match=r"This command requires a configured exchange.*"): start_list_timeframes(pargs) - # Test with --config config.json.example + # Test with --config config_bittrex.json.example args = [ "list-timeframes", - '--config', 'config.json.example', + '--config', 'config_bittrex.json.example', ] start_list_timeframes(get_args(args)) captured = capsys.readouterr() @@ -169,7 +169,7 @@ def test_list_timeframes(mocker, capsys): # Test with --one-column args = [ "list-timeframes", - '--config', 'config.json.example', + '--config', 'config_bittrex.json.example', "--one-column", ] start_list_timeframes(get_args(args)) @@ -209,10 +209,10 @@ def test_list_markets(mocker, markets, capsys): match=r"This command requires a configured exchange.*"): start_list_markets(pargs, False) - # Test with --config config.json.example + # Test with --config config_bittrex.json.example args = [ "list-markets", - '--config', 'config.json.example', + '--config', 'config_bittrex.json.example', "--print-list", ] start_list_markets(get_args(args), False) @@ -239,7 +239,7 @@ def test_list_markets(mocker, markets, capsys): # Test with --all: all markets args = [ "list-markets", "--all", - '--config', 'config.json.example', + '--config', 'config_bittrex.json.example', "--print-list", ] start_list_markets(get_args(args), False) @@ -252,7 +252,7 @@ def test_list_markets(mocker, markets, capsys): # Test list-pairs subcommand: active pairs args = [ "list-pairs", - '--config', 'config.json.example', + '--config', 'config_bittrex.json.example', "--print-list", ] start_list_markets(get_args(args), True) @@ -264,7 +264,7 @@ def test_list_markets(mocker, markets, capsys): # Test list-pairs subcommand with --all: all pairs args = [ "list-pairs", "--all", - '--config', 'config.json.example', + '--config', 'config_bittrex.json.example', "--print-list", ] start_list_markets(get_args(args), True) @@ -277,7 +277,7 @@ def test_list_markets(mocker, markets, capsys): # active markets, base=ETH, LTC args = [ "list-markets", - '--config', 'config.json.example', + '--config', 'config_bittrex.json.example', "--base", "ETH", "LTC", "--print-list", ] @@ -290,7 +290,7 @@ def test_list_markets(mocker, markets, capsys): # active markets, base=LTC args = [ "list-markets", - '--config', 'config.json.example', + '--config', 'config_bittrex.json.example', "--base", "LTC", "--print-list", ] @@ -303,7 +303,7 @@ def test_list_markets(mocker, markets, capsys): # active markets, quote=USDT, USD args = [ "list-markets", - '--config', 'config.json.example', + '--config', 'config_bittrex.json.example', "--quote", "USDT", "USD", "--print-list", ] @@ -316,7 +316,7 @@ def test_list_markets(mocker, markets, capsys): # active markets, quote=USDT args = [ "list-markets", - '--config', 'config.json.example', + '--config', 'config_bittrex.json.example', "--quote", "USDT", "--print-list", ] @@ -329,7 +329,7 @@ def test_list_markets(mocker, markets, capsys): # active markets, base=LTC, quote=USDT args = [ "list-markets", - '--config', 'config.json.example', + '--config', 'config_bittrex.json.example', "--base", "LTC", "--quote", "USDT", "--print-list", ] @@ -342,7 +342,7 @@ def test_list_markets(mocker, markets, capsys): # active pairs, base=LTC, quote=USDT args = [ "list-pairs", - '--config', 'config.json.example', + '--config', 'config_bittrex.json.example', "--base", "LTC", "--quote", "USD", "--print-list", ] @@ -355,7 +355,7 @@ def test_list_markets(mocker, markets, capsys): # active markets, base=LTC, quote=USDT, NONEXISTENT args = [ "list-markets", - '--config', 'config.json.example', + '--config', 'config_bittrex.json.example', "--base", "LTC", "--quote", "USDT", "NONEXISTENT", "--print-list", ] @@ -368,7 +368,7 @@ def test_list_markets(mocker, markets, capsys): # active markets, base=LTC, quote=NONEXISTENT args = [ "list-markets", - '--config', 'config.json.example', + '--config', 'config_bittrex.json.example', "--base", "LTC", "--quote", "NONEXISTENT", "--print-list", ] @@ -381,7 +381,7 @@ def test_list_markets(mocker, markets, capsys): # Test tabular output args = [ "list-markets", - '--config', 'config.json.example', + '--config', 'config_bittrex.json.example', ] start_list_markets(get_args(args), False) captured = capsys.readouterr() @@ -391,7 +391,7 @@ def test_list_markets(mocker, markets, capsys): # Test tabular output, no markets found args = [ "list-markets", - '--config', 'config.json.example', + '--config', 'config_bittrex.json.example', "--base", "LTC", "--quote", "NONEXISTENT", ] start_list_markets(get_args(args), False) @@ -403,7 +403,7 @@ def test_list_markets(mocker, markets, capsys): # Test --print-json args = [ "list-markets", - '--config', 'config.json.example', + '--config', 'config_bittrex.json.example', "--print-json" ] start_list_markets(get_args(args), False) @@ -415,7 +415,7 @@ def test_list_markets(mocker, markets, capsys): # Test --print-csv args = [ "list-markets", - '--config', 'config.json.example', + '--config', 'config_bittrex.json.example', "--print-csv" ] start_list_markets(get_args(args), False) @@ -427,7 +427,7 @@ def test_list_markets(mocker, markets, capsys): # Test --one-column args = [ "list-markets", - '--config', 'config.json.example', + '--config', 'config_bittrex.json.example', "--one-column" ] start_list_markets(get_args(args), False) @@ -439,7 +439,7 @@ def test_list_markets(mocker, markets, capsys): # Test --one-column args = [ "list-markets", - '--config', 'config.json.example', + '--config', 'config_bittrex.json.example', "--one-column" ] with pytest.raises(OperationalException, match=r"Cannot get markets.*"): @@ -781,7 +781,7 @@ def test_start_test_pairlist(mocker, caplog, tickers, default_conf, capsys): patched_configuration_load_config_file(mocker, default_conf) args = [ 'test-pairlist', - '-c', 'config.json.example' + '-c', 'config_bittrex.json.example' ] start_test_pairlist(get_args(args)) @@ -795,7 +795,7 @@ def test_start_test_pairlist(mocker, caplog, tickers, default_conf, capsys): args = [ 'test-pairlist', - '-c', 'config.json.example', + '-c', 'config_bittrex.json.example', '--one-column', ] start_test_pairlist(get_args(args)) @@ -804,7 +804,7 @@ def test_start_test_pairlist(mocker, caplog, tickers, default_conf, capsys): args = [ 'test-pairlist', - '-c', 'config.json.example', + '-c', 'config_bittrex.json.example', '--print-json', ] start_test_pairlist(get_args(args)) diff --git a/tests/conftest.py b/tests/conftest.py index 9eda0e973..75a98dcc5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1492,11 +1492,11 @@ def trades_for_order(): @pytest.fixture(scope="function") def trades_history(): - return [[1565798389463, '126181329', None, 'buy', 0.019627, 0.04, 0.00078508], - [1565798399629, '126181330', None, 'buy', 0.019627, 0.244, 0.004788987999999999], - [1565798399752, '126181331', None, 'sell', 0.019626, 0.011, 0.00021588599999999999], - [1565798399862, '126181332', None, 'sell', 0.019626, 0.011, 0.00021588599999999999], - [1565798399872, '126181333', None, 'sell', 0.019626, 0.011, 0.00021588599999999999]] + return [[1565798389463, '12618132aa9', None, 'buy', 0.019627, 0.04, 0.00078508], + [1565798399629, '1261813bb30', None, 'buy', 0.019627, 0.244, 0.004788987999999999], + [1565798399752, '1261813cc31', None, 'sell', 0.019626, 0.011, 0.00021588599999999999], + [1565798399862, '126181cc332', None, 'sell', 0.019626, 0.011, 0.00021588599999999999], + [1565798399872, '1261aa81333', None, 'sell', 0.019626, 0.011, 0.00021588599999999999]] @pytest.fixture(scope="function") diff --git a/tests/conftest_trades.py b/tests/conftest_trades.py index e84722041..fa9910b8d 100644 --- a/tests/conftest_trades.py +++ b/tests/conftest_trades.py @@ -32,6 +32,7 @@ def mock_trade_1(fee): exchange='bittrex', open_order_id='dry_run_buy_12345', strategy='DefaultStrategy', + timeframe=5, ) o = Order.parse_from_ccxt_object(mock_order_1(), 'ETH/BTC', 'buy') trade.orders.append(o) @@ -84,6 +85,7 @@ def mock_trade_2(fee): is_open=False, open_order_id='dry_run_sell_12345', strategy='DefaultStrategy', + timeframe=5, sell_reason='sell_signal', open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=20), close_date=datetime.now(tz=timezone.utc), @@ -132,6 +134,7 @@ def mock_trade_3(fee): pair='XRP/BTC', stake_amount=0.001, amount=123.0, + amount_requested=123.0, fee_open=fee.return_value, fee_close=fee.return_value, open_rate=0.05, @@ -139,6 +142,8 @@ def mock_trade_3(fee): close_profit=0.01, exchange='bittrex', is_open=False, + strategy='DefaultStrategy', + timeframe=5, sell_reason='roi', open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=20), close_date=datetime.now(tz=timezone.utc), @@ -179,6 +184,7 @@ def mock_trade_4(fee): exchange='bittrex', open_order_id='prod_buy_12345', strategy='DefaultStrategy', + timeframe=5, ) o = Order.parse_from_ccxt_object(mock_order_4(), 'ETC/BTC', 'buy') trade.orders.append(o) diff --git a/tests/data/test_btanalysis.py b/tests/data/test_btanalysis.py index 1592fac10..cdd5c08d2 100644 --- a/tests/data/test_btanalysis.py +++ b/tests/data/test_btanalysis.py @@ -128,7 +128,7 @@ def test_load_trades_from_db(default_conf, fee, mocker): if col not in ['index', 'open_at_end']: assert col in trades.columns trades = load_trades_from_db(db_url=default_conf['db_url'], strategy='DefaultStrategy') - assert len(trades) == 3 + assert len(trades) == 4 trades = load_trades_from_db(db_url=default_conf['db_url'], strategy='NoneStrategy') assert len(trades) == 0 diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index a42ff52e4..9d655997f 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -508,7 +508,7 @@ def test_validate_pairs(default_conf, mocker): # test exchange.validate_pairs d def test_validate_pairs_not_available(default_conf, mocker): api_mock = MagicMock() type(api_mock).markets = PropertyMock(return_value={ - 'XRP/BTC': {'inactive': True} + 'XRP/BTC': {'inactive': True, 'base': 'XRP', 'quote': 'BTC'} }) mocker.patch('freqtrade.exchange.Exchange._init_ccxt', MagicMock(return_value=api_mock)) mocker.patch('freqtrade.exchange.Exchange.validate_timeframes') @@ -1718,8 +1718,8 @@ async def test__async_fetch_trades(default_conf, mocker, caplog, exchange_name, @pytest.mark.asyncio @pytest.mark.parametrize("exchange_name", EXCHANGES) -async def test__async_get_trade_history_id(default_conf, mocker, caplog, exchange_name, - trades_history): +async def test__async_get_trade_history_id(default_conf, mocker, exchange_name, + fetch_trades_result): exchange = get_patched_exchange(mocker, default_conf, id=exchange_name) pagination_arg = exchange._trades_pagination_arg @@ -1727,28 +1727,29 @@ async def test__async_get_trade_history_id(default_conf, mocker, caplog, exchang async def mock_get_trade_hist(pair, *args, **kwargs): if 'since' in kwargs: # Return first 3 - return trades_history[:-2] - elif kwargs.get('params', {}).get(pagination_arg) == trades_history[-3][1]: + return fetch_trades_result[:-2] + elif kwargs.get('params', {}).get(pagination_arg) == fetch_trades_result[-3]['id']: # Return 2 - return trades_history[-3:-1] + return fetch_trades_result[-3:-1] else: # Return last 2 - return trades_history[-2:] + return fetch_trades_result[-2:] # Monkey-patch async function - exchange._async_fetch_trades = MagicMock(side_effect=mock_get_trade_hist) + exchange._api_async.fetch_trades = MagicMock(side_effect=mock_get_trade_hist) pair = 'ETH/BTC' - ret = await exchange._async_get_trade_history_id(pair, since=trades_history[0][0], - until=trades_history[-1][0]-1) + ret = await exchange._async_get_trade_history_id(pair, + since=fetch_trades_result[0]['timestamp'], + until=fetch_trades_result[-1]['timestamp'] - 1) assert type(ret) is tuple assert ret[0] == pair assert type(ret[1]) is list - assert len(ret[1]) == len(trades_history) - assert exchange._async_fetch_trades.call_count == 3 - fetch_trades_cal = exchange._async_fetch_trades.call_args_list + assert len(ret[1]) == len(fetch_trades_result) + assert exchange._api_async.fetch_trades.call_count == 3 + fetch_trades_cal = exchange._api_async.fetch_trades.call_args_list # first call (using since, not fromId) assert fetch_trades_cal[0][0][0] == pair - assert fetch_trades_cal[0][1]['since'] == trades_history[0][0] + assert fetch_trades_cal[0][1]['since'] == fetch_trades_result[0]['timestamp'] # 2nd call assert fetch_trades_cal[1][0][0] == pair @@ -1759,36 +1760,37 @@ async def test__async_get_trade_history_id(default_conf, mocker, caplog, exchang @pytest.mark.asyncio @pytest.mark.parametrize("exchange_name", EXCHANGES) async def test__async_get_trade_history_time(default_conf, mocker, caplog, exchange_name, - trades_history): + fetch_trades_result): caplog.set_level(logging.DEBUG) async def mock_get_trade_hist(pair, *args, **kwargs): - if kwargs['since'] == trades_history[0][0]: - return trades_history[:-1] + if kwargs['since'] == fetch_trades_result[0]['timestamp']: + return fetch_trades_result[:-1] else: - return trades_history[-1:] + return fetch_trades_result[-1:] caplog.set_level(logging.DEBUG) exchange = get_patched_exchange(mocker, default_conf, id=exchange_name) # Monkey-patch async function - exchange._async_fetch_trades = MagicMock(side_effect=mock_get_trade_hist) + exchange._api_async.fetch_trades = MagicMock(side_effect=mock_get_trade_hist) pair = 'ETH/BTC' - ret = await exchange._async_get_trade_history_time(pair, since=trades_history[0][0], - until=trades_history[-1][0]-1) + ret = await exchange._async_get_trade_history_time(pair, + since=fetch_trades_result[0]['timestamp'], + until=fetch_trades_result[-1]['timestamp']-1) assert type(ret) is tuple assert ret[0] == pair assert type(ret[1]) is list - assert len(ret[1]) == len(trades_history) - assert exchange._async_fetch_trades.call_count == 2 - fetch_trades_cal = exchange._async_fetch_trades.call_args_list + assert len(ret[1]) == len(fetch_trades_result) + assert exchange._api_async.fetch_trades.call_count == 2 + fetch_trades_cal = exchange._api_async.fetch_trades.call_args_list # first call (using since, not fromId) assert fetch_trades_cal[0][0][0] == pair - assert fetch_trades_cal[0][1]['since'] == trades_history[0][0] + assert fetch_trades_cal[0][1]['since'] == fetch_trades_result[0]['timestamp'] # 2nd call assert fetch_trades_cal[1][0][0] == pair - assert fetch_trades_cal[0][1]['since'] == trades_history[0][0] + assert fetch_trades_cal[1][1]['since'] == fetch_trades_result[-2]['timestamp'] assert log_has_re(r"Stopping because until was reached.*", caplog) diff --git a/tests/exchange/test_kraken.py b/tests/exchange/test_kraken.py index 3803658eb..97f428e2f 100644 --- a/tests/exchange/test_kraken.py +++ b/tests/exchange/test_kraken.py @@ -89,6 +89,7 @@ def test_get_balances_prod(default_conf, mocker): '2ST': balance_item.copy(), '3ST': balance_item.copy(), '4ST': balance_item.copy(), + 'EUR': balance_item.copy(), }) kraken_open_orders = [{'symbol': '1ST/EUR', 'type': 'limit', @@ -123,21 +124,22 @@ def test_get_balances_prod(default_conf, mocker): 'remaining': 2.0, }, {'status': 'open', - 'symbol': 'BTC/3ST', + 'symbol': '3ST/EUR', 'type': 'limit', 'side': 'buy', - 'price': 20, + 'price': 0.02, 'cost': 0.0, - 'amount': 3.0, + 'amount': 100.0, 'filled': 0.0, 'average': 0.0, - 'remaining': 3.0, + 'remaining': 100.0, }] api_mock.fetch_open_orders = MagicMock(return_value=kraken_open_orders) default_conf['dry_run'] = False exchange = get_patched_exchange(mocker, default_conf, api_mock, id="kraken") balances = exchange.get_balances() - assert len(balances) == 4 + assert len(balances) == 5 + assert balances['1ST']['free'] == 9.0 assert balances['1ST']['total'] == 10.0 assert balances['1ST']['used'] == 1.0 @@ -146,13 +148,17 @@ def test_get_balances_prod(default_conf, mocker): assert balances['2ST']['total'] == 10.0 assert balances['2ST']['used'] == 4.0 - assert balances['3ST']['free'] == 7.0 + assert balances['3ST']['free'] == 10.0 assert balances['3ST']['total'] == 10.0 - assert balances['3ST']['used'] == 3.0 + assert balances['3ST']['used'] == 0.0 assert balances['4ST']['free'] == 10.0 assert balances['4ST']['total'] == 10.0 assert balances['4ST']['used'] == 0.0 + + assert balances['EUR']['free'] == 8.0 + assert balances['EUR']['total'] == 10.0 + assert balances['EUR']['used'] == 2.0 ccxt_exceptionhandlers(mocker, default_conf, api_mock, "kraken", "get_balances", "fetch_balance") diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index 376390664..e55e166d9 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -350,17 +350,17 @@ def test_backtesting_start(default_conf, mocker, testdatadir, caplog) -> None: default_conf['timerange'] = '-1510694220' backtesting = Backtesting(default_conf) + backtesting.strategy.bot_loop_start = MagicMock() backtesting.start() # check the logs, that will contain the backtest result exists = [ - 'Using stake_currency: BTC ...', - 'Using stake_amount: 0.001 ...', 'Backtesting with data from 2017-11-14 21:17:00 ' 'up to 2017-11-14 22:59:00 (0 days)..' ] for line in exists: assert log_has(line, caplog) assert backtesting.strategy.dp._pairlists is not None + assert backtesting.strategy.bot_loop_start.call_count == 1 def test_backtesting_start_no_data(default_conf, mocker, caplog, testdatadir) -> None: @@ -722,8 +722,6 @@ def test_backtest_start_timerange(default_conf, mocker, caplog, testdatadir): 'Ignoring max_open_trades (--disable-max-market-positions was used) ...', 'Parameter --timerange detected: 1510694220-1510700340 ...', f'Using data directory: {testdatadir} ...', - 'Using stake_currency: BTC ...', - 'Using stake_amount: 0.001 ...', 'Loading data from 2017-11-14 20:57:00 ' 'up to 2017-11-14 22:58:00 (0 days)..', 'Backtesting with data from 2017-11-14 21:17:00 ' @@ -786,8 +784,6 @@ def test_backtest_start_multi_strat(default_conf, mocker, caplog, testdatadir): 'Ignoring max_open_trades (--disable-max-market-positions was used) ...', 'Parameter --timerange detected: 1510694220-1510700340 ...', f'Using data directory: {testdatadir} ...', - 'Using stake_currency: BTC ...', - 'Using stake_amount: 0.001 ...', 'Loading data from 2017-11-14 20:57:00 ' 'up to 2017-11-14 22:58:00 (0 days)..', 'Backtesting with data from 2017-11-14 21:17:00 ' @@ -865,8 +861,6 @@ def test_backtest_start_multi_strat_nomock(default_conf, mocker, caplog, testdat 'Ignoring max_open_trades (--disable-max-market-positions was used) ...', 'Parameter --timerange detected: 1510694220-1510700340 ...', f'Using data directory: {testdatadir} ...', - 'Using stake_currency: BTC ...', - 'Using stake_amount: 0.001 ...', 'Loading data from 2017-11-14 20:57:00 ' 'up to 2017-11-14 22:58:00 (0 days)..', 'Backtesting with data from 2017-11-14 21:17:00 ' diff --git a/tests/optimize/test_hyperoptloss.py b/tests/optimize/test_hyperoptloss.py index 63012ee48..f7910e6d6 100644 --- a/tests/optimize/test_hyperoptloss.py +++ b/tests/optimize/test_hyperoptloss.py @@ -20,7 +20,7 @@ def test_hyperoptlossresolver(mocker, default_conf) -> None: hl = ShortTradeDurHyperOptLoss mocker.patch( 'freqtrade.resolvers.hyperopt_resolver.HyperOptLossResolver.load_object', - MagicMock(return_value=hl) + MagicMock(return_value=hl()) ) default_conf.update({'hyperopt_loss': 'SharpeHyperOptLossDaily'}) x = HyperOptLossResolver.load_hyperoptloss(default_conf) diff --git a/tests/optimize/test_optimize_reports.py b/tests/optimize/test_optimize_reports.py index a0e1932ff..f184cb125 100644 --- a/tests/optimize/test_optimize_reports.py +++ b/tests/optimize/test_optimize_reports.py @@ -1,5 +1,5 @@ import re -from datetime import timedelta +from datetime import datetime, timedelta, timezone from pathlib import Path import pandas as pd @@ -77,7 +77,10 @@ def test_generate_backtest_stats(default_conf, testdatadir): SellType.ROI, SellType.FORCE_SELL] }), 'config': default_conf, - 'locks': []} + 'locks': [], + 'backtest_start_time': Arrow.utcnow().int_timestamp, + 'backtest_end_time': Arrow.utcnow().int_timestamp, + } } timerange = TimeRange.parse_timerange('1510688220-1510700340') min_date = Arrow.fromtimestamp(1510688220) @@ -121,8 +124,8 @@ def test_generate_backtest_stats(default_conf, testdatadir): } assert strat_stats['max_drawdown'] == 0.0 - assert strat_stats['drawdown_start'] == Arrow.fromtimestamp(0).datetime - assert strat_stats['drawdown_end'] == Arrow.fromtimestamp(0).datetime + assert strat_stats['drawdown_start'] == datetime(1970, 1, 1, tzinfo=timezone.utc) + assert strat_stats['drawdown_end'] == datetime(1970, 1, 1, tzinfo=timezone.utc) assert strat_stats['drawdown_end_ts'] == 0 assert strat_stats['drawdown_start_ts'] == 0 assert strat_stats['pairlist'] == ['UNITTEST/BTC'] diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 1795fc27f..fda2b1409 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -6,6 +6,7 @@ import pytest from freqtrade.constants import AVAILABLE_PAIRLISTS from freqtrade.exceptions import OperationalException +from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist from freqtrade.plugins.pairlistmanager import PairListManager from freqtrade.resolvers import PairListResolver from tests.conftest import get_patched_freqtradebot, log_has, log_has_re @@ -155,6 +156,47 @@ def test_refresh_static_pairlist(mocker, markets, static_pl_conf): assert static_pl_conf['exchange']['pair_blacklist'] == freqtrade.pairlists.blacklist +@pytest.mark.parametrize('pairs,expected', [ + (['NOEXIST/BTC', r'\+WHAT/BTC'], + ['ETH/BTC', 'TKN/BTC', 'TRST/BTC', 'NOEXIST/BTC', 'SWT/BTC', 'BCC/BTC', 'HOT/BTC']), + (['NOEXIST/BTC', r'*/BTC'], # This is an invalid regex + []), +]) +def test_refresh_static_pairlist_noexist(mocker, markets, static_pl_conf, pairs, expected, caplog): + + static_pl_conf['pairlists'][0]['allow_inactive'] = True + static_pl_conf['exchange']['pair_whitelist'] += pairs + freqtrade = get_patched_freqtradebot(mocker, static_pl_conf) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + exchange_has=MagicMock(return_value=True), + markets=PropertyMock(return_value=markets), + ) + freqtrade.pairlists.refresh_pairlist() + + # Ensure all except those in whitelist are removed + assert set(expected) == set(freqtrade.pairlists.whitelist) + assert static_pl_conf['exchange']['pair_blacklist'] == freqtrade.pairlists.blacklist + if not expected: + assert log_has_re(r'Pair whitelist contains an invalid Wildcard: Wildcard error.*', caplog) + + +def test_invalid_blacklist(mocker, markets, static_pl_conf, caplog): + static_pl_conf['exchange']['pair_blacklist'] = ['*/BTC'] + freqtrade = get_patched_freqtradebot(mocker, static_pl_conf) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + exchange_has=MagicMock(return_value=True), + markets=PropertyMock(return_value=markets), + ) + freqtrade.pairlists.refresh_pairlist() + whitelist = [] + # Ensure all except those in whitelist are removed + assert set(whitelist) == set(freqtrade.pairlists.whitelist) + assert static_pl_conf['exchange']['pair_blacklist'] == freqtrade.pairlists.blacklist + log_has_re(r"Pair blacklist contains an invalid Wildcard.*", caplog) + + def test_refresh_pairlist_dynamic(mocker, shitcoinmarkets, tickers, whitelist_conf): mocker.patch.multiple( @@ -677,6 +719,32 @@ def test_rangestabilityfilter_caching(mocker, markets, default_conf, tickers, oh assert freqtrade.exchange.refresh_latest_ohlcv.call_count == previous_call_count +def test_spreadfilter_invalid_data(mocker, default_conf, markets, tickers, caplog): + default_conf['pairlists'] = [{'method': 'VolumePairList', 'number_assets': 10}, + {'method': 'SpreadFilter', 'max_spread_ratio': 0.1}] + + mocker.patch.multiple('freqtrade.exchange.Exchange', + markets=PropertyMock(return_value=markets), + exchange_has=MagicMock(return_value=True), + get_tickers=tickers + ) + + ftbot = get_patched_freqtradebot(mocker, default_conf) + ftbot.pairlists.refresh_pairlist() + + assert len(ftbot.pairlists.whitelist) == 5 + + tickers.return_value['ETH/BTC']['ask'] = 0.0 + del tickers.return_value['TKN/BTC'] + del tickers.return_value['LTC/BTC'] + mocker.patch.multiple('freqtrade.exchange.Exchange', get_tickers=tickers) + + ftbot.pairlists.refresh_pairlist() + assert log_has_re(r'Removed .* invalid ticker data.*', caplog) + + assert len(ftbot.pairlists.whitelist) == 2 + + @pytest.mark.parametrize("pairlistconfig,desc_expected,exception_expected", [ ({"method": "PriceFilter", "low_price_ratio": 0.001, "min_price": 0.00000010, "max_price": 1.0}, @@ -804,3 +872,73 @@ def test_performance_filter(mocker, whitelist_conf, pairlists, pair_allowlist, o freqtrade.pairlists.refresh_pairlist() allowlist = freqtrade.pairlists.whitelist assert allowlist == allowlist_result + + +@pytest.mark.parametrize('wildcardlist,pairs,expected', [ + (['BTC/USDT'], + ['BTC/USDT'], + ['BTC/USDT']), + (['BTC/USDT', 'ETH/USDT'], + ['BTC/USDT', 'ETH/USDT'], + ['BTC/USDT', 'ETH/USDT']), + (['BTC/USDT', 'ETH/USDT'], + ['BTC/USDT'], ['BTC/USDT']), # Test one too many + (['.*/USDT'], + ['BTC/USDT', 'ETH/USDT'], ['BTC/USDT', 'ETH/USDT']), # Wildcard simple + (['.*C/USDT'], + ['BTC/USDT', 'ETC/USDT', 'ETH/USDT'], ['BTC/USDT', 'ETC/USDT']), # Wildcard exclude one + (['.*UP/USDT', 'BTC/USDT', 'ETH/USDT'], + ['BTC/USDT', 'ETC/USDT', 'ETH/USDT', 'BTCUP/USDT', 'XRPUP/USDT', 'XRPDOWN/USDT'], + ['BTC/USDT', 'ETH/USDT', 'BTCUP/USDT', 'XRPUP/USDT']), # Wildcard exclude one + (['BTC/.*', 'ETH/.*'], + ['BTC/USDT', 'ETC/USDT', 'ETH/USDT', 'BTC/USD', 'ETH/EUR', 'BTC/GBP'], + ['BTC/USDT', 'ETH/USDT', 'BTC/USD', 'ETH/EUR', 'BTC/GBP']), # Wildcard exclude one + (['*UP/USDT', 'BTC/USDT', 'ETH/USDT'], + ['BTC/USDT', 'ETC/USDT', 'ETH/USDT', 'BTCUP/USDT', 'XRPUP/USDT', 'XRPDOWN/USDT'], + None), + (['BTC/USD'], + ['BTC/USD', 'BTC/USDT'], + ['BTC/USD']), +]) +def test_expand_pairlist(wildcardlist, pairs, expected): + if expected is None: + with pytest.raises(ValueError, match=r'Wildcard error in \*UP/USDT,'): + expand_pairlist(wildcardlist, pairs) + else: + assert sorted(expand_pairlist(wildcardlist, pairs)) == sorted(expected) + + +@pytest.mark.parametrize('wildcardlist,pairs,expected', [ + (['BTC/USDT'], + ['BTC/USDT'], + ['BTC/USDT']), + (['BTC/USDT', 'ETH/USDT'], + ['BTC/USDT', 'ETH/USDT'], + ['BTC/USDT', 'ETH/USDT']), + (['BTC/USDT', 'ETH/USDT'], + ['BTC/USDT'], ['BTC/USDT', 'ETH/USDT']), # Test one too many + (['.*/USDT'], + ['BTC/USDT', 'ETH/USDT'], ['BTC/USDT', 'ETH/USDT']), # Wildcard simple + (['.*C/USDT'], + ['BTC/USDT', 'ETC/USDT', 'ETH/USDT'], ['BTC/USDT', 'ETC/USDT']), # Wildcard exclude one + (['.*UP/USDT', 'BTC/USDT', 'ETH/USDT'], + ['BTC/USDT', 'ETC/USDT', 'ETH/USDT', 'BTCUP/USDT', 'XRPUP/USDT', 'XRPDOWN/USDT'], + ['BTC/USDT', 'ETH/USDT', 'BTCUP/USDT', 'XRPUP/USDT']), # Wildcard exclude one + (['BTC/.*', 'ETH/.*'], + ['BTC/USDT', 'ETC/USDT', 'ETH/USDT', 'BTC/USD', 'ETH/EUR', 'BTC/GBP'], + ['BTC/USDT', 'ETH/USDT', 'BTC/USD', 'ETH/EUR', 'BTC/GBP']), # Wildcard exclude one + (['*UP/USDT', 'BTC/USDT', 'ETH/USDT'], + ['BTC/USDT', 'ETC/USDT', 'ETH/USDT', 'BTCUP/USDT', 'XRPUP/USDT', 'XRPDOWN/USDT'], + None), + (['HELLO/WORLD'], [], ['HELLO/WORLD']), # Invalid pair kept + (['BTC/USD'], + ['BTC/USD', 'BTC/USDT'], + ['BTC/USD']), + +]) +def test_expand_pairlist_keep_invalid(wildcardlist, pairs, expected): + if expected is None: + with pytest.raises(ValueError, match=r'Wildcard error in \*UP/USDT,'): + expand_pairlist(wildcardlist, pairs, keep_invalid=True) + else: + assert sorted(expand_pairlist(wildcardlist, pairs, keep_invalid=True)) == sorted(expected) diff --git a/tests/plugins/test_pairlocks.py b/tests/plugins/test_pairlocks.py index bd103b21e..dfcbff0ed 100644 --- a/tests/plugins/test_pairlocks.py +++ b/tests/plugins/test_pairlocks.py @@ -11,11 +11,10 @@ from freqtrade.persistence.models import PairLock @pytest.mark.usefixtures("init_persistence") def test_PairLocks(use_db): PairLocks.timeframe = '5m' + PairLocks.use_db = use_db # No lock should be present if use_db: assert len(PairLock.query.all()) == 0 - else: - PairLocks.use_db = False assert PairLocks.use_db == use_db @@ -88,10 +87,9 @@ def test_PairLocks(use_db): def test_PairLocks_getlongestlock(use_db): PairLocks.timeframe = '5m' # No lock should be present + PairLocks.use_db = use_db if use_db: assert len(PairLock.query.all()) == 0 - else: - PairLocks.use_db = False assert PairLocks.use_db == use_db diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index 19788c067..8ec356d54 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -957,14 +957,24 @@ def test_rpc_blacklist(mocker, default_conf) -> None: assert isinstance(ret['errors'], dict) assert ret['errors']['ETH/BTC']['error_msg'] == 'Pair ETH/BTC already in pairlist.' - ret = rpc._rpc_blacklist(["ETH/ETH"]) + ret = rpc._rpc_blacklist(["*/BTC"]) assert 'StaticPairList' in ret['method'] assert len(ret['blacklist']) == 3 assert ret['blacklist'] == default_conf['exchange']['pair_blacklist'] assert ret['blacklist'] == ['DOGE/BTC', 'HOT/BTC', 'ETH/BTC'] + assert ret['blacklist_expanded'] == ['ETH/BTC'] + assert 'errors' in ret + assert isinstance(ret['errors'], dict) + assert ret['errors'] == {'*/BTC': {'error_msg': 'Pair */BTC is not a valid wildcard.'}} + + ret = rpc._rpc_blacklist(["XRP/.*"]) + assert 'StaticPairList' in ret['method'] + assert len(ret['blacklist']) == 4 + assert ret['blacklist'] == default_conf['exchange']['pair_blacklist'] + assert ret['blacklist'] == ['DOGE/BTC', 'HOT/BTC', 'ETH/BTC', 'XRP/.*'] + assert ret['blacklist_expanded'] == ['ETH/BTC', 'XRP/BTC'] assert 'errors' in ret assert isinstance(ret['errors'], dict) - assert ret['errors']['ETH/ETH']['error_msg'] == 'Pair ETH/ETH does not match stake currency.' def test_rpc_edge_disabled(mocker, default_conf) -> None: diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index e7eee6f05..f5b9a58f3 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -7,18 +7,25 @@ from pathlib import Path from unittest.mock import ANY, MagicMock, PropertyMock import pytest -from flask import Flask +import uvicorn +from fastapi import FastAPI +from fastapi.exceptions import HTTPException +from fastapi.testclient import TestClient from requests.auth import _basic_auth_str from freqtrade.__init__ import __version__ from freqtrade.loggers import setup_logging, setup_logging_pre from freqtrade.persistence import PairLocks, Trade from freqtrade.rpc import RPC -from freqtrade.rpc.api_server import BASE_URI, ApiServer +from freqtrade.rpc.api_server import ApiServer +from freqtrade.rpc.api_server.api_auth import create_token, get_user_from_token +from freqtrade.rpc.api_server.uvicorn_threaded import UvicornServer from freqtrade.state import RunMode, State -from tests.conftest import create_mock_trades, get_patched_freqtradebot, log_has, patch_get_signal +from tests.conftest import (create_mock_trades, get_patched_freqtradebot, log_has, log_has_re, + patch_get_signal) +BASE_URI = "/api/v1" _TEST_USER = "FreqTrader" _TEST_PASS = "SuperSecurePassword1!" @@ -38,18 +45,19 @@ def botclient(default_conf, mocker): ftbot = get_patched_freqtradebot(mocker, default_conf) rpc = RPC(ftbot) - mocker.patch('freqtrade.rpc.api_server.ApiServer.run', MagicMock()) + mocker.patch('freqtrade.rpc.api_server.ApiServer.start_api', MagicMock()) apiserver = ApiServer(rpc, default_conf) - yield ftbot, apiserver.app.test_client() + yield ftbot, TestClient(apiserver.app) # 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), - 'Origin': 'http://example.com'}) + 'Origin': 'http://example.com', + 'content-type': 'application/json' + }) def client_get(client, url): @@ -66,10 +74,10 @@ def client_delete(client, url): def assert_response(response, expected_code=200, needs_cors=True): assert response.status_code == expected_code - assert response.content_type == "application/json" + assert response.headers.get('content-type') == "application/json" if needs_cors: - assert ('Access-Control-Allow-Credentials', 'true') in response.headers._list - assert ('Access-Control-Allow-Origin', 'http://example.com') in response.headers._list + assert ('access-control-allow-credentials', 'true') in response.headers.items() + assert ('access-control-allow-origin', 'http://example.com') in response.headers.items() def test_api_not_found(botclient): @@ -77,55 +85,76 @@ def test_api_not_found(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 - } + assert rc.json() == {"detail": "Not Found"} + + +def test_api_auth(): + with pytest.raises(ValueError): + create_token({'identity': {'u': 'Freqtrade'}}, 'secret1234', token_type="NotATokenType") + + token = create_token({'identity': {'u': 'Freqtrade'}}, 'secret1234') + assert isinstance(token, str) + + u = get_user_from_token(token, 'secret1234') + assert u == 'Freqtrade' + with pytest.raises(HTTPException): + get_user_from_token(token, 'secret1234', token_type='refresh') + # Create invalid token + token = create_token({'identity': {'u1': 'Freqrade'}}, 'secret1234') + with pytest.raises(HTTPException): + get_user_from_token(token, 'secret1234') + + with pytest.raises(HTTPException): + get_user_from_token(b'not_a_token', 'secret1234') def test_api_unauthorized(botclient): ftbot, client = botclient rc = client.get(f"{BASE_URI}/ping") assert_response(rc, needs_cors=False) - assert rc.json == {'status': 'pong'} + assert rc.json() == {'status': 'pong'} # Don't send user/pass information rc = client.get(f"{BASE_URI}/version") assert_response(rc, 401, needs_cors=False) - assert rc.json == {'error': 'Unauthorized'} + assert rc.json() == {'detail': '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'} + assert rc.json() == {'detail': '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'} + assert rc.json() == {'detail': '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'} + assert rc.json() == {'detail': 'Unauthorized'} def test_api_token_login(botclient): ftbot, client = botclient + rc = client.post(f"{BASE_URI}/token/login", + data=None, + headers={'Authorization': _basic_auth_str('WRONG_USER', 'WRONG_PASS'), + 'Origin': 'http://example.com'}) + assert_response(rc, 401) rc = client_post(client, f"{BASE_URI}/token/login") assert_response(rc) - assert 'access_token' in rc.json - assert 'refresh_token' in rc.json + assert 'access_token' in rc.json() + assert 'refresh_token' in rc.json() # test Authentication is working with JWT tokens too rc = client.get(f"{BASE_URI}/count", - content_type="application/json", - headers={'Authorization': f'Bearer {rc.json["access_token"]}', + headers={'Authorization': f'Bearer {rc.json()["access_token"]}', 'Origin': 'http://example.com'}) assert_response(rc) @@ -135,13 +164,12 @@ def test_api_token_refresh(botclient): rc = client_post(client, f"{BASE_URI}/token/login") assert_response(rc) rc = client.post(f"{BASE_URI}/token/refresh", - content_type="application/json", data=None, - headers={'Authorization': f'Bearer {rc.json["refresh_token"]}', + headers={'Authorization': f'Bearer {rc.json()["refresh_token"]}', 'Origin': 'http://example.com'}) assert_response(rc) - assert 'access_token' in rc.json - assert 'refresh_token' not in rc.json + assert 'access_token' in rc.json() + assert 'refresh_token' not in rc.json() def test_api_stop_workflow(botclient): @@ -149,24 +177,24 @@ def test_api_stop_workflow(botclient): assert ftbot.state == State.RUNNING rc = client_post(client, f"{BASE_URI}/stop") assert_response(rc) - assert rc.json == {'status': 'stopping trader ...'} + 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'} + 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 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'} + assert rc.json() == {'status': 'already running'} def test_api__init__(default_conf, mocker): @@ -180,11 +208,29 @@ def test_api__init__(default_conf, mocker): "password": "testPass", }}) mocker.patch('freqtrade.rpc.telegram.Updater', MagicMock()) - mocker.patch('freqtrade.rpc.api_server.ApiServer.run', MagicMock()) + mocker.patch('freqtrade.rpc.api_server.webserver.ApiServer.start_api', MagicMock()) apiserver = ApiServer(RPC(get_patched_freqtradebot(mocker, default_conf)), default_conf) assert apiserver._config == default_conf +def test_api_UvicornServer(default_conf, mocker): + thread_mock = mocker.patch('freqtrade.rpc.api_server.uvicorn_threaded.threading.Thread') + s = UvicornServer(uvicorn.Config(MagicMock(), port=8080, host='127.0.0.1')) + assert thread_mock.call_count == 0 + + s.install_signal_handlers() + # Original implementation starts a thread - make sure that's not the case + assert thread_mock.call_count == 0 + + # Fake started to avoid sleeping forever + s.started = True + s.run_in_thread() + assert thread_mock.call_count == 1 + + s.cleanup() + assert s.should_exit is True + + def test_api_run(default_conf, mocker, caplog): default_conf.update({"api_server": {"enabled": True, "listen_ip_address": "127.0.0.1", @@ -193,20 +239,19 @@ def test_api_run(default_conf, mocker, caplog): "password": "testPass", }}) 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) + mocker.patch('freqtrade.rpc.api_server.webserver.UvicornServer', server_mock) apiserver = ApiServer(RPC(get_patched_freqtradebot(mocker, default_conf)), 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 apiserver._config == default_conf + apiserver.start_api() + assert server_mock.call_count == 2 + assert server_mock.call_args_list[0][0][0].host == "127.0.0.1" + assert server_mock.call_args_list[0][0][0].port == 8080 + assert isinstance(server_mock.call_args_list[0][0][0].app, FastAPI) assert log_has("Starting HTTP Server at 127.0.0.1:8080", caplog) assert log_has("Starting Local Rest Server.", caplog) @@ -219,12 +264,12 @@ def test_api_run(default_conf, mocker, caplog): "listen_port": 8089, "password": "", }}) - apiserver.run() + apiserver.start_api() 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 server_mock.call_args_list[0][0][0].host == "0.0.0.0" + assert server_mock.call_args_list[0][0][0].port == 8089 + assert isinstance(server_mock.call_args_list[0][0][0].app, FastAPI) assert log_has("Starting HTTP Server at 0.0.0.0:8089", caplog) assert log_has("Starting Local Rest Server.", caplog) assert log_has("SECURITY WARNING - Local Rest Server listening to external connections", @@ -233,11 +278,13 @@ def test_api_run(default_conf, mocker, caplog): "e.g 127.0.0.1 in config.json", caplog) assert log_has("SECURITY WARNING - No password for local REST Server defined. " "Please make sure that this is intentional!", caplog) + assert log_has_re("SECURITY WARNING - `jwt_secret_key` seems to be default.*", caplog) # Test crashing flask caplog.clear() - mocker.patch('freqtrade.rpc.api_server.make_server', MagicMock(side_effect=Exception)) - apiserver.run() + mocker.patch('freqtrade.rpc.api_server.webserver.UvicornServer', + MagicMock(side_effect=Exception)) + apiserver.start_api() assert log_has("Api server failed to start.", caplog) @@ -249,17 +296,15 @@ def test_api_cleanup(default_conf, mocker, caplog): "password": "testPass", }}) 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()) + + server_mock = MagicMock() + server_mock.cleanup = MagicMock() + mocker.patch('freqtrade.rpc.api_server.webserver.UvicornServer', server_mock) apiserver = ApiServer(RPC(get_patched_freqtradebot(mocker, default_conf)), 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 apiserver._server.cleanup.call_count == 1 assert log_has("Stopping API Server", caplog) @@ -268,7 +313,7 @@ def test_api_reloadconf(botclient): rc = client_post(client, f"{BASE_URI}/reload_config") assert_response(rc) - assert rc.json == {'status': 'Reloading config ...'} + assert rc.json() == {'status': 'Reloading config ...'} assert ftbot.state == State.RELOAD_CONFIG @@ -278,7 +323,7 @@ def test_api_stopbuy(botclient): rc = client_post(client, f"{BASE_URI}/stopbuy") assert_response(rc) - assert rc.json == {'status': 'No more buy will occur from now. Run /reload_config to reset.'} + assert rc.json() == {'status': 'No more buy will occur from now. Run /reload_config to reset.'} assert ftbot.config['max_open_trades'] == 0 @@ -293,9 +338,9 @@ def test_api_balance(botclient, mocker, rpc_balance): 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] == { + assert "currencies" in rc.json() + assert len(rc.json()["currencies"]) == 5 + assert rc.json()['currencies'][0] == { 'currency': 'BTC', 'free': 12.0, 'balance': 12.0, @@ -318,15 +363,19 @@ def test_api_count(botclient, mocker, ticker, fee, markets): rc = client_get(client, f"{BASE_URI}/count") assert_response(rc) - assert rc.json["current"] == 0 - assert rc.json["max"] == 1.0 + assert rc.json()["current"] == 0 + assert rc.json()["max"] == 1 # Create some test data ftbot.enter_positions() rc = client_get(client, f"{BASE_URI}/count") assert_response(rc) - assert rc.json["current"] == 1.0 - assert rc.json["max"] == 1.0 + assert rc.json()["current"] == 1 + assert rc.json()["max"] == 1 + + ftbot.config['max_open_trades'] = float('inf') + rc = client_get(client, f"{BASE_URI}/count") + assert rc.json()["max"] == -1 def test_api_locks(botclient): @@ -335,10 +384,10 @@ def test_api_locks(botclient): rc = client_get(client, f"{BASE_URI}/locks") assert_response(rc) - assert 'locks' in rc.json + assert 'locks' in rc.json() - assert rc.json['lock_count'] == 0 - assert rc.json['lock_count'] == len(rc.json['locks']) + assert rc.json()['lock_count'] == 0 + assert rc.json()['lock_count'] == len(rc.json()['locks']) PairLocks.lock_pair('ETH/BTC', datetime.now(timezone.utc) + timedelta(minutes=4), 'randreason') PairLocks.lock_pair('XRP/BTC', datetime.now(timezone.utc) + timedelta(minutes=20), 'deadbeef') @@ -346,11 +395,11 @@ def test_api_locks(botclient): rc = client_get(client, f"{BASE_URI}/locks") assert_response(rc) - assert rc.json['lock_count'] == 2 - assert rc.json['lock_count'] == len(rc.json['locks']) - assert 'ETH/BTC' in (rc.json['locks'][0]['pair'], rc.json['locks'][1]['pair']) - assert 'randreason' in (rc.json['locks'][0]['reason'], rc.json['locks'][1]['reason']) - assert 'deadbeef' in (rc.json['locks'][0]['reason'], rc.json['locks'][1]['reason']) + assert rc.json()['lock_count'] == 2 + assert rc.json()['lock_count'] == len(rc.json()['locks']) + assert 'ETH/BTC' in (rc.json()['locks'][0]['pair'], rc.json()['locks'][1]['pair']) + assert 'randreason' in (rc.json()['locks'][0]['reason'], rc.json()['locks'][1]['reason']) + assert 'deadbeef' in (rc.json()['locks'][0]['reason'], rc.json()['locks'][1]['reason']) def test_api_show_config(botclient, mocker): @@ -359,15 +408,16 @@ def test_api_show_config(botclient, mocker): rc = client_get(client, f"{BASE_URI}/show_config") assert_response(rc) - assert 'dry_run' in rc.json - assert rc.json['exchange'] == 'bittrex' - assert rc.json['timeframe'] == '5m' - assert rc.json['timeframe_ms'] == 300000 - assert rc.json['timeframe_min'] == 5 - assert rc.json['state'] == 'running' - assert not rc.json['trailing_stop'] - assert 'bid_strategy' in rc.json - assert 'ask_strategy' in rc.json + assert 'dry_run' in rc.json() + assert rc.json()['exchange'] == 'bittrex' + assert rc.json()['timeframe'] == '5m' + assert rc.json()['timeframe_ms'] == 300000 + assert rc.json()['timeframe_min'] == 5 + assert rc.json()['state'] == 'running' + assert rc.json()['bot_name'] == 'freqtrade' + assert not rc.json()['trailing_stop'] + assert 'bid_strategy' in rc.json() + assert 'ask_strategy' in rc.json() def test_api_daily(botclient, mocker, ticker, fee, markets): @@ -382,10 +432,10 @@ def test_api_daily(botclient, mocker, ticker, fee, markets): ) rc = client_get(client, f"{BASE_URI}/daily") assert_response(rc) - assert len(rc.json['data']) == 7 - assert rc.json['stake_currency'] == 'BTC' - assert rc.json['fiat_display_currency'] == 'USD' - assert rc.json['data'][0]['date'] == str(datetime.utcnow().date()) + assert len(rc.json()['data']) == 7 + assert rc.json()['stake_currency'] == 'BTC' + assert rc.json()['fiat_display_currency'] == 'USD' + assert rc.json()['data'][0]['date'] == str(datetime.utcnow().date()) def test_api_trades(botclient, mocker, fee, markets): @@ -397,19 +447,20 @@ def test_api_trades(botclient, mocker, fee, markets): ) rc = client_get(client, f"{BASE_URI}/trades") assert_response(rc) - assert len(rc.json) == 2 - assert rc.json['trades_count'] == 0 + assert len(rc.json()) == 2 + assert rc.json()['trades_count'] == 0 create_mock_trades(fee) + Trade.session.flush() rc = client_get(client, f"{BASE_URI}/trades") assert_response(rc) - assert len(rc.json['trades']) == 2 - assert rc.json['trades_count'] == 2 + assert len(rc.json()['trades']) == 2 + assert rc.json()['trades_count'] == 2 rc = client_get(client, f"{BASE_URI}/trades?limit=1") assert_response(rc) - assert len(rc.json['trades']) == 1 - assert rc.json['trades_count'] == 1 + assert len(rc.json()['trades']) == 1 + assert rc.json()['trades_count'] == 1 def test_api_delete_trade(botclient, mocker, fee, markets): @@ -428,6 +479,7 @@ def test_api_delete_trade(botclient, mocker, fee, markets): assert_response(rc, 502) create_mock_trades(fee) + Trade.session.flush() ftbot.strategy.order_types['stoploss_on_exchange'] = True trades = Trade.query.all() trades[1].stoploss_order_id = '1234' @@ -435,7 +487,7 @@ def test_api_delete_trade(botclient, mocker, fee, markets): rc = client_delete(client, f"{BASE_URI}/trades/1") assert_response(rc) - assert rc.json['result_msg'] == 'Deleted trade 1. Closed 1 open orders.' + assert rc.json()['result_msg'] == 'Deleted trade 1. Closed 1 open orders.' assert len(trades) - 1 == len(Trade.query.all()) assert cancel_mock.call_count == 1 @@ -448,7 +500,7 @@ def test_api_delete_trade(botclient, mocker, fee, markets): assert len(trades) - 1 == len(Trade.query.all()) rc = client_delete(client, f"{BASE_URI}/trades/2") assert_response(rc) - assert rc.json['result_msg'] == 'Deleted trade 2. Closed 2 open orders.' + assert rc.json()['result_msg'] == 'Deleted trade 2. Closed 2 open orders.' assert len(trades) - 2 == len(Trade.query.all()) assert stoploss_mock.call_count == 1 @@ -457,28 +509,32 @@ def test_api_logs(botclient): ftbot, client = botclient rc = client_get(client, f"{BASE_URI}/logs") assert_response(rc) - assert len(rc.json) == 2 - assert 'logs' in rc.json + assert len(rc.json()) == 2 + assert 'logs' in rc.json() # Using a fixed comparison here would make this test fail! - assert rc.json['log_count'] > 1 - assert len(rc.json['logs']) == rc.json['log_count'] + assert rc.json()['log_count'] > 1 + assert len(rc.json()['logs']) == rc.json()['log_count'] - assert isinstance(rc.json['logs'][0], list) + assert isinstance(rc.json()['logs'][0], list) # date - assert isinstance(rc.json['logs'][0][0], str) + assert isinstance(rc.json()['logs'][0][0], str) # created_timestamp - assert isinstance(rc.json['logs'][0][1], float) - assert isinstance(rc.json['logs'][0][2], str) - assert isinstance(rc.json['logs'][0][3], str) - assert isinstance(rc.json['logs'][0][4], str) + assert isinstance(rc.json()['logs'][0][1], float) + assert isinstance(rc.json()['logs'][0][2], str) + assert isinstance(rc.json()['logs'][0][3], str) + assert isinstance(rc.json()['logs'][0][4], str) - rc = client_get(client, f"{BASE_URI}/logs?limit=5") - assert_response(rc) - assert len(rc.json) == 2 - assert 'logs' in rc.json + rc1 = client_get(client, f"{BASE_URI}/logs?limit=5") + assert_response(rc1) + assert len(rc1.json()) == 2 + assert 'logs' in rc1.json() # Using a fixed comparison here would make this test fail! - assert rc.json['log_count'] == 5 - assert len(rc.json['logs']) == rc.json['log_count'] + if rc1.json()['log_count'] < 5: + # Help debugging random test failure + print(f"rc={rc.json()}") + print(f"rc1={rc1.json()}") + assert rc1.json()['log_count'] == 5 + assert len(rc1.json()['logs']) == rc1.json()['log_count'] def test_api_edge_disabled(botclient, mocker, ticker, fee, markets): @@ -493,7 +549,7 @@ def test_api_edge_disabled(botclient, mocker, ticker, fee, markets): ) rc = client_get(client, f"{BASE_URI}/edge") assert_response(rc, 502) - assert rc.json == {"error": "Error querying _edge: Edge is not enabled."} + assert rc.json() == {"error": "Error querying /api/v1/edge: Edge is not enabled."} @pytest.mark.usefixtures("init_persistence") @@ -510,7 +566,7 @@ def test_api_profit(botclient, mocker, ticker, fee, markets, limit_buy_order, li rc = client_get(client, f"{BASE_URI}/profit") assert_response(rc, 200) - assert rc.json['trade_count'] == 0 + assert rc.json()['trade_count'] == 0 ftbot.enter_positions() trade = Trade.query.first() @@ -520,9 +576,9 @@ def test_api_profit(botclient, mocker, ticker, fee, markets, limit_buy_order, li rc = client_get(client, f"{BASE_URI}/profit") assert_response(rc, 200) # One open trade - assert rc.json['trade_count'] == 1 - assert rc.json['best_pair'] == '' - assert rc.json['best_rate'] == 0 + assert rc.json()['trade_count'] == 1 + assert rc.json()['best_pair'] == '' + assert rc.json()['best_rate'] == 0 trade = Trade.query.first() trade.update(limit_sell_order) @@ -532,32 +588,32 @@ def test_api_profit(botclient, mocker, ticker, fee, markets, limit_buy_order, li 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', - 'first_trade_timestamp': ANY, - 'latest_trade_date': 'just now', - 'latest_trade_timestamp': ANY, - 'profit_all_coin': 6.217e-05, - 'profit_all_fiat': 0.76748865, - 'profit_all_percent': 6.2, - 'profit_all_percent_mean': 6.2, - 'profit_all_ratio_mean': 0.06201058, - 'profit_all_percent_sum': 6.2, - 'profit_all_ratio_sum': 0.06201058, - 'profit_closed_coin': 6.217e-05, - 'profit_closed_fiat': 0.76748865, - 'profit_closed_percent': 6.2, - 'profit_closed_ratio_mean': 0.06201058, - 'profit_closed_percent_mean': 6.2, - 'profit_closed_ratio_sum': 0.06201058, - 'profit_closed_percent_sum': 6.2, - 'trade_count': 1, - 'closed_trade_count': 1, - 'winning_trades': 1, - 'losing_trades': 0, - } + assert rc.json() == {'avg_duration': '0:00:00', + 'best_pair': 'ETH/BTC', + 'best_rate': 6.2, + 'first_trade_date': 'just now', + 'first_trade_timestamp': ANY, + 'latest_trade_date': 'just now', + 'latest_trade_timestamp': ANY, + 'profit_all_coin': 6.217e-05, + 'profit_all_fiat': 0.76748865, + 'profit_all_percent': 6.2, + 'profit_all_percent_mean': 6.2, + 'profit_all_ratio_mean': 0.06201058, + 'profit_all_percent_sum': 6.2, + 'profit_all_ratio_sum': 0.06201058, + 'profit_closed_coin': 6.217e-05, + 'profit_closed_fiat': 0.76748865, + 'profit_closed_percent': 6.2, + 'profit_closed_ratio_mean': 0.06201058, + 'profit_closed_percent_mean': 6.2, + 'profit_closed_ratio_sum': 0.06201058, + 'profit_closed_percent_sum': 6.2, + 'trade_count': 1, + 'closed_trade_count': 1, + 'winning_trades': 1, + 'losing_trades': 0, + } @pytest.mark.usefixtures("init_persistence") @@ -574,19 +630,19 @@ def test_api_stats(botclient, mocker, ticker, fee, markets,): rc = client_get(client, f"{BASE_URI}/stats") assert_response(rc, 200) - assert 'durations' in rc.json - assert 'sell_reasons' in rc.json + assert 'durations' in rc.json() + assert 'sell_reasons' in rc.json() create_mock_trades(fee) rc = client_get(client, f"{BASE_URI}/stats") assert_response(rc, 200) - assert 'durations' in rc.json - assert 'sell_reasons' in rc.json + assert 'durations' in rc.json() + assert 'sell_reasons' in rc.json() - assert 'wins' in rc.json['durations'] - assert 'losses' in rc.json['durations'] - assert 'draws' in rc.json['durations'] + assert 'wins' in rc.json()['durations'] + assert 'losses' in rc.json()['durations'] + assert 'draws' in rc.json()['durations'] def test_api_performance(botclient, mocker, ticker, fee): @@ -627,9 +683,9 @@ def test_api_performance(botclient, mocker, ticker, fee): 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}] + 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): @@ -645,76 +701,78 @@ def test_api_status(botclient, mocker, ticker, fee, markets): rc = client_get(client, f"{BASE_URI}/status") assert_response(rc, 200) - assert rc.json == [] + assert rc.json() == [] ftbot.enter_positions() trades = Trade.get_open_trades() trades[0].open_order_id = None ftbot.exit_positions(trades) + Trade.session.flush() rc = client_get(client, f"{BASE_URI}/status") assert_response(rc) - assert len(rc.json) == 1 - assert rc.json == [{'amount': 91.07468123, - 'amount_requested': 91.07468123, - 'base_currency': 'BTC', - 'close_date': None, - 'close_date_hum': None, - 'close_timestamp': None, - 'close_profit': None, - 'close_profit_pct': None, - 'close_profit_abs': None, - 'close_rate': None, - 'current_profit': -0.00408133, - 'current_profit_pct': -0.41, - 'current_profit_abs': -4.09e-06, - 'profit_ratio': -0.00408133, - 'profit_pct': -0.41, - 'profit_abs': -4.09e-06, - 'current_rate': 1.099e-05, - 'open_date': ANY, - 'open_date_hum': 'just now', - 'open_timestamp': ANY, - 'open_order': None, - 'open_rate': 1.098e-05, - 'pair': 'ETH/BTC', - 'stake_amount': 0.001, - 'stop_loss_abs': 9.882e-06, - 'stop_loss_pct': -10.0, - 'stop_loss_ratio': -0.1, - 'stoploss_order_id': None, - 'stoploss_last_update': ANY, - 'stoploss_last_update_timestamp': ANY, - 'initial_stop_loss_abs': 9.882e-06, - 'initial_stop_loss_pct': -10.0, - 'initial_stop_loss_ratio': -0.1, - 'stoploss_current_dist': -1.1080000000000002e-06, - 'stoploss_current_dist_ratio': -0.10081893, - 'stoploss_current_dist_pct': -10.08, - 'stoploss_entry_dist': -0.00010475, - 'stoploss_entry_dist_ratio': -0.10448878, - 'trade_id': 1, - 'close_rate_requested': None, - 'current_rate': 1.099e-05, - 'fee_close': 0.0025, - 'fee_close_cost': None, - 'fee_close_currency': None, - 'fee_open': 0.0025, - 'fee_open_cost': None, - 'fee_open_currency': None, - 'open_date': ANY, - 'is_open': True, - 'max_rate': 1.099e-05, - 'min_rate': 1.098e-05, - 'open_order_id': None, - 'open_rate_requested': 1.098e-05, - 'open_trade_value': 0.0010025, - 'sell_reason': None, - 'sell_order_status': None, - 'strategy': 'DefaultStrategy', - 'timeframe': 5, - 'exchange': 'bittrex', - }] + assert len(rc.json()) == 1 + assert rc.json() == [{ + 'amount': 91.07468123, + 'amount_requested': 91.07468123, + 'base_currency': 'BTC', + 'close_date': None, + 'close_date_hum': None, + 'close_timestamp': None, + 'close_profit': None, + 'close_profit_pct': None, + 'close_profit_abs': None, + 'close_rate': None, + 'current_profit': -0.00408133, + 'current_profit_pct': -0.41, + 'current_profit_abs': -4.09e-06, + 'profit_ratio': -0.00408133, + 'profit_pct': -0.41, + 'profit_abs': -4.09e-06, + 'current_rate': 1.099e-05, + 'open_date': ANY, + 'open_date_hum': 'just now', + 'open_timestamp': ANY, + 'open_order': None, + 'open_rate': 1.098e-05, + 'pair': 'ETH/BTC', + 'stake_amount': 0.001, + 'stop_loss_abs': 9.882e-06, + 'stop_loss_pct': -10.0, + 'stop_loss_ratio': -0.1, + 'stoploss_order_id': None, + 'stoploss_last_update': ANY, + 'stoploss_last_update_timestamp': ANY, + 'initial_stop_loss_abs': 9.882e-06, + 'initial_stop_loss_pct': -10.0, + 'initial_stop_loss_ratio': -0.1, + 'stoploss_current_dist': -1.1080000000000002e-06, + 'stoploss_current_dist_ratio': -0.10081893, + 'stoploss_current_dist_pct': -10.08, + 'stoploss_entry_dist': -0.00010475, + 'stoploss_entry_dist_ratio': -0.10448878, + 'trade_id': 1, + 'close_rate_requested': None, + 'current_rate': 1.099e-05, + 'fee_close': 0.0025, + 'fee_close_cost': None, + 'fee_close_currency': None, + 'fee_open': 0.0025, + 'fee_open_cost': None, + 'fee_open_currency': None, + 'open_date': ANY, + 'is_open': True, + 'max_rate': 1.099e-05, + 'min_rate': 1.098e-05, + 'open_order_id': None, + 'open_rate_requested': 1.098e-05, + 'open_trade_value': 0.0010025, + 'sell_reason': None, + 'sell_order_status': None, + 'strategy': 'DefaultStrategy', + 'timeframe': 5, + 'exchange': 'bittrex', + }] def test_api_version(botclient): @@ -722,7 +780,7 @@ def test_api_version(botclient): rc = client_get(client, f"{BASE_URI}/version") assert_response(rc) - assert rc.json == {"version": __version__} + assert rc.json() == {"version": __version__} def test_api_blacklist(botclient, mocker): @@ -730,21 +788,34 @@ def test_api_blacklist(botclient, mocker): rc = client_get(client, f"{BASE_URI}/blacklist") assert_response(rc) - assert rc.json == {"blacklist": ["DOGE/BTC", "HOT/BTC"], - "length": 2, - "method": ["StaticPairList"], - "errors": {}, - } + # DOGE and HOT are not in the markets mock! + assert rc.json() == {"blacklist": ["DOGE/BTC", "HOT/BTC"], + "blacklist_expanded": [], + "length": 2, + "method": ["StaticPairList"], + "errors": {}, + } # 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"], - "errors": {}, - } + assert rc.json() == {"blacklist": ["DOGE/BTC", "HOT/BTC", "ETH/BTC"], + "blacklist_expanded": ["ETH/BTC"], + "length": 3, + "method": ["StaticPairList"], + "errors": {}, + } + + rc = client_post(client, f"{BASE_URI}/blacklist", + data='{"blacklist": ["XRP/.*"]}') + assert_response(rc) + assert rc.json() == {"blacklist": ["DOGE/BTC", "HOT/BTC", "ETH/BTC", "XRP/.*"], + "blacklist_expanded": ["ETH/BTC", "XRP/BTC"], + "length": 4, + "method": ["StaticPairList"], + "errors": {}, + } def test_api_whitelist(botclient): @@ -752,9 +823,11 @@ def test_api_whitelist(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"]} + assert rc.json() == { + "whitelist": ['ETH/BTC', 'LTC/BTC', 'XRP/BTC', 'NEO/BTC'], + "length": 4, + "method": ["StaticPairList"] + } def test_api_forcebuy(botclient, mocker, fee): @@ -763,7 +836,7 @@ def test_api_forcebuy(botclient, mocker, fee): 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."} + assert rc.json() == {"error": "Error querying /api/v1/forcebuy: Forcebuy not enabled."} # enable forcebuy ftbot.config['forcebuy_enable'] = True @@ -773,9 +846,9 @@ def test_api_forcebuy(botclient, mocker, fee): rc = client_post(client, f"{BASE_URI}/forcebuy", data='{"pair": "ETH/BTC"}') assert_response(rc) - assert rc.json == {"status": "Error buying pair ETH/BTC."} + assert rc.json() == {"status": "Error buying pair ETH/BTC."} - # Test creating trae + # Test creating trade fbuy_mock = MagicMock(return_value=Trade( pair='ETH/ETH', amount=1, @@ -789,59 +862,63 @@ def test_api_forcebuy(botclient, mocker, fee): fee_close=fee.return_value, fee_open=fee.return_value, close_rate=0.265441, + id=22, + timeframe=5, + strategy="DefaultStrategy" )) 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, - 'amount_requested': 1, - 'trade_id': None, - 'close_date': None, - 'close_date_hum': None, - 'close_timestamp': None, - 'close_rate': 0.265441, - 'open_date': ANY, - 'open_date_hum': 'just now', - 'open_timestamp': ANY, - 'open_rate': 0.245441, - 'pair': 'ETH/ETH', - 'stake_amount': 1, - 'stop_loss_abs': None, - 'stop_loss_pct': None, - 'stop_loss_ratio': None, - 'stoploss_order_id': None, - 'stoploss_last_update': None, - 'stoploss_last_update_timestamp': None, - 'initial_stop_loss_abs': None, - 'initial_stop_loss_pct': None, - 'initial_stop_loss_ratio': None, - 'close_profit': None, - 'close_profit_pct': None, - 'close_profit_abs': None, - 'close_rate_requested': None, - 'profit_ratio': None, - 'profit_pct': None, - 'profit_abs': None, - 'fee_close': 0.0025, - 'fee_close_cost': None, - 'fee_close_currency': None, - 'fee_open': 0.0025, - 'fee_open_cost': None, - 'fee_open_currency': None, - 'is_open': False, - 'max_rate': None, - 'min_rate': None, - 'open_order_id': '123456', - 'open_rate_requested': None, - 'open_trade_value': 0.24605460, - 'sell_reason': None, - 'sell_order_status': None, - 'strategy': None, - 'timeframe': None, - 'exchange': 'bittrex', - } + assert rc.json() == { + 'amount': 1, + 'amount_requested': 1, + 'trade_id': 22, + 'close_date': None, + 'close_date_hum': None, + 'close_timestamp': None, + 'close_rate': 0.265441, + 'open_date': ANY, + 'open_date_hum': 'just now', + 'open_timestamp': ANY, + 'open_rate': 0.245441, + 'pair': 'ETH/ETH', + 'stake_amount': 1, + 'stop_loss_abs': None, + 'stop_loss_pct': None, + 'stop_loss_ratio': None, + 'stoploss_order_id': None, + 'stoploss_last_update': None, + 'stoploss_last_update_timestamp': None, + 'initial_stop_loss_abs': None, + 'initial_stop_loss_pct': None, + 'initial_stop_loss_ratio': None, + 'close_profit': None, + 'close_profit_pct': None, + 'close_profit_abs': None, + 'close_rate_requested': None, + 'profit_ratio': None, + 'profit_pct': None, + 'profit_abs': None, + 'fee_close': 0.0025, + 'fee_close_cost': None, + 'fee_close_currency': None, + 'fee_open': 0.0025, + 'fee_open_cost': None, + 'fee_open_currency': None, + 'is_open': False, + 'max_rate': None, + 'min_rate': None, + 'open_order_id': '123456', + 'open_rate_requested': None, + 'open_trade_value': 0.24605460, + 'sell_reason': None, + 'sell_order_status': None, + 'strategy': 'DefaultStrategy', + 'timeframe': 5, + 'exchange': 'bittrex', + } def test_api_forcesell(botclient, mocker, ticker, fee, markets): @@ -858,14 +935,14 @@ def test_api_forcesell(botclient, mocker, ticker, fee, markets): rc = client_post(client, f"{BASE_URI}/forcesell", data='{"tradeid": "1"}') assert_response(rc, 502) - assert rc.json == {"error": "Error querying _forcesell: invalid argument"} + assert rc.json() == {"error": "Error querying /api/v1/forcesell: invalid argument"} ftbot.enter_positions() rc = client_post(client, f"{BASE_URI}/forcesell", data='{"tradeid": "1"}') assert_response(rc) - assert rc.json == {'result': 'Created sell order for trade 1.'} + assert rc.json() == {'result': 'Created sell order for trade 1.'} def test_api_pair_candles(botclient, ohlcv_history): @@ -876,22 +953,22 @@ def test_api_pair_candles(botclient, ohlcv_history): # No pair rc = client_get(client, f"{BASE_URI}/pair_candles?limit={amount}&timeframe={timeframe}") - assert_response(rc, 400) + assert_response(rc, 422) # No timeframe rc = client_get(client, f"{BASE_URI}/pair_candles?pair=XRP%2FBTC") - assert_response(rc, 400) + assert_response(rc, 422) rc = client_get(client, f"{BASE_URI}/pair_candles?limit={amount}&pair=XRP%2FBTC&timeframe={timeframe}") assert_response(rc) - assert 'columns' in rc.json - assert 'data_start_ts' in rc.json - assert 'data_start' in rc.json - assert 'data_stop' in rc.json - assert 'data_stop_ts' in rc.json - assert len(rc.json['data']) == 0 + assert 'columns' in rc.json() + assert 'data_start_ts' in rc.json() + assert 'data_start' in rc.json() + assert 'data_stop' in rc.json() + assert 'data_stop_ts' in rc.json() + assert len(rc.json()['data']) == 0 ohlcv_history['sma'] = ohlcv_history['close'].rolling(2).mean() ohlcv_history['buy'] = 0 ohlcv_history.loc[1, 'buy'] = 1 @@ -902,34 +979,34 @@ def test_api_pair_candles(botclient, ohlcv_history): rc = client_get(client, f"{BASE_URI}/pair_candles?limit={amount}&pair=XRP%2FBTC&timeframe={timeframe}") assert_response(rc) - assert 'strategy' in rc.json - assert rc.json['strategy'] == 'DefaultStrategy' - assert 'columns' in rc.json - assert 'data_start_ts' in rc.json - assert 'data_start' in rc.json - assert 'data_stop' in rc.json - assert 'data_stop_ts' in rc.json - assert rc.json['data_start'] == '2017-11-26 08:50:00+00:00' - assert rc.json['data_start_ts'] == 1511686200000 - assert rc.json['data_stop'] == '2017-11-26 09:00:00+00:00' - assert rc.json['data_stop_ts'] == 1511686800000 - assert isinstance(rc.json['columns'], list) - assert rc.json['columns'] == ['date', 'open', 'high', - 'low', 'close', 'volume', 'sma', 'buy', 'sell', - '__date_ts', '_buy_signal_open', '_sell_signal_open'] - assert 'pair' in rc.json - assert rc.json['pair'] == 'XRP/BTC' + assert 'strategy' in rc.json() + assert rc.json()['strategy'] == 'DefaultStrategy' + assert 'columns' in rc.json() + assert 'data_start_ts' in rc.json() + assert 'data_start' in rc.json() + assert 'data_stop' in rc.json() + assert 'data_stop_ts' in rc.json() + assert rc.json()['data_start'] == '2017-11-26 08:50:00+00:00' + assert rc.json()['data_start_ts'] == 1511686200000 + assert rc.json()['data_stop'] == '2017-11-26 09:00:00+00:00' + assert rc.json()['data_stop_ts'] == 1511686800000 + assert isinstance(rc.json()['columns'], list) + assert rc.json()['columns'] == ['date', 'open', 'high', + 'low', 'close', 'volume', 'sma', 'buy', 'sell', + '__date_ts', '_buy_signal_open', '_sell_signal_open'] + assert 'pair' in rc.json() + assert rc.json()['pair'] == 'XRP/BTC' - assert 'data' in rc.json - assert len(rc.json['data']) == amount + assert 'data' in rc.json() + assert len(rc.json()['data']) == amount - assert (rc.json['data'] == + assert (rc.json()['data'] == [['2017-11-26 08:50:00', 8.794e-05, 8.948e-05, 8.794e-05, 8.88e-05, 0.0877869, None, 0, 0, 1511686200000, None, None], ['2017-11-26 08:55:00', 8.88e-05, 8.942e-05, 8.88e-05, 8.893e-05, 0.05874751, 8.886500000000001e-05, 1, 0, 1511686500000, 8.88e-05, None], ['2017-11-26 09:00:00', 8.891e-05, 8.893e-05, 8.875e-05, 8.877e-05, - 0.7039405, 8.885000000000002e-05, 0, 0, 1511686800000, None, None] + 0.7039405, 8.885e-05, 0, 0, 1511686800000, None, None] ]) @@ -942,41 +1019,49 @@ def test_api_pair_history(botclient, ohlcv_history): rc = client_get(client, f"{BASE_URI}/pair_history?timeframe={timeframe}" "&timerange=20180111-20180112&strategy=DefaultStrategy") - assert_response(rc, 400) + assert_response(rc, 422) # No Timeframe rc = client_get(client, f"{BASE_URI}/pair_history?pair=UNITTEST%2FBTC" "&timerange=20180111-20180112&strategy=DefaultStrategy") - assert_response(rc, 400) + assert_response(rc, 422) # No timerange rc = client_get(client, f"{BASE_URI}/pair_history?pair=UNITTEST%2FBTC&timeframe={timeframe}" "&strategy=DefaultStrategy") - assert_response(rc, 400) + assert_response(rc, 422) # No strategy rc = client_get(client, f"{BASE_URI}/pair_history?pair=UNITTEST%2FBTC&timeframe={timeframe}" "&timerange=20180111-20180112") - assert_response(rc, 400) + assert_response(rc, 422) # Working rc = client_get(client, f"{BASE_URI}/pair_history?pair=UNITTEST%2FBTC&timeframe={timeframe}" "&timerange=20180111-20180112&strategy=DefaultStrategy") assert_response(rc, 200) - assert rc.json['length'] == 289 - assert len(rc.json['data']) == rc.json['length'] - assert 'columns' in rc.json - assert 'data' in rc.json - assert rc.json['pair'] == 'UNITTEST/BTC' - assert rc.json['strategy'] == 'DefaultStrategy' - assert rc.json['data_start'] == '2018-01-11 00:00:00+00:00' - assert rc.json['data_start_ts'] == 1515628800000 - assert rc.json['data_stop'] == '2018-01-12 00:00:00+00:00' - assert rc.json['data_stop_ts'] == 1515715200000 + assert rc.json()['length'] == 289 + assert len(rc.json()['data']) == rc.json()['length'] + assert 'columns' in rc.json() + assert 'data' in rc.json() + assert rc.json()['pair'] == 'UNITTEST/BTC' + assert rc.json()['strategy'] == 'DefaultStrategy' + assert rc.json()['data_start'] == '2018-01-11 00:00:00+00:00' + assert rc.json()['data_start_ts'] == 1515628800000 + assert rc.json()['data_stop'] == '2018-01-12 00:00:00+00:00' + assert rc.json()['data_stop_ts'] == 1515715200000 + + # No data found + rc = client_get(client, + f"{BASE_URI}/pair_history?pair=UNITTEST%2FBTC&timeframe={timeframe}" + "&timerange=20200111-20200112&strategy=DefaultStrategy") + assert_response(rc, 502) + assert rc.json()['error'] == ("Error querying /api/v1/pair_history: " + "No data for UNITTEST/BTC, 5m in 20200111-20200112 found.") def test_api_plot_config(botclient): @@ -984,14 +1069,14 @@ def test_api_plot_config(botclient): rc = client_get(client, f"{BASE_URI}/plot_config") assert_response(rc) - assert rc.json == {} + assert rc.json() == {} ftbot.strategy.plot_config = {'main_plot': {'sma': {}}, 'subplots': {'RSI': {'rsi': {'color': 'red'}}}} rc = client_get(client, f"{BASE_URI}/plot_config") assert_response(rc) - assert rc.json == ftbot.strategy.plot_config - assert isinstance(rc.json['main_plot'], dict) + assert rc.json() == ftbot.strategy.plot_config + assert isinstance(rc.json()['main_plot'], dict) def test_api_strategies(botclient): @@ -1000,7 +1085,7 @@ def test_api_strategies(botclient): rc = client_get(client, f"{BASE_URI}/strategies") assert_response(rc) - assert rc.json == {'strategies': ['DefaultStrategy', 'TestStrategyLegacy']} + assert rc.json() == {'strategies': ['DefaultStrategy', 'TestStrategyLegacy']} def test_api_strategy(botclient): @@ -1009,10 +1094,10 @@ def test_api_strategy(botclient): rc = client_get(client, f"{BASE_URI}/strategy/DefaultStrategy") assert_response(rc) - assert rc.json['strategy'] == 'DefaultStrategy' + assert rc.json()['strategy'] == 'DefaultStrategy' data = (Path(__file__).parents[1] / "strategy/strats/default_strategy.py").read_text() - assert rc.json['code'] == data + assert rc.json()['code'] == data rc = client_get(client, f"{BASE_URI}/strategy/NoStrat") assert_response(rc, 404) @@ -1024,21 +1109,21 @@ def test_list_available_pairs(botclient): rc = client_get(client, f"{BASE_URI}/available_pairs") assert_response(rc) - assert rc.json['length'] == 12 - assert isinstance(rc.json['pairs'], list) + assert rc.json()['length'] == 12 + assert isinstance(rc.json()['pairs'], list) rc = client_get(client, f"{BASE_URI}/available_pairs?timeframe=5m") assert_response(rc) - assert rc.json['length'] == 12 + assert rc.json()['length'] == 12 rc = client_get(client, f"{BASE_URI}/available_pairs?stake_currency=ETH") assert_response(rc) - assert rc.json['length'] == 1 - assert rc.json['pairs'] == ['XRP/ETH'] - assert len(rc.json['pair_interval']) == 2 + assert rc.json()['length'] == 1 + assert rc.json()['pairs'] == ['XRP/ETH'] + assert len(rc.json()['pair_interval']) == 2 rc = client_get(client, f"{BASE_URI}/available_pairs?stake_currency=ETH&timeframe=5m") assert_response(rc) - assert rc.json['length'] == 1 - assert rc.json['pairs'] == ['XRP/ETH'] - assert len(rc.json['pair_interval']) == 1 + assert rc.json()['length'] == 1 + assert rc.json()['pairs'] == ['XRP/ETH'] + assert len(rc.json()['pair_interval']) == 1 diff --git a/tests/rpc/test_rpc_manager.py b/tests/rpc/test_rpc_manager.py index 06706120f..3068e9764 100644 --- a/tests/rpc/test_rpc_manager.py +++ b/tests/rpc/test_rpc_manager.py @@ -160,7 +160,7 @@ def test_startupmessages_telegram_enabled(mocker, default_conf, caplog) -> None: 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) + mocker.patch('freqtrade.rpc.api_server.ApiServer.start_api', run_mock) default_conf['telegram']['enabled'] = False rpc_manager = RPCManager(get_patched_freqtradebot(mocker, default_conf)) @@ -172,7 +172,7 @@ def test_init_apiserver_disabled(mocker, default_conf, caplog) -> None: 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) + mocker.patch('freqtrade.rpc.api_server.ApiServer.start_api', run_mock) default_conf["telegram"]["enabled"] = False default_conf["api_server"] = {"enabled": True, diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 97b9e5e7c..1c34b6b26 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -205,13 +205,14 @@ def test_telegram_status(default_conf, update, mocker) -> None: assert msg_mock.call_count == 1 context = MagicMock() - # /status table 2 3 - context.args = ["table", "2", "3"] + # /status table + context.args = ["table"] telegram._status(update=update, context=context) assert status_table.call_count == 1 def test_status_handle(default_conf, update, ticker, fee, mocker) -> None: + default_conf['max_open_trades'] = 3 mocker.patch.multiple( 'freqtrade.exchange.Exchange', fetch_ticker=ticker, @@ -252,8 +253,23 @@ def test_status_handle(default_conf, update, ticker, fee, mocker) -> None: assert 'Close Rate' not in ''.join(lines) assert 'Close Profit' not in ''.join(lines) - assert msg_mock.call_count == 1 + assert msg_mock.call_count == 3 assert 'ETH/BTC' in msg_mock.call_args_list[0][0][0] + assert 'LTC/BTC' in msg_mock.call_args_list[1][0][0] + + msg_mock.reset_mock() + context = MagicMock() + context.args = ["2", "3"] + + telegram._status(update=update, context=context) + + lines = msg_mock.call_args_list[0][0][0].split('\n') + assert '' not in lines + assert 'Close Rate' not in ''.join(lines) + assert 'Close Profit' not in ''.join(lines) + + assert msg_mock.call_count == 2 + assert 'LTC/BTC' in msg_mock.call_args_list[0][0][0] def test_status_table_handle(default_conf, update, ticker, fee, mocker) -> None: @@ -1011,15 +1027,13 @@ def test_blacklist_static(default_conf, update, mocker) -> None: msg_mock.reset_mock() context = MagicMock() - context.args = ["ETH/ETH"] + context.args = ["XRP/.*"] telegram._blacklist(update=update, context=context) - assert msg_mock.call_count == 2 - assert ("Error adding `ETH/ETH` to blacklist: `Pair ETH/ETH does not match stake currency.`" - in msg_mock.call_args_list[0][0][0]) + assert msg_mock.call_count == 1 - assert ("Blacklist contains 3 pairs\n`DOGE/BTC, HOT/BTC, ETH/BTC`" - in msg_mock.call_args_list[1][0][0]) - assert freqtradebot.pairlists.blacklist == ["DOGE/BTC", "HOT/BTC", "ETH/BTC"] + assert ("Blacklist contains 4 pairs\n`DOGE/BTC, HOT/BTC, ETH/BTC, XRP/.*`" + in msg_mock.call_args_list[0][0][0]) + assert freqtradebot.pairlists.blacklist == ["DOGE/BTC", "HOT/BTC", "ETH/BTC", "XRP/.*"] def test_telegram_logs(default_conf, update, mocker) -> None: diff --git a/tests/strategy/test_default_strategy.py b/tests/strategy/test_default_strategy.py index 1b1648db9..ec7b3c33d 100644 --- a/tests/strategy/test_default_strategy.py +++ b/tests/strategy/test_default_strategy.py @@ -1,5 +1,9 @@ +from datetime import datetime + from pandas import DataFrame +from freqtrade.persistence.models import Trade + from .strats.default_strategy import DefaultStrategy @@ -12,7 +16,7 @@ def test_default_strategy_structure(): assert hasattr(DefaultStrategy, 'populate_sell_trend') -def test_default_strategy(result): +def test_default_strategy(result, fee): strategy = DefaultStrategy({}) metadata = {'pair': 'ETH/BTC'} @@ -23,3 +27,18 @@ def test_default_strategy(result): assert type(indicators) is DataFrame assert type(strategy.populate_buy_trend(indicators, metadata)) is DataFrame assert type(strategy.populate_sell_trend(indicators, metadata)) is DataFrame + + trade = Trade( + open_rate=19_000, + amount=0.1, + pair='ETH/BTC', + fee_open=fee.return_value + ) + + assert strategy.confirm_trade_entry(pair='ETH/BTC', order_type='limit', amount=0.1, + rate=20000, time_in_force='gtc') is True + assert strategy.confirm_trade_exit(pair='ETH/BTC', trade=trade, order_type='limit', amount=0.1, + rate=20000, time_in_force='gtc', sell_reason='roi') is True + + assert strategy.custom_stoploss(pair='ETH/BTC', trade=trade, current_time=datetime.now(), + current_rate=20_000, current_profit=0.05) == strategy.stoploss diff --git a/tests/strategy/test_interface.py b/tests/strategy/test_interface.py index 640849ba4..f158a1518 100644 --- a/tests/strategy/test_interface.py +++ b/tests/strategy/test_interface.py @@ -13,6 +13,7 @@ from freqtrade.data.history import load_data from freqtrade.exceptions import StrategyError from freqtrade.persistence import PairLocks, Trade from freqtrade.resolvers import StrategyResolver +from freqtrade.strategy.interface import SellCheckTuple, SellType from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper from tests.conftest import log_has, log_has_re @@ -105,9 +106,29 @@ def test_get_signal_old_dataframe(default_conf, mocker, caplog, ohlcv_history): assert log_has('Outdated history for pair xyz. Last tick is 16 minutes old', caplog) -def test_assert_df_raise(default_conf, mocker, caplog, ohlcv_history): - # default_conf defines a 5m interval. we check interval * 2 + 5m - # this is necessary as the last candle is removed (partial candles) by default +def test_ignore_expired_candle(default_conf): + default_conf.update({'strategy': 'DefaultStrategy'}) + strategy = StrategyResolver.load_strategy(default_conf) + strategy.ignore_buying_expired_candle_after = 60 + + latest_date = datetime(2020, 12, 30, 7, 0, 0, tzinfo=timezone.utc) + # Add 1 candle length as the "latest date" defines candle open. + current_time = latest_date + timedelta(seconds=80 + 300) + + assert strategy.ignore_expired_candle(latest_date=latest_date, + current_time=current_time, + timeframe_seconds=300, + buy=True) is True + + current_time = latest_date + timedelta(seconds=30 + 300) + + assert not strategy.ignore_expired_candle(latest_date=latest_date, + current_time=current_time, + timeframe_seconds=300, + buy=True) is True + + +def test_assert_df_raise(mocker, caplog, ohlcv_history): ohlcv_history.loc[1, 'date'] = arrow.utcnow().shift(minutes=-16) # Take a copy to correctly modify the call mocked_history = ohlcv_history.copy() @@ -127,7 +148,7 @@ def test_assert_df_raise(default_conf, mocker, caplog, ohlcv_history): caplog) -def test_assert_df(default_conf, mocker, ohlcv_history, caplog): +def test_assert_df(ohlcv_history, caplog): df_len = len(ohlcv_history) - 1 # Ensure it's running when passed correctly _STRATEGY.assert_df(ohlcv_history, len(ohlcv_history), @@ -288,6 +309,77 @@ def test_min_roi_reached3(default_conf, fee) -> None: assert strategy.min_roi_reached(trade, 0.31, arrow.utcnow().shift(minutes=-2).datetime) +@pytest.mark.parametrize( + 'profit,adjusted,expected,trailing,custom,profit2,adjusted2,expected2,custom_stop', [ + # Profit, adjusted stoploss(absolute), profit for 2nd call, enable trailing, + # enable custom stoploss, expected after 1st call, expected after 2nd call + (0.2, 0.9, SellType.NONE, False, False, 0.3, 0.9, SellType.NONE, None), + (0.2, 0.9, SellType.NONE, False, False, -0.2, 0.9, SellType.STOP_LOSS, None), + (0.2, 1.14, SellType.NONE, True, False, 0.05, 1.14, SellType.TRAILING_STOP_LOSS, None), + (0.01, 0.96, SellType.NONE, True, False, 0.05, 1, SellType.NONE, None), + (0.05, 1, SellType.NONE, True, False, -0.01, 1, SellType.TRAILING_STOP_LOSS, None), + # Default custom case - trails with 10% + (0.05, 0.95, SellType.NONE, False, True, -0.02, 0.95, SellType.NONE, None), + (0.05, 0.95, SellType.NONE, False, True, -0.06, 0.95, SellType.TRAILING_STOP_LOSS, None), + (0.05, 1, SellType.NONE, False, True, -0.06, 1, SellType.TRAILING_STOP_LOSS, + lambda **kwargs: -0.05), + (0.05, 1, SellType.NONE, False, True, 0.09, 1.04, SellType.NONE, + lambda **kwargs: -0.05), + (0.05, 0.95, SellType.NONE, False, True, 0.09, 0.98, SellType.NONE, + lambda current_profit, **kwargs: -0.1 if current_profit < 0.6 else -(current_profit * 2)), + # Error case - static stoploss in place + (0.05, 0.9, SellType.NONE, False, True, 0.09, 0.9, SellType.NONE, + lambda **kwargs: None), + ]) +def test_stop_loss_reached(default_conf, fee, profit, adjusted, expected, trailing, custom, + profit2, adjusted2, expected2, custom_stop) -> None: + + default_conf.update({'strategy': 'DefaultStrategy'}) + + strategy = StrategyResolver.load_strategy(default_conf) + trade = Trade( + pair='ETH/BTC', + stake_amount=0.01, + amount=1, + open_date=arrow.utcnow().shift(hours=-1).datetime, + fee_open=fee.return_value, + fee_close=fee.return_value, + exchange='bittrex', + open_rate=1, + ) + trade.adjust_min_max_rates(trade.open_rate) + strategy.trailing_stop = trailing + strategy.trailing_stop_positive = -0.05 + strategy.use_custom_stoploss = custom + original_stopvalue = strategy.custom_stoploss + if custom_stop: + strategy.custom_stoploss = custom_stop + + now = arrow.utcnow().datetime + sl_flag = strategy.stop_loss_reached(current_rate=trade.open_rate * (1 + profit), trade=trade, + current_time=now, current_profit=profit, + force_stoploss=0, high=None) + assert isinstance(sl_flag, SellCheckTuple) + assert sl_flag.sell_type == expected + if expected == SellType.NONE: + assert sl_flag.sell_flag is False + else: + assert sl_flag.sell_flag is True + assert round(trade.stop_loss, 2) == adjusted + + sl_flag = strategy.stop_loss_reached(current_rate=trade.open_rate * (1 + profit2), trade=trade, + current_time=now, current_profit=profit2, + force_stoploss=0, high=None) + assert sl_flag.sell_type == expected2 + if expected2 == SellType.NONE: + assert sl_flag.sell_flag is False + else: + assert sl_flag.sell_flag is True + assert round(trade.stop_loss, 2) == adjusted2 + + strategy.custom_stoploss = original_stopvalue + + def test_analyze_ticker_default(ohlcv_history, mocker, caplog) -> None: caplog.set_level(logging.DEBUG) ind_mock = MagicMock(side_effect=lambda x, meta: x) diff --git a/tests/strategy/test_strategy_helpers.py b/tests/strategy/test_strategy_helpers.py index 1d3e80d24..252288e2e 100644 --- a/tests/strategy/test_strategy_helpers.py +++ b/tests/strategy/test_strategy_helpers.py @@ -1,5 +1,6 @@ import numpy as np import pandas as pd +import pytest from freqtrade.strategy import merge_informative_pair, timeframe_to_minutes @@ -47,17 +48,17 @@ def test_merge_informative_pair(): assert 'volume_1h' in result.columns assert result['volume'].equals(data['volume']) - # First 4 rows are empty + # First 3 rows are empty assert result.iloc[0]['date_1h'] is pd.NaT assert result.iloc[1]['date_1h'] is pd.NaT assert result.iloc[2]['date_1h'] is pd.NaT - assert result.iloc[3]['date_1h'] is pd.NaT # Next 4 rows contain the starting date (0:00) + assert result.iloc[3]['date_1h'] == result.iloc[0]['date'] assert result.iloc[4]['date_1h'] == result.iloc[0]['date'] assert result.iloc[5]['date_1h'] == result.iloc[0]['date'] assert result.iloc[6]['date_1h'] == result.iloc[0]['date'] - assert result.iloc[7]['date_1h'] == result.iloc[0]['date'] # Next 4 rows contain the next Hourly date original date row 4 + assert result.iloc[7]['date_1h'] == result.iloc[4]['date'] assert result.iloc[8]['date_1h'] == result.iloc[4]['date'] @@ -86,3 +87,11 @@ def test_merge_informative_pair_same(): # Dates match 1:1 assert result['date_15m'].equals(result['date']) + + +def test_merge_informative_pair_lower(): + data = generate_test_data('1h', 40) + informative = generate_test_data('15m', 40) + + with pytest.raises(ValueError, match=r"Tried to merge a faster timeframe .*"): + merge_informative_pair(data, informative, '1h', '15m', ffill=True) diff --git a/tests/strategy/test_strategy.py b/tests/strategy/test_strategy_loading.py similarity index 100% rename from tests/strategy/test_strategy.py rename to tests/strategy/test_strategy_loading.py diff --git a/tests/test_arguments.py b/tests/test_arguments.py index e2a1ae53c..60c2cfbac 100644 --- a/tests/test_arguments.py +++ b/tests/test_arguments.py @@ -172,7 +172,7 @@ def test_download_data_options() -> None: def test_plot_dataframe_options() -> None: args = [ 'plot-dataframe', - '-c', 'config.json.example', + '-c', 'config_bittrex.json.example', '--indicators1', 'sma10', 'sma100', '--indicators2', 'macd', 'fastd', 'fastk', '--plot-limit', '30', diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 12be5ae8b..6257a7e0b 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -3065,6 +3065,7 @@ def test_sell_profit_only_enable_profit(default_conf, limit_buy_order, limit_buy default_conf['ask_strategy'] = { 'use_sell_signal': True, 'sell_profit_only': True, + 'sell_profit_offset': 0.1, } freqtrade = FreqtradeBot(default_conf) patch_get_signal(freqtrade) @@ -3076,7 +3077,11 @@ def test_sell_profit_only_enable_profit(default_conf, limit_buy_order, limit_buy trade.update(limit_buy_order) freqtrade.wallets.update() patch_get_signal(freqtrade, value=(False, True)) + assert freqtrade.handle_trade(trade) is False + + freqtrade.config['ask_strategy']['sell_profit_offset'] = 0.0 assert freqtrade.handle_trade(trade) is True + assert trade.sell_reason == SellType.SELL_SIGNAL.value @@ -4313,6 +4318,11 @@ def test_update_open_orders(mocker, default_conf, fee, caplog): create_mock_trades(fee) freqtrade.update_open_orders() + assert not log_has_re(r"Error updating Order .*", caplog) + + freqtrade.config['dry_run'] = False + freqtrade.update_open_orders() + assert log_has_re(r"Error updating Order .*", caplog) caplog.clear() @@ -4358,6 +4368,19 @@ def test_update_closed_trades_without_assigned_fees(mocker, default_conf, fee): freqtrade.update_closed_trades_without_assigned_fees() + # Does nothing for dry-run + trades = Trade.get_trades().all() + assert len(trades) == MOCK_TRADE_COUNT + for trade in trades: + assert trade.fee_open_cost is None + assert trade.fee_open_currency is None + assert trade.fee_close_cost is None + assert trade.fee_close_currency is None + + freqtrade.config['dry_run'] = False + + freqtrade.update_closed_trades_without_assigned_fees() + trades = Trade.get_trades().all() assert len(trades) == MOCK_TRADE_COUNT diff --git a/tests/test_main.py b/tests/test_main.py index f55aea336..70632aeaa 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -67,12 +67,12 @@ def test_main_fatal_exception(mocker, default_conf, caplog) -> None: mocker.patch('freqtrade.freqtradebot.RPCManager', MagicMock()) mocker.patch('freqtrade.freqtradebot.init_db', MagicMock()) - args = ['trade', '-c', 'config.json.example'] + args = ['trade', '-c', 'config_bittrex.json.example'] # Test Main + the KeyboardInterrupt exception with pytest.raises(SystemExit): main(args) - assert log_has('Using config: config.json.example ...', caplog) + assert log_has('Using config: config_bittrex.json.example ...', caplog) assert log_has('Fatal exception!', caplog) @@ -85,12 +85,12 @@ def test_main_keyboard_interrupt(mocker, default_conf, caplog) -> None: mocker.patch('freqtrade.wallets.Wallets.update', MagicMock()) mocker.patch('freqtrade.freqtradebot.init_db', MagicMock()) - args = ['trade', '-c', 'config.json.example'] + args = ['trade', '-c', 'config_bittrex.json.example'] # Test Main + the KeyboardInterrupt exception with pytest.raises(SystemExit): main(args) - assert log_has('Using config: config.json.example ...', caplog) + assert log_has('Using config: config_bittrex.json.example ...', caplog) assert log_has('SIGINT received, aborting ...', caplog) @@ -106,12 +106,12 @@ def test_main_operational_exception(mocker, default_conf, caplog) -> None: mocker.patch('freqtrade.freqtradebot.RPCManager', MagicMock()) mocker.patch('freqtrade.freqtradebot.init_db', MagicMock()) - args = ['trade', '-c', 'config.json.example'] + args = ['trade', '-c', 'config_bittrex.json.example'] # Test Main + the KeyboardInterrupt exception with pytest.raises(SystemExit): main(args) - assert log_has('Using config: config.json.example ...', caplog) + assert log_has('Using config: config_bittrex.json.example ...', caplog) assert log_has('Oh snap!', caplog) @@ -157,12 +157,12 @@ def test_main_reload_config(mocker, default_conf, caplog) -> None: mocker.patch('freqtrade.freqtradebot.RPCManager', MagicMock()) mocker.patch('freqtrade.freqtradebot.init_db', MagicMock()) - args = Arguments(['trade', '-c', 'config.json.example']).get_parsed_arg() + args = Arguments(['trade', '-c', 'config_bittrex.json.example']).get_parsed_arg() worker = Worker(args=args, config=default_conf) with pytest.raises(SystemExit): - main(['trade', '-c', 'config.json.example']) + main(['trade', '-c', 'config_bittrex.json.example']) - assert log_has('Using config: config.json.example ...', caplog) + assert log_has('Using config: config_bittrex.json.example ...', caplog) assert worker_mock.call_count == 4 assert reconfigure_mock.call_count == 1 assert isinstance(worker.freqtrade, FreqtradeBot) @@ -180,7 +180,7 @@ def test_reconfigure(mocker, default_conf) -> None: mocker.patch('freqtrade.freqtradebot.RPCManager', MagicMock()) mocker.patch('freqtrade.freqtradebot.init_db', MagicMock()) - args = Arguments(['trade', '-c', 'config.json.example']).get_parsed_arg() + args = Arguments(['trade', '-c', 'config_bittrex.json.example']).get_parsed_arg() worker = Worker(args=args, config=default_conf) freqtrade = worker.freqtrade diff --git a/tests/test_plotting.py b/tests/test_plotting.py index 42847ca50..96c9868a9 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -47,14 +47,15 @@ def test_init_plotscript(default_conf, mocker, testdatadir): default_conf['timeframe'] = "5m" default_conf["datadir"] = testdatadir default_conf['exportfilename'] = testdatadir / "backtest-result_test.json" - ret = init_plotscript(default_conf) + supported_markets = ["TRX/BTC", "ADA/BTC"] + ret = init_plotscript(default_conf, supported_markets) assert "ohlcv" in ret assert "trades" in ret assert "pairs" in ret assert 'timerange' in ret default_conf['pairs'] = ["TRX/BTC", "ADA/BTC"] - ret = init_plotscript(default_conf, 20) + ret = init_plotscript(default_conf, supported_markets, 20) assert "ohlcv" in ret assert "TRX/BTC" in ret["ohlcv"] assert "ADA/BTC" in ret["ohlcv"] @@ -353,12 +354,16 @@ def test_generate_profit_graph(testdatadir): profit_pair = find_trace_in_fig_data(figure.data, f"Profit {pair}") assert isinstance(profit_pair, go.Scatter) + with pytest.raises(OperationalException, match=r"No trades found.*"): + # Pair cannot be empty - so it's an empty dataframe. + generate_profit_graph(pairs, data, trades.loc[trades['pair'].isnull()], timeframe="5m") + def test_start_plot_dataframe(mocker): aup = mocker.patch("freqtrade.plot.plotting.load_and_plot_trades", MagicMock()) args = [ "plot-dataframe", - "--config", "config.json.example", + "--config", "config_bittrex.json.example", "--pairs", "ETH/BTC" ] start_plot_dataframe(get_args(args)) @@ -402,7 +407,7 @@ def test_start_plot_profit(mocker): aup = mocker.patch("freqtrade.plot.plotting.plot_profit", MagicMock()) args = [ "plot-profit", - "--config", "config.json.example", + "--config", "config_bittrex.json.example", "--pairs", "ETH/BTC" ] start_plot_profit(get_args(args))