diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..3fa04cb6f --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +# These are supported funding model platforms + +github: [xmatthias] diff --git a/.gitignore b/.gitignore index 16df71194..34c751242 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ freqtrade-plot.html freqtrade-profit-plot.html freqtrade/rpc/api_server/ui/* +# Macos related +.DS_Store + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/README.md b/README.md index b67e16010..9b25775af 100644 --- a/README.md +++ b/README.md @@ -5,10 +5,14 @@ [![Documentation](https://readthedocs.org/projects/freqtrade/badge/)](https://www.freqtrade.io) [![Maintainability](https://api.codeclimate.com/v1/badges/5737e6d668200b7518ff/maintainability)](https://codeclimate.com/github/freqtrade/freqtrade/maintainability) -Freqtrade is a free and open source crypto trading bot written in Python. It is designed to support all major exchanges and be controlled via Telegram. It contains backtesting, plotting and money management tools as well as strategy optimization by machine learning. +Freqtrade is a free and open source crypto trading bot written in Python. It is designed to support all major exchanges and be controlled via Telegram or webUI. It contains backtesting, plotting and money management tools as well as strategy optimization by machine learning. ![freqtrade](https://raw.githubusercontent.com/freqtrade/freqtrade/develop/docs/assets/freqtrade-screenshot.png) +## Sponsored promotion + +[![tokenbot-promo](https://raw.githubusercontent.com/freqtrade/freqtrade/develop/docs/assets/TokenBot-Freqtrade-banner.png)](https://tokenbot.com/?utm_source=github&utm_medium=freqtrade&utm_campaign=algodevs) + ## Disclaimer This software is for educational purposes only. Do not risk money which @@ -31,7 +35,7 @@ Please read the [exchange specific notes](docs/exchanges.md) to learn about even - [X] [FTX](https://ftx.com) - [X] [Gate.io](https://www.gate.io/ref/6266643) - [X] [Kraken](https://kraken.com/) -- [X] [OKEX](https://www.okex.com/) +- [X] [OKX](https://www.okx.com/) - [ ] [potentially many others](https://github.com/ccxt/ccxt/). _(We cannot guarantee they will work)_ ### Community tested @@ -57,9 +61,9 @@ Please find the complete documentation on the [freqtrade website](https://www.fr - [x] **Edge position sizing** Calculate your win rate, risk reward ratio, the best stoploss and adjust your position size before taking a position for each specific market. [Learn more](https://www.freqtrade.io/en/stable/edge/). - [x] **Whitelist crypto-currencies**: Select which crypto-currency you want to trade or use dynamic whitelists. - [x] **Blacklist crypto-currencies**: Select which crypto-currency you want to avoid. +- [x] **Builtin WebUI**: Builtin web UI to manage your bot. - [x] **Manageable via Telegram**: Manage the bot with Telegram. -- [x] **Display profit/loss in fiat**: Display your profit/loss in 33 fiat. -- [x] **Daily summary of profit/loss**: Provide a daily summary of your profit/loss. +- [x] **Display profit/loss in fiat**: Display your profit/loss in fiat currency. - [x] **Performance status report**: Provide a performance status of your current trades. ## Quick start diff --git a/config_examples/config_full.example.json b/config_examples/config_full.example.json index f0e467f07..7398e8e6c 100644 --- a/config_examples/config_full.example.json +++ b/config_examples/config_full.example.json @@ -88,6 +88,7 @@ "key": "your_exchange_key", "secret": "your_exchange_secret", "password": "", + "log_responses": false, "ccxt_config": {}, "ccxt_async_config": {}, "pair_whitelist": [ diff --git a/docs/assets/TokenBot-Freqtrade-banner.png b/docs/assets/TokenBot-Freqtrade-banner.png new file mode 100644 index 000000000..6087056b6 Binary files /dev/null and b/docs/assets/TokenBot-Freqtrade-banner.png differ diff --git a/docs/backtesting.md b/docs/backtesting.md index 30a4af7c4..95722f506 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -313,6 +313,7 @@ A backtesting result will look like that: | Avg. Duration Winners | 4:23:00 | | Avg. Duration Loser | 6:55:00 | | Rejected Buy signals | 3089 | +| Entry/Exit Timeouts | 0 / 0 | | | | | Min balance | 0.00945123 BTC | | Max balance | 0.01846651 BTC | @@ -406,6 +407,7 @@ It contains some useful key metrics about performance of your strategy on backte | Avg. Duration Winners | 4:23:00 | | Avg. Duration Loser | 6:55:00 | | Rejected Buy signals | 3089 | +| Entry/Exit Timeouts | 0 / 0 | | | | | Min balance | 0.00945123 BTC | | Max balance | 0.01846651 BTC | @@ -435,6 +437,7 @@ It contains some useful key metrics about performance of your strategy on backte - `Days win/draw/lose`: Winning / Losing days (draws are usually days without closed trade). - `Avg. Duration Winners` / `Avg. Duration Loser`: Average durations for winning and losing trades. - `Rejected Buy signals`: Buy signals that could not be acted upon due to max_open_trades being reached. +- `Entry/Exit Timeouts`: Entry/exit orders which did not fill (only applicable if custom pricing is used). - `Min balance` / `Max balance`: Lowest and Highest Wallet balance during the backtest period. - `Drawdown (Account)`: Maximum Account Drawdown experienced. Calculated as $(Absolute Drawdown) / (DrawdownHigh + startingBalance)$. - `Drawdown`: Maximum, absolute drawdown experienced. Difference between Drawdown High and Subsequent Low point. diff --git a/docs/bot-basics.md b/docs/bot-basics.md index 248437de0..acd8721c9 100644 --- a/docs/bot-basics.md +++ b/docs/bot-basics.md @@ -63,6 +63,7 @@ This loop will be repeated again and again until the bot is stopped. * Check position adjustments for open trades if enabled and call `adjust_trade_position()` to determine if an additional order is requested. * Call `custom_stoploss()` and `custom_sell()` to find custom exit points. * For sells based on sell-signal and custom-sell: Call `custom_exit_price()` to determine exit price (Prices are moved to be within the closing candle). + * Check for Order timeouts, either via the `unfilledtimeout` configuration, or via `check_buy_timeout()` / `check_sell_timeout()` strategy callbacks. * Generate backtest report output !!! Note diff --git a/docs/data-analysis.md b/docs/data-analysis.md index 17da98935..9a79ee5ed 100644 --- a/docs/data-analysis.md +++ b/docs/data-analysis.md @@ -1,6 +1,6 @@ -# Analyzing bot data with Jupyter notebooks +# Analyzing bot data with Jupyter notebooks -You can analyze the results of backtests and trading history easily using Jupyter notebooks. Sample notebooks are located at `user_data/notebooks/` after initializing the user directory with `freqtrade create-userdir --userdir user_data`. +You can analyze the results of backtests and trading history easily using Jupyter notebooks. Sample notebooks are located at `user_data/notebooks/` after initializing the user directory with `freqtrade create-userdir --userdir user_data`. ## Quick start with docker @@ -41,32 +41,35 @@ ipython kernel install --user --name=freqtrade !!! Warning Some tasks don't work especially well in notebooks. For example, anything using asynchronous execution is a problem for Jupyter. Also, freqtrade's primary entry point is the shell cli, so using pure python in a notebook bypasses arguments that provide required objects and parameters to helper functions. You may need to set those values or create expected objects manually. -## Recommended workflow +## Recommended workflow -| Task | Tool | - --- | --- -Bot operations | CLI +| Task | Tool | + --- | --- +Bot operations | CLI Repetitive tasks | Shell scripts -Data analysis & visualization | Notebook +Data analysis & visualization | Notebook 1. Use the CLI to + * download historical data * run a backtest * run with real-time data - * export results + * export results 1. Collect these actions in shell scripts + * save complicated commands with arguments - * execute multi-step operations + * execute multi-step operations * automate testing strategies and preparing data for analysis 1. Use a notebook to + * visualize data - * munge and plot to generate insights + * mangle and plot to generate insights -## Example utility snippets +## Example utility snippets -### Change directory to root +### Change directory to root Jupyter notebooks execute from the notebook directory. The following snippet searches for the project root, so relative paths remain consistent. diff --git a/docs/exchanges.md b/docs/exchanges.md index 544372242..77c21638d 100644 --- a/docs/exchanges.md +++ b/docs/exchanges.md @@ -182,13 +182,13 @@ Kucoin supports [time_in_force](configuration.md#understand-order_time_in_force) For Kucoin, please add `"KCS/"` to your blacklist to avoid issues. Accounts having KCS accounts use this to pay for fees - if your first trade happens to be on `KCS`, further trades will consume this position and make the initial KCS trade unsellable as the expected amount is not there anymore. -## OKEX +## OKX -OKEX requires a passphrase for each api key, you will therefore need to add this key into the configuration so your exchange section looks as follows: +OKX requires a passphrase for each api key, you will therefore need to add this key into the configuration so your exchange section looks as follows: ```json "exchange": { - "name": "okex", + "name": "okx", "key": "your_exchange_key", "secret": "your_exchange_secret", "password": "your_exchange_api_key_password", @@ -197,7 +197,7 @@ OKEX requires a passphrase for each api key, you will therefore need to add this ``` !!! Warning - OKEX only provides 100 candles per api call. Therefore, the strategy will only have a pretty low amount of data available in backtesting mode. + OKX only provides 100 candles per api call. Therefore, the strategy will only have a pretty low amount of data available in backtesting mode. ## Gate.io diff --git a/docs/hyperopt.md b/docs/hyperopt.md index b7b6cb772..27d5a8761 100644 --- a/docs/hyperopt.md +++ b/docs/hyperopt.md @@ -116,7 +116,7 @@ optional arguments: ShortTradeDurHyperOptLoss, OnlyProfitHyperOptLoss, SharpeHyperOptLoss, SharpeHyperOptLossDaily, SortinoHyperOptLoss, SortinoHyperOptLossDaily, - CalmarHyperOptLoss, MaxDrawDownHyperOptLoss + CalmarHyperOptLoss, MaxDrawDownHyperOptLoss, ProfitDrawDownHyperOptLoss --disable-param-export Disable automatic hyperopt parameter export. --ignore-missing-spaces, --ignore-unparameterized-spaces @@ -525,6 +525,7 @@ Currently, the following loss functions are builtin: * `SortinoHyperOptLossDaily` - optimizes Sortino Ratio calculated on **daily** trade returns relative to **downside** standard deviation. * `MaxDrawDownHyperOptLoss` - Optimizes Maximum drawdown. * `CalmarHyperOptLoss` - Optimizes Calmar Ratio calculated on trade returns relative to max drawdown. +* `ProfitDrawDownHyperOptLoss` - Optimizes by max Profit & min Drawdown objective. `DRAWDOWN_MULT` variable within the hyperoptloss file can be adjusted to be stricter or more flexible on drawdown purposes. Creation of a custom loss function is covered in the [Advanced Hyperopt](advanced-hyperopt.md) part of the documentation. diff --git a/docs/index.md b/docs/index.md index 1f8f15704..9fb302a91 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,7 +11,7 @@ ## Introduction -Freqtrade is a crypto-currency algorithmic trading software developed in python (3.8+) and supported on Windows, macOS and Linux. +Freqtrade is a free and open source crypto trading bot written in Python. It is designed to support all major exchanges and be controlled via Telegram or webUI. It contains backtesting, plotting and money management tools as well as strategy optimization by machine learning. !!! Danger "DISCLAIMER" This software is for educational purposes only. Do not risk money which you are afraid to lose. USE THE SOFTWARE AT YOUR OWN RISK. THE AUTHORS AND ALL AFFILIATES ASSUME NO RESPONSIBILITY FOR YOUR TRADING RESULTS. @@ -20,6 +20,12 @@ Freqtrade is a crypto-currency algorithmic trading software developed in python We strongly recommend you to have basic coding skills and Python knowledge. Do not hesitate to read the source code and understand the mechanisms of this bot, algorithms and techniques implemented in it. +![freqtrade screenshot](assets/freqtrade-screenshot.png) + +## Sponsored promotion + +[![tokenbot-promo](assets/TokenBot-Freqtrade-banner.png)](https://tokenbot.com/?utm_source=github&utm_medium=freqtrade&utm_campaign=algodevs) + ## Features - Develop your Strategy: Write your strategy in python, using [pandas](https://pandas.pydata.org/). Example strategies to inspire you are available in the [strategy repository](https://github.com/freqtrade/freqtrade-strategies). @@ -29,7 +35,7 @@ Freqtrade is a crypto-currency algorithmic trading software developed in python - Select markets: Create your static list or use an automatic one based on top traded volumes and/or prices (not available during backtesting). You can also explicitly blacklist markets you don't want to trade. - Run: Test your strategy with simulated money (Dry-Run mode) or deploy it with real money (Live-Trade mode). - Run using Edge (optional module): The concept is to find the best historical [trade expectancy](edge.md#expectancy) by markets based on variation of the stop-loss and then allow/reject markets to trade. The sizing of the trade is based on a risk of a percentage of your capital. -- Control/Monitor: Use Telegram or a REST API (start/stop the bot, show profit/loss, daily summary, current open trades results, etc.). +- Control/Monitor: Use Telegram or a WebUI (start/stop the bot, show profit/loss, daily summary, current open trades results, etc.). - Analyse: Further analysis can be performed on either Backtesting data or Freqtrade trading history (SQL database), including automated standard plots, and methods to load the data into [interactive environments](data-analysis.md). ## Supported exchange marketplaces @@ -41,7 +47,7 @@ Please read the [exchange specific notes](exchanges.md) to learn about eventual, - [X] [FTX](https://ftx.com) - [X] [Gate.io](https://www.gate.io/ref/6266643) - [X] [Kraken](https://kraken.com/) -- [X] [OKEX](https://www.okex.com/) +- [X] [OKX](https://www.okx.com/) - [ ] [potentially many others through ccxt](https://github.com/ccxt/ccxt/). _(We cannot guarantee they will work)_ ### Community tested diff --git a/docs/installation.md b/docs/installation.md index 2a1c3db0a..92aa59498 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -24,7 +24,7 @@ The easiest way to install and run Freqtrade is to clone the bot Github reposito 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.8 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. !!! Warning "Up-to-date clock" @@ -54,7 +54,7 @@ We've included/collected install instructions for Ubuntu, MacOS, and Windows. Th OS Specific steps are listed first, the [Common](#common) section below is necessary for all systems. !!! Note - Python3.7 or higher and the corresponding pip are assumed to be available. + Python3.8 or higher and the corresponding pip are assumed to be available. === "Debian/Ubuntu" #### Install necessary dependencies @@ -69,7 +69,7 @@ OS Specific steps are listed first, the [Common](#common) section below is neces === "RaspberryPi/Raspbian" The following assumes the latest [Raspbian Buster lite image](https://www.raspberrypi.org/downloads/raspbian/). - This image comes with python3.7 preinstalled, making it easy to get freqtrade up and running. + This image comes with python3.9 preinstalled, making it easy to get freqtrade up and running. Tested using a Raspberry Pi 3 with the Raspbian Buster lite image, all updates applied. @@ -169,7 +169,7 @@ You can as well update, configure and reset the codebase of your bot with `./scr ** --install ** With this option, the script will install the bot and most dependencies: -You will need to have git and python3.7+ installed beforehand for this to work. +You will need to have git and python3.8+ installed beforehand for this to work. * Mandatory software as: `ta-lib` * Setup your virtualenv under `.env/` diff --git a/docs/plotting.md b/docs/plotting.md index e5091b9f9..873c08cf4 100644 --- a/docs/plotting.md +++ b/docs/plotting.md @@ -322,8 +322,8 @@ optional arguments: Specify what timerange of data to use. --export EXPORT Export backtest results, argument are: trades. Example: `--export=trades` - --export-filename PATH - Save backtest results to the file with this filename. + --export-filename PATH, --backtest-filename PATH + Use backtest results from this filename. Requires `--export` to be set as well. Example: `--export-filename=user_data/backtest_results/backtest _today.json` diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index 0368c90ff..ad6d43f17 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,4 +1,4 @@ mkdocs==1.2.3 -mkdocs-material==8.1.8 +mkdocs-material==8.1.10 mdx_truly_sane_lists==1.2 pymdown-extensions==9.1 diff --git a/docs/strategy-callbacks.md b/docs/strategy-callbacks.md index b441f5180..9e0b33ab1 100644 --- a/docs/strategy-callbacks.md +++ b/docs/strategy-callbacks.md @@ -54,7 +54,7 @@ Called before entering a trade, makes it possible to manage your position size w class AwesomeStrategy(IStrategy): def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float, proposed_stake: float, min_stake: float, max_stake: float, - side: str, **kwargs) -> float: + entry_tag: Optional[str], side: str, **kwargs) -> float: dataframe, _ = self.dp.get_analyzed_dataframe(pair=pair, timeframe=self.timeframe) current_candle = dataframe.iloc[-1].squeeze() @@ -74,7 +74,7 @@ class AwesomeStrategy(IStrategy): Freqtrade will fall back to the `proposed_stake` value should your code raise an exception. The exception itself will be logged. !!! Tip - You do not _have_ to ensure that `min_stake <= returned_value <= max_stake`. Trades will succeed as the returned value will be clamped to supported range and this acton will be logged. + You do not _have_ to ensure that `min_stake <= returned_value <= max_stake`. Trades will succeed as the returned value will be clamped to supported range and this action will be logged. !!! Tip Returning `0` or `None` will prevent trades from being placed. @@ -390,8 +390,8 @@ class AwesomeStrategy(IStrategy): If the new_entryprice is 97, the proposed_rate is 100 and the `custom_price_max_distance_ratio` is set to 2%, The retained valid custom entry price will be 98, which is 2% below the current (proposed) rate. !!! Warning "Backtesting" - While Custom prices are supported in backtesting (starting with 2021.12), prices will be moved to within the candle's high/low prices. - This behavior is currently being tested, and might be changed at a later point. + Custom prices are supported in backtesting (starting with 2021.12), and orders will fill if the price falls within the candle's low/high range. + Orders that don't fill immediately are subject to regular timeout handling, which happens once per (detail) candle. `custom_exit_price()` is only called for sells of type Sell_signal and Custom sell. All other sell-types will use regular backtesting prices. ## Custom order timeout rules @@ -401,7 +401,8 @@ Simple, time-based order-timeouts can be configured either via strategy or in th However, freqtrade also offers a custom callback for both order types, which allows you to decide based on custom criteria if an 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. + Backtesting fills orders if their price falls within the candle's low/high range. + The below callbacks will be called once per (detail) candle for orders that don't fill immediately (which use custom pricing). ### Custom order timeout example @@ -468,7 +469,8 @@ class AwesomeStrategy(IStrategy): 'sell': 60 * 25 } - def check_buy_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool: + def check_buy_timeout(self, pair: str, trade: Trade, order: dict, + current_time: datetime, **kwargs) -> bool: ob = self.dp.orderbook(pair, 1) current_price = ob['bids'][0][0] # Cancel buy order if price is more than 2% above the order. @@ -477,7 +479,8 @@ class AwesomeStrategy(IStrategy): return False - def check_sell_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool: + def check_sell_timeout(self, pair: str, trade: Trade, order: dict, + current_time: datetime, **kwargs) -> bool: ob = self.dp.orderbook(pair, 1) current_price = ob['asks'][0][0] # Cancel sell order if price is more than 2% below the order. diff --git a/docs/utils.md b/docs/utils.md index 4a032db26..c6e795e60 100644 --- a/docs/utils.md +++ b/docs/utils.md @@ -59,7 +59,7 @@ $ freqtrade new-config --config config_binance.json ? Do you want to enable Dry-run (simulated trades)? Yes ? Please insert your stake currency: BTC ? Please insert your stake amount: 0.05 -? Please insert max_open_trades (Integer or 'unlimited'): 3 +? Please insert max_open_trades (Integer or -1 for unlimited open trades): 3 ? Please insert your desired timeframe (e.g. 5m): 5m ? Please insert your display Currency (for reporting): USD ? Select exchange binance diff --git a/freqtrade/commands/arguments.py b/freqtrade/commands/arguments.py index c9287021c..474759d95 100644 --- a/freqtrade/commands/arguments.py +++ b/freqtrade/commands/arguments.py @@ -78,7 +78,7 @@ ARGS_PLOT_DATAFRAME = ["pairs", "indicators1", "indicators2", "plot_limit", "timerange", "timeframe", "no_trades"] ARGS_PLOT_PROFIT = ["pairs", "timerange", "export", "exportfilename", "db_url", - "trade_source", "timeframe", "plot_auto_open"] + "trade_source", "timeframe", "plot_auto_open", ] ARGS_INSTALL_UI = ["erase_ui_only", 'ui_version'] diff --git a/freqtrade/commands/build_config_commands.py b/freqtrade/commands/build_config_commands.py index a8cd99892..58750bd90 100644 --- a/freqtrade/commands/build_config_commands.py +++ b/freqtrade/commands/build_config_commands.py @@ -76,12 +76,9 @@ def ask_user_config() -> Dict[str, Any]: { "type": "text", "name": "max_open_trades", - "message": f"Please insert max_open_trades (Integer or '{UNLIMITED_STAKE_AMOUNT}'):", + "message": "Please insert max_open_trades (Integer or -1 for unlimited open trades):", "default": "3", - "validate": lambda val: val == UNLIMITED_STAKE_AMOUNT or validate_is_int(val), - "filter": lambda val: '"' + UNLIMITED_STAKE_AMOUNT + '"' - if val == UNLIMITED_STAKE_AMOUNT - else val + "validate": lambda val: validate_is_int(val) }, { "type": "select", @@ -115,8 +112,8 @@ def ask_user_config() -> Dict[str, Any]: "gateio", "kraken", "kucoin", - "okex", - Separator("-----------------------------------------------"), + "okx", + Separator("------------------"), "other", ], }, @@ -151,7 +148,7 @@ def ask_user_config() -> Dict[str, Any]: "type": "password", "name": "exchange_key_password", "message": "Insert Exchange API Key password", - "when": lambda x: not x['dry_run'] and x['exchange_name'] in ('kucoin', 'okex') + "when": lambda x: not x['dry_run'] and x['exchange_name'] in ('kucoin', 'okx') }, { "type": "confirm", diff --git a/freqtrade/commands/cli_options.py b/freqtrade/commands/cli_options.py index 88d517066..11f17cc7e 100644 --- a/freqtrade/commands/cli_options.py +++ b/freqtrade/commands/cli_options.py @@ -182,11 +182,12 @@ AVAILABLE_CLI_OPTIONS = { choices=constants.EXPORT_OPTIONS, ), "exportfilename": Arg( - '--export-filename', - help='Save backtest results to the file with this filename. ' - 'Requires `--export` to be set as well. ' - 'Example: `--export-filename=user_data/backtest_results/backtest_today.json`', - metavar='PATH', + "--export-filename", + "--backtest-filename", + help="Use this filename for backtest results." + "Requires `--export` to be set as well. " + "Example: `--export-filename=user_data/backtest_results/backtest_today.json`", + metavar="PATH", ), "disableparamexport": Arg( '--disable-param-export', diff --git a/freqtrade/configuration/configuration.py b/freqtrade/configuration/configuration.py index b364e6479..aef02683f 100644 --- a/freqtrade/configuration/configuration.py +++ b/freqtrade/configuration/configuration.py @@ -431,7 +431,6 @@ class Configuration: logstring='Using "{}" to store trades data.') def _process_data_options(self, config: Dict[str, Any]) -> None: - self._args_to_config(config, argname='new_pairs_days', logstring='Detected --new-pairs-days: {}') self._args_to_config(config, argname='trading_mode', diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 9c00dc7e3..f5dae9473 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -28,7 +28,7 @@ HYPEROPT_LOSS_BUILTIN = ['ShortTradeDurHyperOptLoss', 'OnlyProfitHyperOptLoss', 'SharpeHyperOptLoss', 'SharpeHyperOptLossDaily', 'SortinoHyperOptLoss', 'SortinoHyperOptLossDaily', 'CalmarHyperOptLoss', - 'MaxDrawDownHyperOptLoss'] + 'MaxDrawDownHyperOptLoss', 'ProfitDrawDownHyperOptLoss'] AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList', 'AgeFilter', 'OffsetFilter', 'PerformanceFilter', 'PrecisionFilter', 'PriceFilter', 'RangeStabilityFilter', @@ -462,6 +462,7 @@ SCHEMA_BACKTEST_REQUIRED = [ 'dry_run_wallet', 'dataformat_ohlcv', 'dataformat_trades', + 'unfilledtimeout', ] SCHEMA_MINIMAL_REQUIRED = [ diff --git a/freqtrade/data/history/history_utils.py b/freqtrade/data/history/history_utils.py index c391dcb5c..557d2b09b 100644 --- a/freqtrade/data/history/history_utils.py +++ b/freqtrade/data/history/history_utils.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import Dict, List, Optional, Tuple import arrow -from pandas import DataFrame +from pandas import DataFrame, concat from freqtrade.configuration import TimeRange from freqtrade.constants import DEFAULT_DATAFRAME_COLUMNS @@ -229,7 +229,7 @@ def _download_pair_history(pair: str, *, else: # Run cleaning again to ensure there were no duplicate candles # Especially between existing and new data. - data = clean_ohlcv_dataframe(data.append(new_dataframe), timeframe, pair, + data = clean_ohlcv_dataframe(concat([data, new_dataframe], axis=0), timeframe, pair, fill_missing=False, drop_incomplete=False) logger.debug("New Start: %s", diff --git a/freqtrade/exchange/__init__.py b/freqtrade/exchange/__init__.py index b356a8147..9dc2b8480 100644 --- a/freqtrade/exchange/__init__.py +++ b/freqtrade/exchange/__init__.py @@ -20,4 +20,4 @@ from freqtrade.exchange.gateio import Gateio from freqtrade.exchange.hitbtc import Hitbtc from freqtrade.exchange.kraken import Kraken from freqtrade.exchange.kucoin import Kucoin -from freqtrade.exchange.okex import Okex +from freqtrade.exchange.okx import Okx diff --git a/freqtrade/exchange/common.py b/freqtrade/exchange/common.py index a48f95e67..690c3b6cd 100644 --- a/freqtrade/exchange/common.py +++ b/freqtrade/exchange/common.py @@ -27,7 +27,8 @@ API_FETCH_ORDER_RETRY_COUNT = 5 BAD_EXCHANGES = { "bitmex": "Various reasons.", - "phemex": "Does not provide history. ", + "phemex": "Does not provide history.", + "probit": "Requires additional, regular calls to `signIn()`.", "poloniex": "Does not provide fetch_order endpoint to fetch both open and closed orders.", } @@ -35,6 +36,7 @@ MAP_EXCHANGE_CHILDCLASS = { 'binanceus': 'binance', 'binanceje': 'binance', 'binanceusdm': 'binance', + 'okex': 'okx', } diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 763386f54..0434c3b9c 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -2155,7 +2155,7 @@ def is_exchange_known_ccxt(exchange_name: str, ccxt_module: CcxtModuleType = Non def is_exchange_officially_supported(exchange_name: str) -> bool: - return exchange_name in ['bittrex', 'binance', 'kraken', 'ftx', 'gateio', 'okex'] + return exchange_name in ['bittrex', 'binance', 'kraken', 'ftx', 'gateio', 'okx'] def ccxt_exchanges(ccxt_module: CcxtModuleType = None) -> List[str]: diff --git a/freqtrade/exchange/okex.py b/freqtrade/exchange/okx.py similarity index 96% rename from freqtrade/exchange/okex.py rename to freqtrade/exchange/okx.py index ff440f42c..e74a06dc0 100644 --- a/freqtrade/exchange/okex.py +++ b/freqtrade/exchange/okx.py @@ -9,8 +9,8 @@ from freqtrade.exchange import Exchange logger = logging.getLogger(__name__) -class Okex(Exchange): - """Okex exchange class. +class Okx(Exchange): + """Okx exchange class. Contains adjustments needed for Freqtrade to work with this exchange. """ diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index f5e9883ad..35df38e69 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -123,6 +123,7 @@ class FreqtradeBot(LoggingMixin): for minutes in [0, 15, 30, 45]: t = str(time(time_slot, minutes, 2)) self._schedule.every().day.at(t).do(update) + self.last_process = datetime(1970, 1, 1, tzinfo=timezone.utc) def notify_status(self, msg: str) -> None: """ @@ -212,6 +213,7 @@ class FreqtradeBot(LoggingMixin): if self.trading_mode == TradingMode.FUTURES: self._schedule.run_pending() Trade.commit() + self.last_process = datetime.now(timezone.utc) def process_stopped(self) -> None: """ @@ -1187,8 +1189,9 @@ class FreqtradeBot(LoggingMixin): time_method = 'sell' if order['side'] == 'sell' else 'buy' max_timeouts = self.config.get('unfilledtimeout', {}).get('exit_timeout_count', 0) - if not_closed and (fully_cancelled or self.strategy.ft_check_timed_out( - time_method, trade, order, datetime.now(timezone.utc)) + order_obj = trade.select_order_by_order_id(trade.open_order_id) + if not_closed and (fully_cancelled or (order_obj and self.strategy.ft_check_timed_out( + time_method, trade, order_obj, datetime.now(timezone.utc))) ): if is_entering: self.handle_cancel_enter(trade, order, constants.CANCEL_REASON['TIMEOUT']) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 70dc1a3ab..719aea8aa 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -66,6 +66,8 @@ class Backtesting: LoggingMixin.show_output = False self.config = config self.results: Dict[str, Any] = {} + self.trade_id_counter: int = 0 + self.order_id_counter: int = 0 config['dry_run'] = True self.run_ids: Dict[str, str] = {} @@ -276,6 +278,8 @@ class Backtesting: PairLocks.reset_locks() Trade.reset_trades() self.rejected_trades = 0 + self.timedout_entry_orders = 0 + self.timedout_exit_orders = 0 self.dataprovider.clear_cache() if enable_protections: self._load_protections(self.strategy) @@ -322,6 +326,14 @@ class Backtesting: # Trim startup period from analyzed dataframe df_analyzed = processed[pair] = pair_data = trim_dataframe( df_analyzed, self.timerange, startup_candles=self.required_startup) + # Update dataprovider cache + self.dataprovider._set_cached_df( + pair, self.timeframe, df_analyzed, self.config['candle_type_def']) + + # Create a copy of the dataframe before shifting, that way the buy signal/tag + # remains on the correct candle for callbacks. + df_analyzed = df_analyzed.copy() + # To avoid using data from future, we use buy/sell signals shifted # from the previous candle for col in headers[5:]: @@ -332,14 +344,6 @@ class Backtesting: else: df_analyzed.loc[:, col] = 0 if not tag_col else None - # Update dataprovider cache - self.dataprovider._set_cached_df( - pair, - self.timeframe, - df_analyzed, - self.config['candle_type_def'] - ) - df_analyzed = df_analyzed.drop(df_analyzed.head(1).index) # Convert from Pandas to list for performance reasons @@ -404,7 +408,10 @@ class Backtesting: # use Open rate if open_rate > calculated sell rate return sell_row[OPEN_IDX] - return close_rate + # Use the maximum between close_rate and low as we + # cannot sell outside of a candle. + # Applies when a new ROI setting comes in place and the whole candle is above that. + return min(max(close_rate, sell_row[LOW_IDX]), sell_row[HIGH_IDX]) else: # This should not be reached... @@ -430,10 +437,15 @@ class Backtesting: pos_trade = self._enter_trade( trade.pair, row, 'short' if trade.is_short else 'long', stake_amount, trade) if pos_trade is not None: + self.wallets.update() return pos_trade return trade + def _get_order_filled(self, rate: float, row: Tuple) -> bool: + """ Rate is within candle, therefore filled""" + return row[LOW_IDX] <= rate <= row[HIGH_IDX] + def _get_sell_trade_entry_for_candle(self, trade: LocalTrade, sell_row: Tuple) -> Optional[LocalTrade]: @@ -462,18 +474,21 @@ class Backtesting: closerate = self._get_close_rate(sell_row, trade, sell, trade_dur) # call the custom exit price,with default value as previous closerate current_profit = trade.calc_profit_ratio(closerate) + order_type = self.strategy.order_types['sell'] if sell.sell_type in (SellType.SELL_SIGNAL, SellType.CUSTOM_SELL): # Custom exit pricing only for sell-signals - closerate = strategy_safe_wrapper(self.strategy.custom_exit_price, - default_retval=closerate)( - pair=trade.pair, trade=trade, - current_time=sell_row[DATE_IDX], - proposed_rate=closerate, current_profit=current_profit) - # Use the maximum between close_rate and low as we cannot sell outside of a candle. - closerate = min(max(closerate, sell_row[LOW_IDX]), sell_row[HIGH_IDX]) - + if order_type == 'limit': + closerate = strategy_safe_wrapper(self.strategy.custom_exit_price, + default_retval=closerate)( + pair=trade.pair, trade=trade, + current_time=sell_candle_time, + proposed_rate=closerate, current_profit=current_profit) + # We can't place orders lower than current low. + # freqtrade does not support this in live, and the order would fill immediately + closerate = max(closerate, sell_row[LOW_IDX]) # Confirm trade exit: time_in_force = self.strategy.order_time_in_force['sell'] + if not strategy_safe_wrapper(self.strategy.confirm_trade_exit, default_retval=True)( pair=trade.pair, trade=trade, order_type='limit', amount=trade.amount, rate=closerate, @@ -493,7 +508,28 @@ class Backtesting: ): trade.sell_reason = sell_row[EXIT_TAG_IDX] - trade.close(closerate, show_msg=False) + self.order_id_counter += 1 + order = Order( + id=self.order_id_counter, + ft_trade_id=trade.id, + order_date=sell_candle_time, + order_update_date=sell_candle_time, + ft_is_open=True, + ft_pair=trade.pair, + order_id=str(self.order_id_counter), + symbol=trade.pair, + ft_order_side="sell", + side="sell", + order_type=order_type, + status="open", + price=closerate, + average=closerate, + amount=trade.amount, + filled=0, + remaining=trade.amount, + cost=trade.amount * closerate, + ) + trade.orders.append(order) return trade return None @@ -547,13 +583,16 @@ class Backtesting: current_time = row[DATE_IDX].to_pydatetime() entry_tag = row[ENTER_TAG_IDX] if len(row) >= ENTER_TAG_IDX + 1 else None # let's call the custom entry price, using the open price as default price - propose_rate = strategy_safe_wrapper(self.strategy.custom_entry_price, - default_retval=row[OPEN_IDX])( - pair=pair, current_time=current_time, - proposed_rate=row[OPEN_IDX], entry_tag=entry_tag) # default value is the open rate - - # Move rate to within the candle's low/high rate - propose_rate = min(max(propose_rate, row[LOW_IDX]), row[HIGH_IDX]) + order_type = self.strategy.order_types['buy'] + propose_rate = row[OPEN_IDX] + if order_type == 'limit': + propose_rate = strategy_safe_wrapper(self.strategy.custom_entry_price, + default_retval=row[OPEN_IDX])( + pair=pair, current_time=current_time, + proposed_rate=propose_rate, entry_tag=entry_tag) # default value is the open rate + # We can't place orders higher than current high (otherwise it'd be a stop limit buy) + # which freqtrade does not support in live. + propose_rate = min(propose_rate, row[HIGH_IDX]) min_stake_amount = self.exchange.get_min_pair_stake_amount(pair, propose_rate, -0.05) or 0 max_stake_amount = self.exchange.get_max_pair_stake_amount(pair, propose_rate) @@ -562,9 +601,9 @@ class Backtesting: pos_adjust = trade is not None if not pos_adjust: try: - stake_amount = self.wallets.get_trade_stake_amount(pair, None) + stake_amount = self.wallets.get_trade_stake_amount(pair, None, update=False) except DependencyException: - return trade + return None stake_amount = strategy_safe_wrapper(self.strategy.custom_stake_amount, default_retval=stake_amount)( @@ -598,7 +637,7 @@ class Backtesting: leverage = min(max(leverage, 1.0), max_leverage) order_type = self.strategy.order_types['buy'] - time_in_force = self.strategy.order_time_in_force['sell'] + time_in_force = self.strategy.order_time_in_force['buy'] # Confirm trade entry: if not pos_adjust: if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)( @@ -608,15 +647,21 @@ class Backtesting: return None if stake_amount and (not min_stake_amount or stake_amount > min_stake_amount): + self.order_id_counter += 1 amount = round((stake_amount / propose_rate) * leverage, 8) if trade is None: # Enter trade + self.trade_id_counter += 1 trade = LocalTrade( + id=self.trade_id_counter, + open_order_id=self.order_id_counter, pair=pair, open_rate=propose_rate, + open_rate_requested=propose_rate, open_date=current_time, stake_amount=stake_amount, amount=amount, + amount_requested=amount, fee_open=self.fee, fee_close=self.fee, is_open=True, @@ -627,28 +672,36 @@ class Backtesting: leverage=leverage, orders=[] ) + trade.adjust_stop_loss(trade.open_rate, self.strategy.stoploss, initial=True) order = Order( - ft_is_open=False, + id=self.order_id_counter, + ft_trade_id=trade.id, + ft_is_open=True, ft_pair=trade.pair, + order_id=str(self.order_id_counter), symbol=trade.pair, ft_order_side="buy", side="buy", - order_type="market", - status="closed", + order_type=order_type, + status="open", order_date=current_time, order_filled_date=current_time, order_update_date=current_time, price=propose_rate, average=propose_rate, amount=amount, - filled=amount, - cost=stake_amount + trade.fee_open + filled=0, + remaining=amount, + cost=stake_amount + trade.fee_open, ) + if pos_adjust and self._get_order_filled(order.price, row): + order.close_bt_order(current_time) + else: + trade.open_order_id = str(self.order_id_counter) trade.orders.append(order) - if pos_adjust: - trade.recalc_trade_from_orders() + trade.recalc_trade_from_orders() return trade @@ -661,6 +714,9 @@ class Backtesting: for pair in open_trades.keys(): if len(open_trades[pair]) > 0: for trade in open_trades[pair]: + if trade.open_order_id and trade.nr_of_successful_buys == 0: + # Ignore trade if buy-order did not fill yet + continue sell_row = data[pair][-1] trade.close_date = sell_row[DATE_IDX].to_pydatetime() @@ -695,6 +751,51 @@ class Backtesting: return 'short' return None + def run_protections(self, enable_protections, pair: str, current_time: datetime): + if enable_protections: + self.protections.stop_per_pair(pair, current_time) + self.protections.global_stop(current_time) + + def check_order_cancel(self, trade: LocalTrade, current_time) -> bool: + """ + Check if an order has been canceled. + Returns True if the trade should be Deleted (initial order was canceled). + """ + for order in [o for o in trade.orders if o.ft_is_open]: + + timedout = self.strategy.ft_check_timed_out(order.side, trade, order, current_time) + if timedout: + if order.side == 'buy': + self.timedout_entry_orders += 1 + if trade.nr_of_successful_buys == 0: + # Remove trade due to buy timeout expiration. + return True + else: + # Close additional buy order + del trade.orders[trade.orders.index(order)] + if order.side == 'sell': + self.timedout_exit_orders += 1 + # Close sell order and retry selling on next signal. + del trade.orders[trade.orders.index(order)] + + return False + + def validate_row( + self, data: Dict, pair: str, row_index: int, current_time: datetime) -> Optional[Tuple]: + try: + # Row is treated as "current incomplete candle". + # Buy / sell signals are shifted by 1 to compensate for this. + row = data[pair][row_index] + except IndexError: + # missing Data for one pair at the end. + # Warnings for this are shown during data loading + return None + + # Waits until the time-counter reaches the start of the data for this pair. + if row[DATE_IDX] > current_time: + return None + return row + def backtest(self, processed: Dict, start_date: datetime, end_date: datetime, max_open_trades: int = 0, position_stacking: bool = False, @@ -717,14 +818,15 @@ class Backtesting: """ trades: List[LocalTrade] = [] self.prepare_backtest(enable_protections) - + # Ensure wallets are uptodate (important for --strategy-list) + self.wallets.update() # Use dict of lists with data for performance # (looping lists is a lot faster than pandas DataFrames) data: Dict = self._get_ohlcv_as_lists(processed) # Indexes per pair, so some pairs are allowed to have a missing start. indexes: Dict = defaultdict(int) - tmp = start_date + timedelta(minutes=self.timeframe_min) + current_time = start_date + timedelta(minutes=self.timeframe_min) open_trades: Dict[str, List[LocalTrade]] = defaultdict(list) open_trade_count = 0 @@ -733,28 +835,20 @@ class Backtesting: (end_date - start_date) / timedelta(minutes=self.timeframe_min))) # Loop timerange and get candle for each pair at that point in time - while tmp <= end_date: + while current_time <= end_date: open_trade_count_start = open_trade_count self.check_abort() for i, pair in enumerate(data): row_index = indexes[pair] - try: - # Row is treated as "current incomplete candle". - # Buy / sell signals are shifted by 1 to compensate for this. - row = data[pair][row_index] - except IndexError: - # missing Data for one pair at the end. - # Warnings for this are shown during data loading - continue - - # Waits until the time-counter reaches the start of the data for this pair. - if row[DATE_IDX] > tmp: + row = self.validate_row(data, pair, row_index, current_time) + if not row: continue row_index += 1 indexes[pair] = row_index self.dataprovider._set_dataframe_max_index(row_index) + # 1. Process buys. # without positionstacking, we can only have one open trade per pair. # max_open_trades must be respected # don't open on the last row @@ -762,39 +856,58 @@ class Backtesting: if ( (position_stacking or len(open_trades[pair]) == 0) and self.trade_slot_available(max_open_trades, open_trade_count_start) - and tmp != end_date + and current_time != end_date and trade_dir is not None and not PairLocks.is_pair_locked(pair, row[DATE_IDX]) ): trade = self._enter_trade(pair, row, trade_dir) if trade: # TODO: hacky workaround to avoid opening > max_open_trades - # This emulates previous behaviour - not sure if this is correct + # This emulates previous behavior - not sure if this is correct # Prevents buying if the trade-slot was freed in this candle open_trade_count_start += 1 open_trade_count += 1 # logger.debug(f"{pair} - Emulate creation of new trade: {trade}.") open_trades[pair].append(trade) - LocalTrade.add_bt_trade(trade) for trade in list(open_trades[pair]): - # also check the buying candle for sell conditions. - trade_entry = self._get_sell_trade_entry(trade, row) - # Sell occurred - if trade_entry: + # 2. Process buy orders. + order = trade.select_order('buy', is_open=True) + if order and self._get_order_filled(order.price, row): + order.close_bt_order(current_time) + trade.open_order_id = None + LocalTrade.add_bt_trade(trade) + self.wallets.update() + + # 3. Create sell orders (if any) + if not trade.open_order_id: + self._get_sell_trade_entry(trade, row) # Place sell order if necessary + + # 4. Process sell orders. + order = trade.select_order('sell', is_open=True) + if order and self._get_order_filled(order.price, row): + trade.open_order_id = None + trade.close_date = current_time + trade.close(order.price, show_msg=False) + # logger.debug(f"{pair} - Backtesting sell {trade}") open_trade_count -= 1 open_trades[pair].remove(trade) - LocalTrade.close_bt_trade(trade) - trades.append(trade_entry) - if enable_protections: - self.protections.stop_per_pair(pair, row[DATE_IDX]) - self.protections.global_stop(tmp) + trades.append(trade) + self.wallets.update() + self.run_protections(enable_protections, pair, current_time) + + # 5. Cancel expired buy/sell orders. + if self.check_order_cancel(trade, current_time): + # Close trade due to buy timeout expiration. + open_trade_count -= 1 + open_trades[pair].remove(trade) + self.wallets.update() # Move time one configured time_interval ahead. self.progress.increment() - tmp += timedelta(minutes=self.timeframe_min) + current_time += timedelta(minutes=self.timeframe_min) trades += self.handle_left_open(open_trades, data=data) self.wallets.update() @@ -805,6 +918,8 @@ class Backtesting: 'config': self.strategy.config, 'locks': PairLocks.get_all_locks(), 'rejected_signals': self.rejected_trades, + 'timedout_entry_orders': self.timedout_entry_orders, + 'timedout_exit_orders': self.timedout_exit_orders, 'final_balance': self.wallets.get_total(self.strategy.config['stake_currency']), } diff --git a/freqtrade/optimize/hyperopt_loss_profit_drawdown.py b/freqtrade/optimize/hyperopt_loss_profit_drawdown.py new file mode 100644 index 000000000..5bd12ff52 --- /dev/null +++ b/freqtrade/optimize/hyperopt_loss_profit_drawdown.py @@ -0,0 +1,30 @@ +""" +ProfitDrawDownHyperOptLoss + +This module defines the alternative HyperOptLoss class based on Profit & +Drawdown objective which can be used for Hyperoptimization. + +Possible to change `DRAWDOWN_MULT` to penalize drawdown objective for +individual needs. +""" +from pandas import DataFrame + +from freqtrade.data.btanalysis import calculate_max_drawdown +from freqtrade.optimize.hyperopt import IHyperOptLoss + + +# higher numbers penalize drawdowns more severely +DRAWDOWN_MULT = 0.075 + + +class ProfitDrawDownHyperOptLoss(IHyperOptLoss): + @staticmethod + def hyperopt_loss_function(results: DataFrame, trade_count: int, *args, **kwargs) -> float: + total_profit = results["profit_abs"].sum() + + try: + max_drawdown_abs = calculate_max_drawdown(results, value_col="profit_abs")[5] + except ValueError: + max_drawdown_abs = 0 + + return -1 * (total_profit * (1 - max_drawdown_abs * DRAWDOWN_MULT)) diff --git a/freqtrade/optimize/optimize_reports.py b/freqtrade/optimize/optimize_reports.py index 9db5c3e7f..76c92b4b7 100644 --- a/freqtrade/optimize/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports.py @@ -442,6 +442,8 @@ def generate_strategy_stats(pairlist: List[str], 'dry_run_wallet': start_balance, 'final_balance': content['final_balance'], 'rejected_signals': content['rejected_signals'], + 'timedout_entry_orders': content['timedout_entry_orders'], + 'timedout_exit_orders': content['timedout_exit_orders'], 'max_open_trades': max_open_trades, 'max_open_trades_setting': (config['max_open_trades'] if config['max_open_trades'] != float('inf') else -1), @@ -747,6 +749,9 @@ def text_table_add_metrics(strat_results: Dict) -> str: ('Avg. Duration Winners', f"{strat_results['winner_holding_avg']}"), ('Avg. Duration Loser', f"{strat_results['loser_holding_avg']}"), ('Rejected Buy signals', strat_results.get('rejected_signals', 'N/A')), + ('Entry/Exit Timeouts', + f"{strat_results.get('timedout_entry_orders', 'N/A')} / " + f"{strat_results.get('timedout_exit_orders', 'N/A')}"), ('', ''), # Empty line to improve readability ('Min balance', round_coin_value(strat_results['csum_min'], diff --git a/freqtrade/persistence/migrations.py b/freqtrade/persistence/migrations.py index e529c2461..1747f3002 100644 --- a/freqtrade/persistence/migrations.py +++ b/freqtrade/persistence/migrations.py @@ -28,7 +28,36 @@ def get_backup_name(tabs, backup_prefix: str): return table_back_name -def migrate_trades_table(decl_base, inspector, engine, table_back_name: str, cols: List): +def get_last_sequence_ids(engine, trade_back_name, order_back_name): + order_id: int = None + trade_id: int = None + + if engine.name == 'postgresql': + with engine.begin() as connection: + trade_id = connection.execute(text("select nextval('trades_id_seq')")).fetchone()[0] + order_id = connection.execute(text("select nextval('orders_id_seq')")).fetchone()[0] + with engine.begin() as connection: + connection.execute(text( + f"ALTER SEQUENCE orders_id_seq rename to {order_back_name}_id_seq_bak")) + connection.execute(text( + f"ALTER SEQUENCE trades_id_seq rename to {trade_back_name}_id_seq_bak")) + return order_id, trade_id + + +def set_sequence_ids(engine, order_id, trade_id): + + if engine.name == 'postgresql': + with engine.begin() as connection: + if order_id: + connection.execute(text(f"ALTER SEQUENCE orders_id_seq RESTART WITH {order_id}")) + if trade_id: + connection.execute(text(f"ALTER SEQUENCE trades_id_seq RESTART WITH {trade_id}")) + + +def migrate_trades_and_orders_table( + decl_base, inspector, engine, + trade_back_name: str, cols: List, + order_back_name: str, cols_order: List): fee_open = get_column_def(cols, 'fee_open', 'fee') fee_open_cost = get_column_def(cols, 'fee_open_cost', 'null') fee_open_currency = get_column_def(cols, 'fee_open_currency', 'null') @@ -79,11 +108,20 @@ def migrate_trades_table(decl_base, inspector, engine, table_back_name: str, col # Schema migration necessary with engine.begin() as connection: - connection.execute(text(f"alter table trades rename to {table_back_name}")) + connection.execute(text(f"alter table trades rename to {trade_back_name}")) + with engine.begin() as connection: # drop indexes on backup table in new session - for index in inspector.get_indexes(table_back_name): - connection.execute(text(f"drop index {index['name']}")) + for index in inspector.get_indexes(trade_back_name): + if engine.name == 'mysql': + connection.execute(text(f"drop index {index['name']} on {trade_back_name}")) + else: + connection.execute(text(f"drop index {index['name']}")) + + order_id, trade_id = get_last_sequence_ids(engine, trade_back_name, order_back_name) + + drop_orders_table(engine, order_back_name) + # let SQLAlchemy create the schema as required decl_base.metadata.create_all(engine) @@ -120,9 +158,12 @@ def migrate_trades_table(decl_base, inspector, engine, table_back_name: str, col {trading_mode} trading_mode, {leverage} leverage, {isolated_liq} isolated_liq, {is_short} is_short, {interest_rate} interest_rate, {funding_fees} funding_fees - from {table_back_name} + from {trade_back_name} """)) + migrate_orders_table(engine, order_back_name, cols_order) + set_sequence_ids(engine, order_id, trade_id) + def migrate_open_orders_to_trades(engine): with engine.begin() as connection: @@ -141,19 +182,18 @@ def migrate_open_orders_to_trades(engine): """)) -def migrate_orders_table(decl_base, inspector, engine, table_back_name: str, cols: List): - # Schema migration necessary +def drop_orders_table(engine, table_back_name: str): + # Drop and recreate orders table as backup + # This drops foreign keys, too. with engine.begin() as connection: - connection.execute(text(f"alter table orders rename to {table_back_name}")) + connection.execute(text(f"create table {table_back_name} as select * from orders")) + connection.execute(text("drop table orders")) - with engine.begin() as connection: - # drop indexes on backup table in new session - for index in inspector.get_indexes(table_back_name): - connection.execute(text(f"drop index {index['name']}")) + +def migrate_orders_table(engine, table_back_name: str, cols: List): # let SQLAlchemy create the schema as required - decl_base.metadata.create_all(engine) leverage = get_column_def(cols, 'leverage', '1.0') # sqlite does not support literals for booleans with engine.begin() as connection: @@ -176,12 +216,18 @@ def check_migrate(engine, decl_base, previous_tables) -> None: cols = inspector.get_columns('trades') tabs = get_table_names_for_table(inspector, 'trades') + cols_order = inspector.get_columns('orders') table_back_name = get_backup_name(tabs, 'trades_bak') + order_tabs = get_table_names_for_table(inspector, 'orders') + order_table_bak_name = get_backup_name(order_tabs, 'orders_bak') - # Check for latest column + # Check if migration necessary + # Migrates both trades and orders table! if not has_column(cols, 'enter_tag'): - logger.info(f'Running database migration for trades - backup: {table_back_name}') - migrate_trades_table(decl_base, inspector, engine, table_back_name, cols) + logger.info(f"Running database migration for trades - " + f"backup: {table_back_name}, {order_table_bak_name}") + migrate_trades_and_orders_table( + decl_base, inspector, engine, table_back_name, cols, order_table_bak_name, cols_order) # Reread columns - the above recreated the table! inspector = inspect(engine) cols = inspector.get_columns('trades') @@ -189,14 +235,3 @@ def check_migrate(engine, decl_base, previous_tables) -> None: if 'orders' not in previous_tables and 'trades' in previous_tables: logger.info('Moving open orders to Orders table.') migrate_open_orders_to_trades(engine) - else: - cols_order = inspector.get_columns('orders') - - # Last added column of order table - # To determine if migrations need to run - if not has_column(cols_order, 'leverage'): - tabs = get_table_names_for_table(inspector, 'orders') - # Empty for now - as there is only one iteration of the orders table so far. - table_back_name = get_backup_name(tabs, 'orders_bak') - - migrate_orders_table(decl_base, inspector, engine, table_back_name, cols_order) diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index 2fb0f39c9..29761fdf5 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -132,9 +132,12 @@ class Order(_DECL_BASE): order_date = Column(DateTime, nullable=True, default=datetime.utcnow) order_filled_date = Column(DateTime, nullable=True) order_update_date = Column(DateTime, nullable=True) - leverage = Column(Float, nullable=True, default=1.0) + @property + def order_date_utc(self): + return self.order_date.replace(tzinfo=timezone.utc) + def __repr__(self): return (f'Order(id={self.id}, order_id={self.order_id}, trade_id={self.ft_trade_id}, ' @@ -170,6 +173,35 @@ class Order(_DECL_BASE): self.order_filled_date = datetime.now(timezone.utc) self.order_update_date = datetime.now(timezone.utc) + def to_json(self) -> Dict[str, Any]: + return { + 'amount': self.amount, + 'average': round(self.average, 8) if self.average else 0, + 'cost': self.cost if self.cost else 0, + 'filled': self.filled, + 'ft_order_side': self.ft_order_side, + 'is_open': self.ft_is_open, + 'order_date': self.order_date.strftime(DATETIME_PRINT_FORMAT) + if self.order_date else None, + 'order_timestamp': int(self.order_date.replace( + tzinfo=timezone.utc).timestamp() * 1000) if self.order_date else None, + 'order_filled_date': self.order_filled_date.strftime(DATETIME_PRINT_FORMAT) + if self.order_filled_date else None, + 'order_filled_timestamp': int(self.order_filled_date.replace( + tzinfo=timezone.utc).timestamp() * 1000) if self.order_filled_date else None, + 'order_type': self.order_type, + 'pair': self.ft_pair, + 'price': self.price, + 'remaining': self.remaining, + 'status': self.status, + } + + def close_bt_order(self, close_date: datetime): + self.order_filled_date = close_date + self.filled = self.amount + self.status = 'closed' + self.ft_is_open = False + @staticmethod def update_orders(orders: List['Order'], order: Dict[str, Any]): """ @@ -390,6 +422,16 @@ class LocalTrade(): ) def to_json(self) -> Dict[str, Any]: + filled_orders = self.select_filled_orders() + filled_entries = [] + filled_exits = [] + if len(filled_orders) > 0: + for order in filled_orders: + if order.ft_order_side == 'buy': + filled_entries.append(order.to_json()) + if order.ft_order_side == 'sell': + filled_exits.append(order.to_json()) + return { 'trade_id': self.id, 'pair': self.pair, @@ -460,6 +502,8 @@ class LocalTrade(): 'trading_mode': self.trading_mode, 'funding_fees': self.funding_fees, 'open_order_id': self.open_order_id, + 'filled_entry_orders': filled_entries, + 'filled_exit_orders': filled_exits, } @staticmethod @@ -794,8 +838,8 @@ class LocalTrade(): return float(f"{profit_ratio:.8f}") def recalc_trade_from_orders(self): - # We need at least 2 orders for averaging amounts and rates. - if len(self.orders) < 2: + # We need at least 2 entry orders for averaging amounts and rates. + if len(self.select_filled_orders('buy')) < 2: # Just in case, still recalc open trade value self.recalc_open_trade_value() return @@ -825,14 +869,27 @@ class LocalTrade(): if self.stop_loss_pct is not None and self.open_rate is not None: self.adjust_stop_loss(self.open_rate, self.stop_loss_pct) - def select_order(self, order_side: str, is_open: Optional[bool]) -> Optional[Order]: + def select_order_by_order_id(self, order_id: str) -> Optional[Order]: + """ + Finds order object by Order id. + :param order_id: Exchange order id + """ + for o in self.orders: + if o.order_id == order_id: + return o + return None + + def select_order( + self, order_side: str = None, is_open: Optional[bool] = None) -> Optional[Order]: """ Finds latest order for this orderside and status :param order_side: Side of the order (either 'buy' or 'sell') :param is_open: Only search for open orders? :return: latest Order object if it exists, else None """ - orders = [o for o in self.orders if o.side == order_side] + orders = self.orders + if order_side: + orders = [o for o in self.orders if o.side == order_side] if is_open is not None: orders = [o for o in orders if o.ft_is_open == is_open] if len(orders) > 0: @@ -840,14 +897,14 @@ class LocalTrade(): else: return None - def select_filled_orders(self, order_side: str) -> List['Order']: + def select_filled_orders(self, order_side: Optional[str] = None) -> List['Order']: """ Finds filled orders for this orderside. - :param order_side: Side of the order (either 'buy' or 'sell') + :param order_side: Side of the order (either 'buy', 'sell', or None) :return: array of Order objects """ - return [o for o in self.orders if o.ft_order_side == order_side and - o.ft_is_open is False and + return [o for o in self.orders if ((o.ft_order_side == order_side) or (order_side is None)) + and o.ft_is_open is False and (o.filled or 0) > 0 and o.status in NON_OPEN_EXCHANGE_STATES] diff --git a/freqtrade/plot/plotting.py b/freqtrade/plot/plotting.py index fee84e5ea..9f6b193d3 100644 --- a/freqtrade/plot/plotting.py +++ b/freqtrade/plot/plotting.py @@ -61,8 +61,8 @@ def init_plotscript(config, markets: List, startup_candles: int = 0): startup_candles, min_date) no_trades = False - filename = config.get('exportfilename') - if config.get('no_trades', False): + filename = config.get("exportfilename") + if config.get("no_trades", False): no_trades = True elif config['trade_source'] == 'file': if not filename.is_dir() and not filename.is_file(): diff --git a/freqtrade/plugins/pairlist/PerformanceFilter.py b/freqtrade/plugins/pairlist/PerformanceFilter.py index d3196d3ae..5b02a47ab 100644 --- a/freqtrade/plugins/pairlist/PerformanceFilter.py +++ b/freqtrade/plugins/pairlist/PerformanceFilter.py @@ -60,6 +60,7 @@ class PerformanceFilter(IPairList): # Get pairlist from performance dataframe values list_df = pd.DataFrame({'pair': pairlist}) + list_df['prior_idx'] = list_df.index # Set initial value for pairs with no trades to 0 # Sort the list using: @@ -67,7 +68,7 @@ class PerformanceFilter(IPairList): # - then count (low to high, so as to favor same performance with fewer trades) # - then pair name alphametically sorted_df = list_df.merge(performance, on='pair', how='left')\ - .fillna(0).sort_values(by=['count', 'pair'], ascending=True)\ + .fillna(0).sort_values(by=['count', 'prior_idx'], ascending=True)\ .sort_values(by=['profit_ratio'], ascending=False) if self._min_profit is not None: removed = sorted_df[sorted_df['profit_ratio'] < self._min_profit] diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index 6b932857f..1a21fba6c 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -110,7 +110,7 @@ class SellReason(BaseModel): class Stats(BaseModel): sell_reasons: Dict[str, SellReason] - durations: Dict[str, Union[str, float]] + durations: Dict[str, Optional[float]] class DailyRecord(BaseModel): @@ -399,3 +399,8 @@ class BacktestResponse(BaseModel): class SysInfo(BaseModel): cpu_pct: List[float] ram_pct: float + + +class Health(BaseModel): + last_process: datetime + last_process_ts: int diff --git a/freqtrade/rpc/api_server/api_v1.py b/freqtrade/rpc/api_server/api_v1.py index 93a160efb..6e3808cf0 100644 --- a/freqtrade/rpc/api_server/api_v1.py +++ b/freqtrade/rpc/api_server/api_v1.py @@ -15,12 +15,12 @@ from freqtrade.rpc import RPC from freqtrade.rpc.api_server.api_schemas import (AvailablePairs, Balances, BlacklistPayload, BlacklistResponse, Count, Daily, DeleteLockRequest, DeleteTrade, ForceEnterPayload, - ForceEnterResponse, ForceExitPayload, Locks, Logs, - OpenTradeSchema, PairHistory, PerformanceEntry, - Ping, PlotConfig, Profit, ResultMsg, ShowConfig, - Stats, StatusMsg, StrategyListResponse, - StrategyResponse, SysInfo, Version, - WhitelistResponse) + ForceEnterResponse, ForceExitPayload, Health, + Locks, Logs, OpenTradeSchema, PairHistory, + PerformanceEntry, Ping, PlotConfig, Profit, + ResultMsg, ShowConfig, Stats, StatusMsg, + StrategyListResponse, StrategyResponse, SysInfo, + Version, WhitelistResponse) from freqtrade.rpc.api_server.deps import get_config, get_exchange, get_rpc, get_rpc_optional from freqtrade.rpc.rpc import RPCException @@ -222,7 +222,8 @@ def reload_config(rpc: RPC = Depends(get_rpc)): @router.get('/pair_candles', response_model=PairHistory, tags=['candle data']) -def pair_candles(pair: str, timeframe: str, limit: Optional[int], rpc: RPC = Depends(get_rpc)): +def pair_candles( + pair: str, timeframe: str, limit: Optional[int] = None, rpc: RPC = Depends(get_rpc)): return rpc._rpc_analysed_dataframe(pair, timeframe, limit) @@ -304,3 +305,8 @@ def list_available_pairs(timeframe: Optional[str] = None, stake_currency: Option @router.get('/sysinfo', response_model=SysInfo, tags=['info']) def sysinfo(): return RPC._rpc_sysinfo() + + +@router.get('/health', response_model=Health, tags=['info']) +def health(rpc: RPC = Depends(get_rpc)): + return rpc._health() diff --git a/freqtrade/rpc/fiat_convert.py b/freqtrade/rpc/fiat_convert.py index f65fd2d54..82a6a4778 100644 --- a/freqtrade/rpc/fiat_convert.py +++ b/freqtrade/rpc/fiat_convert.py @@ -17,6 +17,15 @@ from freqtrade.constants import SUPPORTED_FIAT logger = logging.getLogger(__name__) +# Manually map symbol to ID for some common coins +# with duplicate coingecko entries +coingecko_mapping = { + 'eth': 'ethereum', + 'bnb': 'binancecoin', + 'sol': 'solana', +} + + class CryptoToFiatConverter: """ Main class to initiate Crypto to FIAT. @@ -77,8 +86,9 @@ class CryptoToFiatConverter: else: return None found = [x for x in self._coinlistings if x['symbol'] == crypto_symbol] - if crypto_symbol == 'eth': - found = [x for x in self._coinlistings if x['id'] == 'ethereum'] + + if crypto_symbol in coingecko_mapping.keys(): + found = [x for x in self._coinlistings if x['id'] == coingecko_mapping[crypto_symbol]] if len(found) == 1: return found[0]['id'] diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index cc0402b41..91f7fce08 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -10,8 +10,9 @@ from typing import Any, Dict, List, Optional, Tuple, Union import arrow import psutil from dateutil.relativedelta import relativedelta +from dateutil.tz import tzlocal from numpy import NAN, inf, int64, mean -from pandas import DataFrame +from pandas import DataFrame, NaT from freqtrade import __version__ from freqtrade.configuration.timerange import TimeRange @@ -259,9 +260,11 @@ class RPC: profit_str ] if self._config.get('position_adjustment_enable', False): - max_buy = self._config['max_entry_position_adjustment'] + 1 + max_buy_str = '' + if self._config.get('max_entry_position_adjustment', -1) > 0: + max_buy_str = f"/{self._config['max_entry_position_adjustment'] + 1}" filled_buys = trade.nr_of_successful_buys - detail_trade.append(f"{filled_buys}/{max_buy}") + detail_trade.append(f"{filled_buys}{max_buy_str}") trades_list.append(detail_trade) profitcol = "Profit" if self._fiat_converter: @@ -269,7 +272,7 @@ class RPC: columns = ['ID L/S', 'Pair', 'Since', profitcol] if self._config.get('position_adjustment_enable', False): - columns.append('# Buys') + columns.append('# Entries') return trades_list, columns, fiat_profit_sum def _rpc_daily_profit( @@ -443,9 +446,9 @@ class RPC: trade_dur = (trade.close_date - trade.open_date).total_seconds() dur[trade_win_loss(trade)].append(trade_dur) - wins_dur = sum(dur['wins']) / len(dur['wins']) if len(dur['wins']) > 0 else 'N/A' - draws_dur = sum(dur['draws']) / len(dur['draws']) if len(dur['draws']) > 0 else 'N/A' - losses_dur = sum(dur['losses']) / len(dur['losses']) if len(dur['losses']) > 0 else 'N/A' + wins_dur = sum(dur['wins']) / len(dur['wins']) if len(dur['wins']) > 0 else None + draws_dur = sum(dur['draws']) / len(dur['draws']) if len(dur['draws']) > 0 else None + losses_dur = sum(dur['losses']) / len(dur['losses']) if len(dur['losses']) > 0 else None durations = {'wins': wins_dur, 'draws': draws_dur, 'losses': losses_dur} return {'sell_reasons': sell_reasons, 'durations': durations} @@ -972,8 +975,16 @@ class RPC: mask = (dataframe[sig_type] == 1) signals[sig_type] = int(mask.sum()) dataframe.loc[mask, f'_{sig_type}_signal_close'] = dataframe.loc[mask, 'close'] - dataframe = dataframe.replace([inf, -inf], NAN) - dataframe = dataframe.replace({NAN: None}) + + # band-aid until this is fixed: + # https://github.com/pandas-dev/pandas/issues/45836 + datetime_types = ['datetime', 'datetime64', 'datetime64[ns, UTC]'] + date_columns = dataframe.select_dtypes(include=datetime_types) + for date_column in date_columns: + # replace NaT with `None` + dataframe[date_column] = dataframe[date_column].astype(object).replace({NaT: None}) + + dataframe = dataframe.replace({inf: None, -inf: None, NAN: None}) res = { 'pair': pair, @@ -1052,3 +1063,11 @@ class RPC: "cpu_pct": psutil.cpu_percent(interval=1, percpu=True), "ram_pct": psutil.virtual_memory().percent } + + def _health(self) -> Dict[str, Union[str, int]]: + last_p = self._freqtrade.last_process + return { + 'last_process': str(last_p), + 'last_process_loc': last_p.astimezone(tzlocal()).strftime(DATETIME_PRINT_FORMAT), + 'last_process_ts': int(last_p.timestamp()), + } diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index a0c3f3daf..6cca9733c 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -117,7 +117,7 @@ class Telegram(RPCHandler): r'/logs$', r'/whitelist$', r'/blacklist$', r'/bl_delete$', r'/weekly$', r'/weekly \d+$', r'/monthly$', r'/monthly \d+$', r'/forcebuy$', r'/forcelong$', r'/forceshort$', - r'/edge$', r'/help$', r'/version$'] + r'/edge$', r'/health$', r'/help$', r'/version$'] # Create keys for generation valid_keys_print = [k.replace('$', '') for k in valid_keys] @@ -180,6 +180,7 @@ class Telegram(RPCHandler): CommandHandler(['blacklist_delete', 'bl_delete'], self._blacklist_delete), CommandHandler('logs', self._logs), CommandHandler('edge', self._edge), + CommandHandler('health', self._health), CommandHandler('help', self._help), CommandHandler('version', self._version), ] @@ -390,6 +391,48 @@ class Telegram(RPCHandler): else: return "\N{CROSS MARK}" + def _prepare_entry_details(self, filled_orders, base_currency, is_open): + """ + Prepare details of trade with entry adjustment enabled + """ + lines = [] + for x, order in enumerate(filled_orders): + cur_entry_datetime = arrow.get(order["order_filled_date"]) + cur_entry_amount = order["amount"] + cur_entry_average = order["average"] + lines.append(" ") + if x == 0: + lines.append("*Entry #{}:*".format(x+1)) + lines.append("*Entry Amount:* {} ({:.8f} {})" + .format(cur_entry_amount, order["cost"], base_currency)) + lines.append("*Average Entry Price:* {}".format(cur_entry_average)) + else: + sumA = 0 + sumB = 0 + for y in range(x): + sumA += (filled_orders[y]["amount"] * filled_orders[y]["average"]) + sumB += filled_orders[y]["amount"] + prev_avg_price = sumA/sumB + price_to_1st_entry = ((cur_entry_average - filled_orders[0]["average"]) + / filled_orders[0]["average"]) + minus_on_entry = (cur_entry_average - prev_avg_price)/prev_avg_price + dur_entry = cur_entry_datetime - arrow.get(filled_orders[x-1]["order_filled_date"]) + days = dur_entry.days + hours, remainder = divmod(dur_entry.seconds, 3600) + minutes, seconds = divmod(remainder, 60) + lines.append("*Entry #{}:* at {:.2%} avg profit".format(x+1, minus_on_entry)) + if is_open: + lines.append("({})".format(cur_entry_datetime + .humanize(granularity=["day", "hour", "minute"]))) + lines.append("*Entry Amount:* {} ({:.8f} {})" + .format(cur_entry_amount, order["cost"], base_currency)) + lines.append("*Average Entry Price:* {} ({:.2%} from 1st entry rate)" + .format(cur_entry_average, price_to_1st_entry)) + lines.append("*Order filled at:* {}".format(order["order_filled_date"])) + lines.append("({}d {}h {}m {}s from previous entry)" + .format(days, hours, minutes, seconds)) + return lines + @authorized_only def _status(self, update: Update, context: CallbackContext) -> None: """ @@ -413,39 +456,59 @@ class Telegram(RPCHandler): trade_ids = [int(i) for i in context.args if i.isnumeric()] results = self._rpc._rpc_trade_status(trade_ids=trade_ids) - + position_adjust = self._config.get('position_adjustment_enable', False) + max_entries = self._config.get('max_entry_position_adjustment', -1) messages = [] for r in results: r['open_date_hum'] = arrow.get(r['open_date']).humanize() + r['num_entries'] = len(r['filled_entry_orders']) + r['sell_reason'] = r.get('sell_reason', "") lines = [ - "*Trade ID:* `{trade_id}` `(since {open_date_hum})`", + "*Trade ID:* `{trade_id}`" + + ("` (since {open_date_hum})`" if r['is_open'] else ""), "*Current Pair:* {pair}", "*Direction:* " + ("`Short`" if r.get('is_short') else "`Long`"), "*Leverage:* `{leverage}`" if r.get('leverage') else "", "*Amount:* `{amount} ({stake_amount} {base_currency})`", "*Enter Tag:* `{enter_tag}`" if r['enter_tag'] else "", + "*Exit Reason:* `{sell_reason}`" if r['sell_reason'] else "", + ] + + if position_adjust: + max_buy_str = (f"/{max_entries + 1}" if (max_entries > 0) else "") + lines.append("*Number of Entries:* `{num_entries}`" + max_buy_str) + + lines.extend([ "*Open Rate:* `{open_rate:.8f}`", - "*Close Rate:* `{close_rate}`" if r['close_rate'] else "", - "*Current Rate:* `{current_rate:.8f}`", + "*Close Rate:* `{close_rate:.8f}`" if r['close_rate'] else "", + "*Open Date:* `{open_date}`", + "*Close Date:* `{close_date}`" if r['close_date'] else "", + "*Current Rate:* `{current_rate:.8f}`" if r['is_open'] else "", ("*Current Profit:* " if r['is_open'] else "*Close Profit: *") + "`{profit_ratio:.2%}`", - ] - if (r['stop_loss_abs'] != r['initial_stop_loss_abs'] - and r['initial_stop_loss_ratio'] is not None): - # Adding initial stoploss only if it is different from stoploss - lines.append("*Initial Stoploss:* `{initial_stop_loss_abs:.8f}` " - "`({initial_stop_loss_ratio:.2%})`") + ]) - # Adding stoploss and stoploss percentage only if it is not None - lines.append("*Stoploss:* `{stop_loss_abs:.8f}` " + - ("`({stop_loss_ratio:.2%})`" if r['stop_loss_ratio'] else "")) - lines.append("*Stoploss distance:* `{stoploss_current_dist:.8f}` " - "`({stoploss_current_dist_ratio:.2%})`") - if r['open_order']: - if r['sell_order_status']: - lines.append("*Open Order:* `{open_order}` - `{sell_order_status}`") - else: - lines.append("*Open Order:* `{open_order}`") + if r['is_open']: + if (r['stop_loss_abs'] != r['initial_stop_loss_abs'] + and r['initial_stop_loss_ratio'] is not None): + # Adding initial stoploss only if it is different from stoploss + lines.append("*Initial Stoploss:* `{initial_stop_loss_abs:.8f}` " + "`({initial_stop_loss_ratio:.2%})`") + + # Adding stoploss and stoploss percentage only if it is not None + lines.append("*Stoploss:* `{stop_loss_abs:.8f}` " + + ("`({stop_loss_ratio:.2%})`" if r['stop_loss_ratio'] else "")) + lines.append("*Stoploss distance:* `{stoploss_current_dist:.8f}` " + "`({stoploss_current_dist_ratio:.2%})`") + if r['open_order']: + if r['sell_order_status']: + lines.append("*Open Order:* `{open_order}` - `{sell_order_status}`") + else: + lines.append("*Open Order:* `{open_order}`") + + lines_detail = self._prepare_entry_details( + r['filled_entry_orders'], r['base_currency'], r['is_open']) + lines.extend((lines_detail if (len(r['filled_entry_orders']) > 1) else "")) # Filter empty lines using list-comprehension messages.append("\n".join([line for line in lines if line]).format(**r)) @@ -726,9 +789,9 @@ class Telegram(RPCHandler): duration_msg = tabulate( [ ['Wins', str(timedelta(seconds=durations['wins'])) - if durations['wins'] != 'N/A' else 'N/A'], + if durations['wins'] is not None else 'N/A'], ['Losses', str(timedelta(seconds=durations['losses'])) - if durations['losses'] != 'N/A' else 'N/A'] + if durations['losses'] is not None else 'N/A'] ], headers=['', 'Avg. Duration'] ) @@ -1318,6 +1381,7 @@ class Telegram(RPCHandler): "*/logs [limit]:* `Show latest logs - defaults to 10` \n" "*/count:* `Show number of active trades compared to allowed number of trades`\n" "*/edge:* `Shows validated pairs by Edge if it is enabled` \n" + "*/health* `Show latest process timestamp - defaults to 1970-01-01 00:00:00` \n" "_Statistics_\n" "------------\n" @@ -1345,6 +1409,19 @@ class Telegram(RPCHandler): self._send_msg(message, parse_mode=ParseMode.MARKDOWN) + @authorized_only + def _health(self, update: Update, context: CallbackContext) -> None: + """ + Handler for /health + Shows the last process timestamp + """ + try: + health = self._rpc._health() + message = f"Last process: `{health['last_process_loc']}`" + self._send_msg(message) + except RPCException as e: + self._send_msg(str(e)) + @authorized_only def _version(self, update: Update, context: CallbackContext) -> None: """ diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 1b7ba926d..84e4a69af 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -18,6 +18,7 @@ from freqtrade.exceptions import OperationalException, StrategyError 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.persistence.models import LocalTrade, Order from freqtrade.strategy.hyper import HyperStrategyMixin from freqtrade.strategy.informative_decorator import (InformativeData, PopulateIndicators, _create_and_merge_informative_pair, @@ -991,23 +992,22 @@ class IStrategy(ABC, HyperStrategyMixin): else: return current_profit > roi - def ft_check_timed_out(self, side: str, trade: Trade, order: Dict, + def ft_check_timed_out(self, side: str, trade: LocalTrade, order: Order, current_time: datetime) -> bool: """ FT Internal method. Check if timeout is active, and if the order is still open and timed out """ timeout = self.config.get('unfilledtimeout', {}).get(side) - ordertime = arrow.get(order['datetime']).datetime if timeout is not None: timeout_unit = self.config.get('unfilledtimeout', {}).get('unit', 'minutes') timeout_kwargs = {timeout_unit: -timeout} timeout_threshold = current_time + timedelta(**timeout_kwargs) - timedout = (order['status'] == 'open' and order['side'] == side - and ordertime < timeout_threshold) + timedout = (order.status == 'open' and order.side == side + and order.order_date_utc < timeout_threshold) if timedout: return True - time_method = self.check_sell_timeout if order['side'] == 'sell' else self.check_buy_timeout + time_method = self.check_sell_timeout if order.side == 'sell' else self.check_buy_timeout return strategy_safe_wrapper(time_method, default_retval=False)( diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index e6939a7d9..9659c63d9 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -3,7 +3,7 @@ import logging from copy import deepcopy -from typing import Any, Dict, NamedTuple +from typing import Any, Dict, NamedTuple, Optional import arrow @@ -211,7 +211,7 @@ class Wallets: return stake_amount - def get_trade_stake_amount(self, pair: str, edge=None) -> float: + def get_trade_stake_amount(self, pair: str, edge=None, update: bool = True) -> float: """ Calculate stake amount for the trade :return: float: Stake amount @@ -219,7 +219,8 @@ class Wallets: """ stake_amount: float # Ensure wallets are uptodate. - self.update() + if update: + self.update() val_tied_up = Trade.total_open_trades_stakes() available_amount = self.get_available_stake_amount() @@ -238,14 +239,15 @@ class Wallets: return self._check_available_stake_amount(stake_amount, available_amount) - def validate_stake_amount(self, pair, stake_amount, min_stake_amount, max_stake_amount): + def validate_stake_amount(self, pair: str, stake_amount: Optional[float], + min_stake_amount: Optional[float], max_stake_amount: float): if not stake_amount: logger.debug(f"Stake amount is {stake_amount}, ignoring possible trade for {pair}.") return 0 max_stake_amount = min(max_stake_amount, self.get_available_stake_amount()) - if min_stake_amount > max_stake_amount: + if min_stake_amount is not None and min_stake_amount > max_stake_amount: if self._log: logger.warning("Minimum stake amount > available balance.") return 0 diff --git a/requirements-dev.txt b/requirements-dev.txt index 59867ff3a..3fcdaab63 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -7,23 +7,23 @@ coveralls==3.3.1 flake8==4.0.1 flake8-tidy-imports==4.6.0 mypy==0.931 -pytest==6.2.5 +pytest==7.0.0 pytest-asyncio==0.17.2 pytest-cov==3.0.0 -pytest-mock==3.6.1 +pytest-mock==3.7.0 pytest-random-order==1.0.4 isort==5.10.1 # For datetime mocking time-machine==2.6.0 # Convert jupyter notebooks to markdown documents -nbconvert==6.4.0 +nbconvert==6.4.1 # mypy types types-cachetools==4.2.9 types-filelock==3.2.5 -types-requests==2.27.7 +types-requests==2.27.8 types-tabulate==0.8.5 # Extensions to datetime library -types-python-dateutil==2.8.8 \ No newline at end of file +types-python-dateutil==2.8.9 \ No newline at end of file diff --git a/requirements-hyperopt.txt b/requirements-hyperopt.txt index 122243bf2..e4698918a 100644 --- a/requirements-hyperopt.txt +++ b/requirements-hyperopt.txt @@ -2,7 +2,7 @@ -r requirements.txt # Required for hyperopt -scipy==1.7.3 +scipy==1.8.0 scikit-learn==1.0.2 scikit-optimize==0.9.0 filelock==3.4.2 diff --git a/requirements.txt b/requirements.txt index 489f48c61..160c8ba56 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,14 @@ -numpy==1.22.1 +numpy==1.22.2 pandas==1.4.0 pandas-ta==0.3.14b -ccxt==1.72.29 +ccxt==1.72.36 # Pin cryptography for now due to rust build errors with piwheels cryptography==36.0.1 aiohttp==3.8.1 SQLAlchemy==1.4.31 -python-telegram-bot==13.10 -arrow==1.2.1 +python-telegram-bot==13.11 +arrow==1.2.2 cachetools==4.2.2 requests==2.27.1 urllib3==1.26.8 @@ -32,7 +32,7 @@ sdnotify==0.3.2 # API Server fastapi==0.73.0 -uvicorn==0.17.0 +uvicorn==0.17.4 pyjwt==2.3.0 aiofiles==0.8.0 psutil==5.9.0 @@ -41,7 +41,7 @@ psutil==5.9.0 colorama==0.4.4 # Building config files interactively questionary==1.10.0 -prompt-toolkit==3.0.24 +prompt-toolkit==3.0.26 # Extensions to datetime library python-dateutil==2.8.2 diff --git a/setup.sh b/setup.sh index 1df9df606..e1c9b1cba 100755 --- a/setup.sh +++ b/setup.sh @@ -36,7 +36,7 @@ function check_installed_python() { fi done - echo "No usable python found. Please make sure to have python3.7 or newer installed." + echo "No usable python found. Please make sure to have python3.8 or newer installed." exit 1 } diff --git a/tests/conftest.py b/tests/conftest.py index 9e85260b2..e6754a6a1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,13 +20,14 @@ from freqtrade.edge import PairInfo from freqtrade.enums import CandleType, MarginMode, RunMode, SignalDirection, TradingMode from freqtrade.exchange import Exchange from freqtrade.freqtradebot import FreqtradeBot -from freqtrade.persistence import LocalTrade, Trade, init_db +from freqtrade.persistence import LocalTrade, Order, Trade, init_db from freqtrade.resolvers import ExchangeResolver from freqtrade.worker import Worker from tests.conftest_trades import (leverage_trade, mock_trade_1, mock_trade_2, mock_trade_3, mock_trade_4, mock_trade_5, mock_trade_6, short_trade) from tests.conftest_trades_usdt import (mock_trade_usdt_1, mock_trade_usdt_2, mock_trade_usdt_3, - mock_trade_usdt_4, mock_trade_usdt_5, mock_trade_usdt_6) + mock_trade_usdt_4, mock_trade_usdt_5, mock_trade_usdt_6, + mock_trade_usdt_7) logging.getLogger('').setLevel(logging.INFO) @@ -348,6 +349,8 @@ def create_mock_trades_usdt(fee, use_db: bool = True): trade = mock_trade_usdt_6(fee) add_trade(trade) + trade = mock_trade_usdt_7(fee) + add_trade(trade) if use_db: Trade.commit() @@ -2352,7 +2355,7 @@ def import_fails() -> None: @pytest.fixture(scope="function") def open_trade(): - return Trade( + trade = Trade( pair='ETH/BTC', open_rate=0.00001099, exchange='binance', @@ -2364,11 +2367,31 @@ def open_trade(): open_date=arrow.utcnow().shift(minutes=-601).datetime, is_open=True ) + trade.orders = [ + Order( + ft_order_side='buy', + ft_pair=trade.pair, + ft_is_open=False, + order_id='123456789', + status="closed", + symbol=trade.pair, + order_type="market", + side="buy", + price=trade.open_rate, + average=trade.open_rate, + filled=trade.amount, + remaining=0, + cost=trade.open_rate * trade.amount, + order_date=trade.open_date, + order_filled_date=trade.open_date, + ) + ] + return trade @pytest.fixture(scope="function") def open_trade_usdt(): - return Trade( + trade = Trade( pair='ADA/USDT', open_rate=2.0, exchange='binance', @@ -2380,6 +2403,26 @@ def open_trade_usdt(): open_date=arrow.utcnow().shift(minutes=-601).datetime, is_open=True ) + trade.orders = [ + Order( + ft_order_side='buy', + ft_pair=trade.pair, + ft_is_open=False, + order_id='123456789', + status="closed", + symbol=trade.pair, + order_type="market", + side="buy", + price=trade.open_rate, + average=trade.open_rate, + filled=trade.amount, + remaining=0, + cost=trade.open_rate * trade.amount, + order_date=trade.open_date, + order_filled_date=trade.open_date, + ) + ] + return trade @pytest.fixture diff --git a/tests/conftest_trades.py b/tests/conftest_trades.py index 51071396f..3e3ba9495 100644 --- a/tests/conftest_trades.py +++ b/tests/conftest_trades.py @@ -26,6 +26,7 @@ def mock_order_1(is_short: bool): 'side': enter_side(is_short), 'type': 'limit', 'price': 0.123, + 'average': 0.123, 'amount': 123.0, 'filled': 123.0, 'remaining': 0.0, diff --git a/tests/conftest_trades_usdt.py b/tests/conftest_trades_usdt.py index 1a03f0381..508e54f03 100644 --- a/tests/conftest_trades_usdt.py +++ b/tests/conftest_trades_usdt.py @@ -303,3 +303,61 @@ def mock_trade_usdt_6(fee): o = Order.parse_from_ccxt_object(mock_order_usdt_6_sell(), 'LTC/USDT', 'sell') trade.orders.append(o) return trade + + +def mock_order_usdt_7(): + return { + 'id': 'prod_buy_7', + 'symbol': 'LTC/USDT', + 'status': 'closed', + 'side': 'buy', + 'type': 'limit', + 'price': 10.0, + 'amount': 2.0, + 'filled': 2.0, + 'remaining': 0.0, + } + + +def mock_order_usdt_7_sell(): + return { + 'id': 'prod_sell_7', + 'symbol': 'LTC/USDT', + 'status': 'closed', + 'side': 'sell', + 'type': 'limit', + 'price': 8.0, + 'amount': 2.0, + 'filled': 2.0, + 'remaining': 0.0, + } + + +def mock_trade_usdt_7(fee): + """ + Simulate prod entry with open sell order + """ + trade = Trade( + pair='LTC/USDT', + stake_amount=20.0, + amount=2.0, + amount_requested=2.0, + open_date=datetime.now(tz=timezone.utc) - timedelta(minutes=20), + close_date=datetime.now(tz=timezone.utc) - timedelta(minutes=5), + fee_open=fee.return_value, + fee_close=fee.return_value, + is_open=False, + open_rate=10.0, + close_rate=8.0, + close_profit=-0.2, + close_profit_abs=-4.0, + exchange='binance', + strategy='SampleStrategy', + open_order_id="prod_sell_6", + timeframe=5, + ) + o = Order.parse_from_ccxt_object(mock_order_usdt_7(), 'LTC/USDT', 'buy') + trade.orders.append(o) + o = Order.parse_from_ccxt_object(mock_order_usdt_7_sell(), 'LTC/USDT', 'sell') + trade.orders.append(o) + return trade diff --git a/tests/exchange/test_ccxt_compat.py b/tests/exchange/test_ccxt_compat.py index 08b17add8..3bf9f5b6d 100644 --- a/tests/exchange/test_ccxt_compat.py +++ b/tests/exchange/test_ccxt_compat.py @@ -84,7 +84,7 @@ EXCHANGES = { 'futures': True, } }, - 'okex': { + 'okx': { 'pair': 'BTC/USDT', 'stake_currency': 'USDT', 'hasQuoteVolume': True, diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 35ca9a9c3..f5d0d0fd2 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -3202,9 +3202,9 @@ def test_timeframe_to_next_date(): ("BTC-PERP", 'BTC', 'USD', "ftx", False, False, True, 'margin', {}, False), ("BTC-PERP", 'BTC', 'USD', "ftx", False, False, True, 'futures', {}, True), - ("BTC/USDT:USDT", 'BTC', 'USD', "okex", False, False, True, 'spot', {}, False), - ("BTC/USDT:USDT", 'BTC', 'USD', "okex", False, False, True, 'margin', {}, False), - ("BTC/USDT:USDT", 'BTC', 'USD', "okex", False, False, True, 'futures', {}, True), + ("BTC/USDT:USDT", 'BTC', 'USD', "okx", False, False, True, 'spot', {}, False), + ("BTC/USDT:USDT", 'BTC', 'USD', "okx", False, False, True, 'margin', {}, False), + ("BTC/USDT:USDT", 'BTC', 'USD', "okx", False, False, True, 'futures', {}, True), ]) def test_market_is_tradable( mocker, default_conf, market_symbol, base, @@ -3479,16 +3479,16 @@ def test_set_margin_mode(mocker, default_conf, margin_mode): ("bittrex", TradingMode.FUTURES, MarginMode.CROSS, True), ("bittrex", TradingMode.FUTURES, MarginMode.ISOLATED, True), ("gateio", TradingMode.MARGIN, MarginMode.ISOLATED, True), - ("okex", TradingMode.SPOT, None, False), - ("okex", TradingMode.MARGIN, MarginMode.CROSS, True), - ("okex", TradingMode.MARGIN, MarginMode.ISOLATED, True), - ("okex", TradingMode.FUTURES, MarginMode.CROSS, True), + ("okx", TradingMode.SPOT, None, False), + ("okx", TradingMode.MARGIN, MarginMode.CROSS, True), + ("okx", TradingMode.MARGIN, MarginMode.ISOLATED, True), + ("okx", TradingMode.FUTURES, MarginMode.CROSS, True), ("binance", TradingMode.FUTURES, MarginMode.ISOLATED, False), ("gateio", TradingMode.FUTURES, MarginMode.ISOLATED, False), # * Remove once implemented - ("okex", TradingMode.FUTURES, MarginMode.ISOLATED, True), + ("okx", TradingMode.FUTURES, MarginMode.ISOLATED, True), ("binance", TradingMode.MARGIN, MarginMode.CROSS, True), ("binance", TradingMode.FUTURES, MarginMode.CROSS, True), ("kraken", TradingMode.MARGIN, MarginMode.CROSS, True), @@ -3499,7 +3499,7 @@ def test_set_margin_mode(mocker, default_conf, margin_mode): ("gateio", TradingMode.FUTURES, MarginMode.CROSS, True), # * Uncomment once implemented - # ("okex", TradingMode.FUTURES, MarginMode.ISOLATED, False), + # ("okx", TradingMode.FUTURES, MarginMode.ISOLATED, False), # ("binance", TradingMode.MARGIN, MarginMode.CROSS, False), # ("binance", TradingMode.FUTURES, MarginMode.CROSS, False), # ("kraken", TradingMode.MARGIN, MarginMode.CROSS, False), @@ -3539,7 +3539,7 @@ def test_validate_trading_mode_and_margin_mode( ("hitbtc", "futures", {"options": {"defaultType": "swap"}}), ("kraken", "futures", {"options": {"defaultType": "swap"}}), ("kucoin", "futures", {"options": {"defaultType": "swap"}}), - ("okex", "futures", {"options": {"defaultType": "swap"}}), + ("okx", "futures", {"options": {"defaultType": "swap"}}), ]) def test__ccxt_config( default_conf, diff --git a/tests/optimize/__init__.py b/tests/optimize/__init__.py index e9827172f..29b7fd210 100644 --- a/tests/optimize/__init__.py +++ b/tests/optimize/__init__.py @@ -36,6 +36,8 @@ class BTContainer(NamedTuple): trailing_stop_positive_offset: float = 0.0 use_sell_signal: bool = False use_custom_stoploss: bool = False + custom_entry_price: Optional[float] = None + custom_exit_price: Optional[float] = None leverage: float = 1.0 diff --git a/tests/optimize/test_backtest_detail.py b/tests/optimize/test_backtest_detail.py index b89fbe8aa..ee13715f6 100644 --- a/tests/optimize/test_backtest_detail.py +++ b/tests/optimize/test_backtest_detail.py @@ -1,5 +1,6 @@ # pragma pylint: disable=missing-docstring, W0212, line-too-long, C0103, C0330, unused-argument import logging +from unittest.mock import MagicMock import pytest @@ -534,11 +535,84 @@ tc33 = BTContainer(data=[ )] ) -# Test 34: (copy of test25 with leverage) +# Test 34: Custom-entry-price below all candles should timeout - so no trade happens. +tc34 = BTContainer(data=[ + # D O H L C V B S + [0, 5000, 5050, 4950, 5000, 6172, 1, 0], + [1, 5000, 5500, 4951, 5000, 6172, 0, 0], # timeout + [2, 4900, 5250, 4500, 5100, 6172, 0, 0], + [3, 5100, 5100, 4650, 4750, 6172, 0, 0], + [4, 4750, 4950, 4350, 4750, 6172, 0, 0]], + stop_loss=-0.01, roi={"0": 0.10}, profit_perc=0.0, + custom_entry_price=4200, trades=[] +) + +# Test 35: Custom-entry-price above all candles should have rate adjusted to "entry candle high" +tc35 = BTContainer(data=[ + # D O H L C V B S + [0, 5000, 5050, 4950, 5000, 6172, 1, 0], + [1, 5000, 5500, 4951, 5000, 6172, 0, 0], # Timeout + [2, 4900, 5250, 4500, 5100, 6172, 0, 0], + [3, 5100, 5100, 4650, 4750, 6172, 0, 0], + [4, 4750, 4950, 4350, 4750, 6172, 0, 0]], + stop_loss=-0.01, roi={"0": 0.10}, profit_perc=-0.01, + custom_entry_price=7200, trades=[ + BTrade(sell_reason=SellType.STOP_LOSS, open_tick=1, close_tick=1) + ] +) + +# Test 36: Custom-entry-price around candle low +# Causes immediate ROI exit. This is currently expected behavior (#6261) +# https://github.com/freqtrade/freqtrade/issues/6261 +# But may change at a later point. +tc36 = BTContainer(data=[ + # D O H L C V B S BT + [0, 5000, 5050, 4950, 5000, 6172, 1, 0], + [1, 5000, 5500, 4951, 5000, 6172, 0, 0], # Enter and immediate ROI + [2, 4900, 5250, 4500, 5100, 6172, 0, 0], + [3, 5100, 5100, 4650, 4750, 6172, 0, 0], + [4, 4750, 4950, 4350, 4750, 6172, 0, 0]], + stop_loss=-0.01, roi={"0": 0.10}, profit_perc=0.1, + custom_entry_price=4952, + trades=[BTrade(sell_reason=SellType.ROI, open_tick=1, close_tick=1)] +) + + +# Test 37: Custom exit price below all candles +# Price adjusted to candle Low. +tc37 = BTContainer(data=[ + # D O H L C V B S BT + [0, 5000, 5050, 4950, 5000, 6172, 1, 0], + [1, 5000, 5500, 4951, 5000, 6172, 0, 0], + [2, 4900, 5250, 4900, 5100, 6172, 0, 1], # exit - but timeout + [3, 5100, 5100, 4950, 4950, 6172, 0, 0], + [4, 5000, 5100, 4950, 4950, 6172, 0, 0]], + stop_loss=-0.10, roi={"0": 0.10}, profit_perc=-0.01, + use_sell_signal=True, + custom_exit_price=4552, + trades=[BTrade(sell_reason=SellType.SELL_SIGNAL, open_tick=1, close_tick=3)] +) + +# Test 38: Custom exit price above all candles +# causes sell signal timeout +tc38 = BTContainer(data=[ + # D O H L C V B S BT + [0, 5000, 5050, 4950, 5000, 6172, 1, 0], + [1, 5000, 5500, 4951, 5000, 6172, 0, 0], + [2, 4900, 5250, 4900, 5100, 6172, 0, 1], # exit - but timeout + [3, 5100, 5100, 4950, 4950, 6172, 0, 0], + [4, 5000, 5100, 4950, 4950, 6172, 0, 0]], + stop_loss=-0.10, roi={"0": 0.10}, profit_perc=0.0, + use_sell_signal=True, + custom_exit_price=6052, + trades=[BTrade(sell_reason=SellType.FORCE_SELL, open_tick=1, close_tick=4)] +) + +# Test 39: (copy of test25 with leverage) # Sell with signal sell in candle 3 (stoploss also triggers on this candle) # Stoploss at 1%. # Sell-signal wins over stoploss -tc34 = BTContainer(data=[ +tc39 = BTContainer(data=[ # D O H L C V B S [0, 5000, 5025, 4975, 4987, 6172, 1, 0], [1, 5000, 5025, 4975, 4987, 6172, 0, 0], # enter trade (signal on last candle) @@ -551,6 +625,7 @@ tc34 = BTContainer(data=[ trades=[BTrade(sell_reason=SellType.SELL_SIGNAL, open_tick=1, close_tick=4)] ) + TESTS = [ tc0, tc1, @@ -587,6 +662,11 @@ TESTS = [ tc32, tc33, tc34, + tc35, + tc36, + tc37, + tc38, + tc39, # TODO-lev: Add tests for short here ] @@ -621,6 +701,10 @@ def test_backtest_results(default_conf, fee, mocker, caplog, data) -> None: backtesting._can_short = True backtesting.strategy.advise_entry = lambda a, m: frame backtesting.strategy.advise_exit = lambda a, m: frame + if data.custom_entry_price: + backtesting.strategy.custom_entry_price = MagicMock(return_value=data.custom_entry_price) + if data.custom_exit_price: + backtesting.strategy.custom_exit_price = MagicMock(return_value=data.custom_exit_price) backtesting.strategy.use_custom_stoploss = data.use_custom_stoploss backtesting.strategy.leverage = lambda **kwargs: data.leverage caplog.set_level(logging.DEBUG) diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index 56b815d68..140c818fc 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -21,6 +21,7 @@ from freqtrade.data.dataprovider import DataProvider from freqtrade.data.history import get_timerange from freqtrade.enums import RunMode, SellType from freqtrade.exceptions import DependencyException, OperationalException +from freqtrade.exchange.exchange import timeframe_to_next_date from freqtrade.misc import get_strategy_run_id from freqtrade.optimize.backtesting import Backtesting from freqtrade.persistence import LocalTrade @@ -524,6 +525,7 @@ def test_backtest__enter_trade(default_conf, fee, mocker) -> None: # Fake 2 trades, so there's not enough amount for the next trade left. LocalTrade.trades_open.append(trade) LocalTrade.trades_open.append(trade) + backtesting.wallets.update() trade = backtesting._enter_trade(pair, row=row, direction='long') assert trade is None LocalTrade.trades_open.pop() @@ -531,6 +533,7 @@ def test_backtest__enter_trade(default_conf, fee, mocker) -> None: assert trade is not None backtesting.strategy.custom_stake_amount = lambda **kwargs: 123.5 + backtesting.wallets.update() trade = backtesting._enter_trade(pair, row=row, direction='long') assert trade assert trade.stake_amount == 123.5 @@ -659,7 +662,8 @@ def test_backtest__get_sell_trade_entry(default_conf, fee, mocker) -> None: assert res.sell_reason == SellType.ROI.value # Sell at minute 3 (not available above!) assert res.close_date_utc == datetime(2020, 1, 1, 5, 3, tzinfo=timezone.utc) - assert round(res.close_rate, 3) == round(209.0225, 3) + sell_order = res.select_order('sell', True) + assert sell_order is not None def test_backtest_one(default_conf, fee, mocker, testdatadir) -> None: @@ -676,6 +680,7 @@ def test_backtest_one(default_conf, fee, mocker, testdatadir) -> None: timerange=timerange) processed = backtesting.strategy.advise_all_indicators(data) min_date, max_date = get_timerange(processed) + result = backtesting.backtest( processed=deepcopy(processed), start_date=min_date, @@ -769,6 +774,47 @@ def test_processed(default_conf, mocker, testdatadir) -> None: assert col in cols +def test_backtest_dataprovider_analyzed_df(default_conf, fee, mocker, testdatadir) -> None: + default_conf['use_sell_signal'] = False + mocker.patch('freqtrade.exchange.Exchange.get_fee', fee) + mocker.patch("freqtrade.exchange.Exchange.get_min_pair_stake_amount", return_value=0.00001) + mocker.patch("freqtrade.exchange.Exchange.get_max_pair_stake_amount", return_value=100000) + patch_exchange(mocker) + backtesting = Backtesting(default_conf) + backtesting._set_strategy(backtesting.strategylist[0]) + timerange = TimeRange('date', None, 1517227800, 0) + data = history.load_data(datadir=testdatadir, timeframe='5m', pairs=['UNITTEST/BTC'], + timerange=timerange) + processed = backtesting.strategy.advise_all_indicators(data) + min_date, max_date = get_timerange(processed) + + global count + count = 0 + + def tmp_confirm_entry(pair, current_time, **kwargs): + dp = backtesting.strategy.dp + df, _ = dp.get_analyzed_dataframe(pair, backtesting.strategy.timeframe) + current_candle = df.iloc[-1].squeeze() + assert current_candle['enter_long'] == 1 + + candle_date = timeframe_to_next_date(backtesting.strategy.timeframe, current_candle['date']) + assert candle_date == current_time + # These asserts don't properly raise as they are nested, + # therefore we increment count and assert for that. + global count + count = count + 1 + + backtesting.strategy.confirm_trade_entry = tmp_confirm_entry + backtesting.backtest( + processed=deepcopy(processed), + start_date=min_date, + end_date=max_date, + max_open_trades=10, + position_stacking=False, + ) + assert count == 5 + + def test_backtest_pricecontours_protections(default_conf, fee, mocker, testdatadir) -> None: # While this test IS a copy of test_backtest_pricecontours, it's needed to ensure # results do not carry-over to the next run, which is not given by using parametrize. @@ -1013,6 +1059,8 @@ def test_backtest_start_multi_strat(default_conf, mocker, caplog, testdatadir): 'config': default_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, }) mocker.patch('freqtrade.plugins.pairlistmanager.PairListManager.whitelist', @@ -1124,6 +1172,8 @@ def test_backtest_start_multi_strat_nomock(default_conf, mocker, caplog, testdat 'config': default_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, }, { @@ -1131,6 +1181,8 @@ def test_backtest_start_multi_strat_nomock(default_conf, mocker, caplog, testdat 'config': default_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, } ]) @@ -1238,6 +1290,8 @@ def test_backtest_start_nomock_futures(default_conf_usdt, mocker, 'config': default_conf_usdt, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, }, { @@ -1245,6 +1299,8 @@ def test_backtest_start_nomock_futures(default_conf_usdt, mocker, 'config': default_conf_usdt, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, } ]) @@ -1337,6 +1393,8 @@ def test_backtest_start_multi_strat_nomock_detail(default_conf, mocker, 'config': default_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, }, { @@ -1344,6 +1402,8 @@ def test_backtest_start_multi_strat_nomock_detail(default_conf, mocker, 'config': default_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, } ]) @@ -1405,6 +1465,8 @@ def test_backtest_start_multi_strat_caching(default_conf, mocker, caplog, testda 'config': default_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, }) mocker.patch('freqtrade.plugins.pairlistmanager.PairListManager.whitelist', diff --git a/tests/optimize/test_hyperopt.py b/tests/optimize/test_hyperopt.py index 6419e65f3..cbaacc9c5 100644 --- a/tests/optimize/test_hyperopt.py +++ b/tests/optimize/test_hyperopt.py @@ -365,6 +365,8 @@ def test_hyperopt_format_results(hyperopt): 'locks': [], 'final_balance': 0.02, 'rejected_signals': 2, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'backtest_start_time': 1619718665, 'backtest_end_time': 1619718665, } @@ -433,6 +435,8 @@ def test_generate_optimizer(mocker, hyperopt_conf) -> None: 'config': hyperopt_conf, 'locks': [], 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'final_balance': 1000, } diff --git a/tests/optimize/test_hyperoptloss.py b/tests/optimize/test_hyperoptloss.py index e4a2eec2e..e3f6daf6c 100644 --- a/tests/optimize/test_hyperoptloss.py +++ b/tests/optimize/test_hyperoptloss.py @@ -86,6 +86,7 @@ def test_loss_calculation_has_limited_profit(hyperopt_conf, hyperopt_results) -> "SharpeHyperOptLossDaily", "MaxDrawDownHyperOptLoss", "CalmarHyperOptLoss", + "ProfitDrawDownHyperOptLoss", ]) def test_loss_functions_better_profits(default_conf, hyperopt_results, lossfunction) -> None: @@ -106,7 +107,7 @@ def test_loss_functions_better_profits(default_conf, hyperopt_results, lossfunct config=default_conf, processed=None, backtest_stats={'profit_total': hyperopt_results['profit_abs'].sum()} - ) + ) over = hl.hyperopt_loss_function( results_over, trade_count=len(results_over), diff --git a/tests/optimize/test_optimize_reports.py b/tests/optimize/test_optimize_reports.py index 526d19730..a0f1f74b0 100644 --- a/tests/optimize/test_optimize_reports.py +++ b/tests/optimize/test_optimize_reports.py @@ -84,6 +84,8 @@ def test_generate_backtest_stats(default_conf, testdatadir, tmpdir): 'locks': [], 'final_balance': 1000.02, 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'backtest_start_time': Arrow.utcnow().int_timestamp, 'backtest_end_time': Arrow.utcnow().int_timestamp, 'run_id': '123', @@ -134,6 +136,8 @@ def test_generate_backtest_stats(default_conf, testdatadir, tmpdir): 'locks': [], 'final_balance': 1000.02, 'rejected_signals': 20, + 'timedout_entry_orders': 0, + 'timedout_exit_orders': 0, 'backtest_start_time': Arrow.utcnow().int_timestamp, 'backtest_end_time': Arrow.utcnow().int_timestamp, 'run_id': '124', diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 797ea2e5c..8d7e2b5c1 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -4,6 +4,7 @@ import logging import time from unittest.mock import MagicMock, PropertyMock +import pandas as pd import pytest import time_machine @@ -14,7 +15,7 @@ from freqtrade.persistence import Trade from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist from freqtrade.plugins.pairlistmanager import PairListManager from freqtrade.resolvers import PairListResolver -from tests.conftest import (create_mock_trades, get_patched_exchange, get_patched_freqtradebot, +from tests.conftest import (create_mock_trades_usdt, get_patched_exchange, get_patched_freqtradebot, log_has, log_has_re, num_log_has) @@ -492,7 +493,7 @@ def test_VolumePairList_whitelist_gen(mocker, whitelist_conf, shitcoinmarkets, t ohlcv_data = { ('ETH/BTC', '1d', CandleType.SPOT): ohlcv_history, ('TKN/BTC', '1d', CandleType.SPOT): ohlcv_history, - ('LTC/BTC', '1d', CandleType.SPOT): ohlcv_history.append(ohlcv_history), + ('LTC/BTC', '1d', CandleType.SPOT): pd.concat([ohlcv_history, ohlcv_history]), ('XRP/BTC', '1d', CandleType.SPOT): ohlcv_history, ('HOT/BTC', '1d', CandleType.SPOT): ohlcv_history_high_vola, } @@ -714,29 +715,58 @@ def test_ShuffleFilter_init(mocker, whitelist_conf, caplog) -> None: @pytest.mark.usefixtures("init_persistence") -def test_PerformanceFilter_lookback(mocker, whitelist_conf, fee, caplog) -> None: - whitelist_conf['exchange']['pair_whitelist'].append('XRP/BTC') - whitelist_conf['pairlists'] = [ +def test_PerformanceFilter_lookback(mocker, default_conf_usdt, fee, caplog) -> None: + default_conf_usdt['exchange']['pair_whitelist'].extend(['ADA/USDT', 'XRP/USDT', 'ETC/USDT']) + default_conf_usdt['pairlists'] = [ {"method": "StaticPairList"}, {"method": "PerformanceFilter", "minutes": 60, "min_profit": 0.01} ] mocker.patch('freqtrade.exchange.Exchange.exchange_has', MagicMock(return_value=True)) - exchange = get_patched_exchange(mocker, whitelist_conf) - pm = PairListManager(exchange, whitelist_conf) + exchange = get_patched_exchange(mocker, default_conf_usdt) + pm = PairListManager(exchange, default_conf_usdt) pm.refresh_pairlist() - assert pm.whitelist == ['ETH/BTC', 'TKN/BTC', 'XRP/BTC'] + assert pm.whitelist == ['ETH/USDT', 'XRP/USDT', 'NEO/USDT', 'TKN/USDT'] with time_machine.travel("2021-09-01 05:00:00 +00:00") as t: - create_mock_trades(fee, False) + create_mock_trades_usdt(fee) pm.refresh_pairlist() - assert pm.whitelist == ['XRP/BTC'] + assert pm.whitelist == ['XRP/USDT'] assert log_has_re(r'Removing pair .* since .* is below .*', caplog) # Move to "outside" of lookback window, so original sorting is restored. t.move_to("2021-09-01 07:00:00 +00:00") pm.refresh_pairlist() - assert pm.whitelist == ['ETH/BTC', 'TKN/BTC', 'XRP/BTC'] + assert pm.whitelist == ['ETH/USDT', 'XRP/USDT', 'NEO/USDT', 'TKN/USDT'] + + +@pytest.mark.usefixtures("init_persistence") +def test_PerformanceFilter_keep_mid_order(mocker, default_conf_usdt, fee, caplog) -> None: + default_conf_usdt['exchange']['pair_whitelist'].extend(['ADA/USDT', 'ETC/USDT']) + default_conf_usdt['pairlists'] = [ + {"method": "StaticPairList", "allow_inactive": True}, + {"method": "PerformanceFilter", "minutes": 60, } + ] + mocker.patch('freqtrade.exchange.Exchange.exchange_has', return_value=True) + exchange = get_patched_exchange(mocker, default_conf_usdt) + pm = PairListManager(exchange, default_conf_usdt) + pm.refresh_pairlist() + + assert pm.whitelist == ['ETH/USDT', 'LTC/USDT', 'XRP/USDT', + 'NEO/USDT', 'TKN/USDT', 'ADA/USDT', 'ETC/USDT'] + + with time_machine.travel("2021-09-01 05:00:00 +00:00") as t: + create_mock_trades_usdt(fee) + pm.refresh_pairlist() + assert pm.whitelist == ['XRP/USDT', 'ETC/USDT', 'ETH/USDT', + 'NEO/USDT', 'TKN/USDT', 'ADA/USDT', 'LTC/USDT'] + # assert log_has_re(r'Removing pair .* since .* is below .*', caplog) + + # Move to "outside" of lookback window, so original sorting is restored. + t.move_to("2021-09-01 07:00:00 +00:00") + pm.refresh_pairlist() + assert pm.whitelist == ['ETH/USDT', 'LTC/USDT', 'XRP/USDT', + 'NEO/USDT', 'TKN/USDT', 'ADA/USDT', 'ETC/USDT'] def test_gen_pair_whitelist_not_supported(mocker, default_conf, tickers) -> None: @@ -1167,13 +1197,13 @@ def test_pairlistmanager_no_pairlist(mocker, whitelist_conf): {'pair': 'TKN/BTC', 'profit_ratio': -0.0501, 'count': 2}, {'pair': 'ETH/BTC', 'profit_ratio': -0.0501, 'count': 100}], ['TKN/BTC', 'ETH/BTC', 'LTC/BTC']), - # Tie in performance and count, broken by alphabetical sort + # Tie in performance and count, broken by prior sorting sort ([{"method": "StaticPairList"}, {"method": "PerformanceFilter"}], ['ETH/BTC', 'TKN/BTC', 'LTC/BTC'], [{'pair': 'LTC/BTC', 'profit_ratio': -0.0501, 'count': 1}, {'pair': 'TKN/BTC', 'profit_ratio': -0.0501, 'count': 1}, {'pair': 'ETH/BTC', 'profit_ratio': -0.0501, 'count': 1}], - ['ETH/BTC', 'LTC/BTC', 'TKN/BTC']), + ['ETH/BTC', 'TKN/BTC', 'LTC/BTC']), ]) def test_performance_filter(mocker, whitelist_conf, pairlists, pair_allowlist, overall_performance, allowlist_result, tickers, markets, ohlcv_history_list): diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index 90587160c..b23f3084b 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -115,7 +115,15 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: 'isolated_liq': None, 'is_short': False, 'funding_fees': 0.0, - 'trading_mode': TradingMode.SPOT + 'trading_mode': TradingMode.SPOT, + 'filled_entry_orders': [{ + 'amount': 91.07468123, 'average': 1.098e-05, + 'cost': 0.0009999999999054, 'filled': 91.07468123, 'ft_order_side': 'buy', + 'order_date': ANY, 'order_timestamp': ANY, 'order_filled_date': ANY, + 'order_filled_timestamp': ANY, 'order_type': 'limit', 'price': 1.098e-05, + 'is_open': False, 'pair': 'ETH/BTC', + 'remaining': ANY, 'status': ANY}], + 'filled_exit_orders': [], } mocker.patch('freqtrade.exchange.Exchange.get_rate', @@ -189,7 +197,15 @@ def test_rpc_trade_status(default_conf, ticker, fee, mocker) -> None: 'isolated_liq': None, 'is_short': False, 'funding_fees': 0.0, - 'trading_mode': TradingMode.SPOT + 'trading_mode': TradingMode.SPOT, + 'filled_entry_orders': [{ + 'amount': 91.07468123, 'average': 1.098e-05, + 'cost': 0.0009999999999054, 'filled': 91.07468123, 'ft_order_side': 'buy', + 'order_date': ANY, 'order_timestamp': ANY, 'order_filled_date': ANY, + 'order_filled_timestamp': ANY, 'order_type': 'limit', 'price': 1.098e-05, + 'is_open': False, 'pair': 'ETH/BTC', + 'remaining': ANY, 'status': ANY}], + 'filled_exit_orders': [], } @@ -236,9 +252,13 @@ def test_rpc_status_table(default_conf, ticker, fee, mocker) -> None: assert '-0.06' == f'{fiat_profit_sum:.2f}' rpc._config['position_adjustment_enable'] = True + rpc._config['max_entry_position_adjustment'] = 3 result, headers, fiat_profit_sum = rpc._rpc_status_table(default_conf['stake_currency'], 'USD') - assert "# Buys" in headers + assert "# Entries" in headers assert len(result[0]) == 5 + # 4th column should be 1/4 - as 1 order filled (a total of 4 is possible) + # 3 on top of the initial one. + assert result[0][4] == '1/4' mocker.patch('freqtrade.exchange.Exchange.get_rate', MagicMock(side_effect=ExchangeError("Pair 'ETH/BTC' not available"))) @@ -1301,3 +1321,13 @@ def test_rpc_edge_enabled(mocker, edge_conf) -> None: assert ret[0]['Winrate'] == 0.66 assert ret[0]['Expectancy'] == 1.71 assert ret[0]['Stoploss'] == -0.02 + + +def test_rpc_health(mocker, default_conf) -> None: + mocker.patch('freqtrade.rpc.telegram.Telegram', MagicMock()) + + freqtradebot = get_patched_freqtradebot(mocker, default_conf) + rpc = RPC(freqtradebot) + result = rpc._health() + assert result['last_process'] == '1970-01-01 00:00:00+00:00' + assert result['last_process_ts'] == 0 diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 5a78a1274..370978c7b 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -7,6 +7,7 @@ from datetime import datetime, timedelta, timezone from pathlib import Path from unittest.mock import ANY, MagicMock, PropertyMock +import pandas as pd import pytest import uvicorn from fastapi import FastAPI @@ -1264,6 +1265,25 @@ def test_api_pair_candles(botclient, ohlcv_history): 0.7039405, 8.885e-05, 0, 0, 0, 0, 1511686800000, None, None, None, None] ]) + ohlcv_history['exit_long'] = ohlcv_history['exit_long'].astype('float64') + ohlcv_history.at[0, 'exit_long'] = float('inf') + ohlcv_history['date1'] = ohlcv_history['date'] + ohlcv_history.at[0, 'date1'] = pd.NaT + + ftbot.dataprovider._set_cached_df("XRP/BTC", timeframe, ohlcv_history, CandleType.SPOT) + rc = client_get(client, + f"{BASE_URI}/pair_candles?limit={amount}&pair=XRP%2FBTC&timeframe={timeframe}") + assert_response(rc) + 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, None, 0, 0, None, 1511686200000, None, None, 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.0, 0, 0, '2017-11-26 08:55:00', + 1511686500000, 8.893e-05, None, None, None], + ['2017-11-26 09:00:00', 8.891e-05, 8.893e-05, 8.875e-05, 8.877e-05, + 0.7039405, 8.885e-05, 0, 0.0, 0, 0, '2017-11-26 09:00:00', 1511686800000, + None, None, None, None] + ]) def test_api_pair_history(botclient, ohlcv_history): @@ -1540,3 +1560,14 @@ def test_api_backtesting(botclient, mocker, fee, caplog, tmpdir): assert result['status'] == 'reset' assert not result['running'] assert result['status_msg'] == 'Backtest reset' + + +def test_health(botclient): + ftbot, client = botclient + + rc = client_get(client, f"{BASE_URI}/health") + + assert_response(rc) + ret = rc.json() + assert ret['last_process_ts'] == 0 + assert ret['last_process'] == '1970-01-01T00:00:00+00:00' diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 82ee9d884..f71e529ef 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -24,6 +24,7 @@ from freqtrade.exceptions import OperationalException from freqtrade.freqtradebot import FreqtradeBot from freqtrade.loggers import setup_logging from freqtrade.persistence import PairLocks, Trade +from freqtrade.persistence.models import Order from freqtrade.rpc import RPC from freqtrade.rpc.rpc import RPCException from freqtrade.rpc.telegram import Telegram, authorized_only @@ -102,7 +103,7 @@ def test_telegram_init(default_conf, mocker, caplog) -> None: "['count'], ['locks'], ['unlock', 'delete_locks'], " "['reload_config', 'reload_conf'], ['show_config', 'show_conf'], " "['stopbuy'], ['whitelist'], ['blacklist'], ['blacklist_delete', 'bl_delete'], " - "['logs'], ['edge'], ['help'], ['version']" + "['logs'], ['edge'], ['health'], ['help'], ['version']" "]") assert log_has(message_str, caplog) @@ -206,7 +207,8 @@ def test_telegram_status(default_conf, update, mocker) -> None: 'stop_loss_ratio': -0.0001, 'open_order': '(limit buy rem=0.00000000)', 'is_open': True, - 'is_short': False + 'is_short': False, + 'filled_entry_orders': [], }]), ) @@ -222,6 +224,80 @@ def test_telegram_status(default_conf, update, mocker) -> None: assert status_table.call_count == 1 +@pytest.mark.usefixtures("init_persistence") +def test_telegram_status_multi_entry(default_conf, update, mocker, fee) -> None: + update.message.chat.id = "123" + default_conf['telegram']['enabled'] = False + default_conf['telegram']['chat_id'] = "123" + default_conf['position_adjustment_enable'] = True + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + fetch_order=MagicMock(return_value=None), + get_rate=MagicMock(return_value=0.22), + ) + + telegram, _, msg_mock = get_telegram_testobject(mocker, default_conf) + + create_mock_trades(fee) + trades = Trade.get_open_trades() + trade = trades[0] + trade.orders.append(Order( + order_id='5412vbb', + ft_order_side='buy', + ft_pair=trade.pair, + ft_is_open=False, + status="closed", + symbol=trade.pair, + order_type="market", + side="buy", + price=trade.open_rate * 0.95, + average=trade.open_rate * 0.95, + filled=trade.amount, + remaining=0, + cost=trade.amount, + order_date=trade.open_date, + order_filled_date=trade.open_date, + ) + ) + trade.recalc_trade_from_orders() + Trade.commit() + + telegram._status(update=update, context=MagicMock()) + assert msg_mock.call_count == 4 + msg = msg_mock.call_args_list[0][0][0] + assert re.search(r'Number of Entries.*2', msg) + assert re.search(r'Average Entry Price', msg) + assert re.search(r'Order filled at', msg) + assert re.search(r'Close Date:', msg) is None + assert re.search(r'Close Profit:', msg) is None + + +@pytest.mark.usefixtures("init_persistence") +def test_telegram_status_closed_trade(default_conf, update, mocker, fee) -> None: + update.message.chat.id = "123" + default_conf['telegram']['enabled'] = False + default_conf['telegram']['chat_id'] = "123" + default_conf['position_adjustment_enable'] = True + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + fetch_order=MagicMock(return_value=None), + get_rate=MagicMock(return_value=0.22), + ) + + telegram, _, msg_mock = get_telegram_testobject(mocker, default_conf) + + create_mock_trades(fee) + trades = Trade.get_trades([Trade.is_open.is_(False)]) + trade = trades[0] + context = MagicMock() + context.args = [str(trade.id)] + telegram._status(update=update, context=context) + assert msg_mock.call_count == 1 + msg = msg_mock.call_args_list[0][0][0] + assert re.search(r'Close Date:', msg) + assert re.search(r'Close Profit:', msg) + + def test_status_handle(default_conf, update, ticker, fee, mocker) -> None: default_conf['max_open_trades'] = 3 mocker.patch.multiple( diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index ef443a27f..d8ebba8f9 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -712,14 +712,14 @@ def test_process_informative_pairs_added(default_conf_usdt, ticker_usdt, mocker) (True, 'spot', 'binance', None, None), (False, 'spot', 'gateio', None, None), (True, 'spot', 'gateio', None, None), - (False, 'spot', 'okex', None, None), - (True, 'spot', 'okex', None, None), + (False, 'spot', 'okx', None, None), + (True, 'spot', 'okx', None, None), (True, 'futures', 'binance', 'isolated', 11.89108910891089), (False, 'futures', 'binance', 'isolated', 8.070707070707071), (True, 'futures', 'gateio', 'isolated', 11.87413417771621), (False, 'futures', 'gateio', 'isolated', 8.085708510208207), - # (True, 'futures', 'okex', 'isolated', 11.87413417771621), - # (False, 'futures', 'okex', 'isolated', 8.085708510208207), + # (True, 'futures', 'okx', 'isolated', 11.87413417771621), + # (False, 'futures', 'okx', 'isolated', 8.085708510208207), ]) def test_execute_entry(mocker, default_conf_usdt, fee, limit_order, limit_order_open, is_short, trading_mode, @@ -735,11 +735,11 @@ def test_execute_entry(mocker, default_conf_usdt, fee, limit_order, ((wb + cum_b) - (side_1 * position * ep1)) / ((position * mmr_b) - (side_1 * position)) ((2 + 0.01) - (1 * 1 * 10)) / ((1 * 0.01) - (1 * 1)) = 8.070707070707071 - exchange_name = gateio/okex, is_short = true + exchange_name = gateio/okx, is_short = true (open_rate + (wallet_balance / position)) / (1 + (mm_ratio + taker_fee_rate)) (10 + (2 / 1)) / (1 + (0.01 + 0.0006)) = 11.87413417771621 - exchange_name = gateio/okex, is_short = false + exchange_name = gateio/okx, is_short = false (open_rate - (wallet_balance / position)) / (1 - (mm_ratio + taker_fee_rate)) (10 - (2 / 1)) / (1 - (0.01 + 0.0006)) = 8.085708510208207 """ @@ -791,7 +791,7 @@ def test_execute_entry(mocker, default_conf_usdt, fee, limit_order, call_args = enter_mm.call_args_list[0][1] assert call_args['pair'] == pair assert call_args['rate'] == bid - assert pytest.approx(call_args['amount'], round(stake_amount / bid * leverage, 8)) + assert pytest.approx(call_args['amount']) == round(stake_amount / bid * leverage, 8) enter_rate_mock.reset_mock() # Should create an open trade with an open order id @@ -813,7 +813,7 @@ def test_execute_entry(mocker, default_conf_usdt, fee, limit_order, call_args = enter_mm.call_args_list[1][1] assert call_args['pair'] == pair assert call_args['rate'] == fix_price - assert pytest.approx(call_args['amount'], round(stake_amount / fix_price * leverage, 8)) + assert pytest.approx(call_args['amount']) == round(stake_amount / fix_price * leverage, 8) # In case of closed order order['status'] = 'closed' @@ -2268,6 +2268,7 @@ def test_check_handle_timedout_buy_usercustom( ) freqtrade = FreqtradeBot(default_conf_usdt) open_trade.is_short = is_short + open_trade.orders[0].side = 'sell' if is_short else 'buy' Trade.query.session.add(open_trade) # Ensure default is to return empty (so not mocked yet) @@ -2323,6 +2324,7 @@ def test_check_handle_timedout_buy( ) -> None: old_order = limit_sell_order_old if is_short else limit_buy_order_old rpc_mock = patch_RPCManager(mocker) + old_order['id'] = open_trade.open_order_id limit_buy_cancel = deepcopy(old_order) limit_buy_cancel['status'] = 'canceled' cancel_order_mock = MagicMock(return_value=limit_buy_cancel) @@ -2425,6 +2427,8 @@ def test_check_handle_timedout_sell_usercustom( is_short, open_trade_usdt, caplog ) -> None: default_conf_usdt["unfilledtimeout"] = {"buy": 1440, "sell": 1440, "exit_timeout_count": 1} + limit_sell_order_old['id'] = open_trade_usdt.open_order_id + rpc_mock = patch_RPCManager(mocker) cancel_order_mock = MagicMock() patch_exchange(mocker) @@ -2473,7 +2477,7 @@ def test_check_handle_timedout_sell_usercustom( # 2nd canceled trade - Fail execute sell caplog.clear() - open_trade_usdt.open_order_id = 'order_id_2' + open_trade_usdt.open_order_id = limit_sell_order_old['id'] mocker.patch('freqtrade.persistence.Trade.get_exit_order_count', return_value=1) mocker.patch('freqtrade.freqtradebot.FreqtradeBot.execute_trade_exit', side_effect=DependencyException) @@ -2484,7 +2488,7 @@ def test_check_handle_timedout_sell_usercustom( caplog.clear() # 2nd canceled trade ... - open_trade_usdt.open_order_id = 'order_id_2' + open_trade_usdt.open_order_id = limit_sell_order_old['id'] freqtrade.check_handle_timedout() assert log_has_re('Emergencyselling trade.*', caplog) assert et_mock.call_count == 1 @@ -2497,6 +2501,7 @@ def test_check_handle_timedout_sell( ) -> None: rpc_mock = patch_RPCManager(mocker) cancel_order_mock = MagicMock() + limit_sell_order_old['id'] = open_trade_usdt.open_order_id patch_exchange(mocker) mocker.patch.multiple( 'freqtrade.exchange.Exchange', @@ -2561,6 +2566,7 @@ def test_check_handle_timedout_partial( open_trade, mocker ) -> None: rpc_mock = patch_RPCManager(mocker) + limit_buy_order_old_partial['id'] = open_trade.open_order_id limit_buy_canceled = deepcopy(limit_buy_order_old_partial) limit_buy_canceled['status'] = 'canceled' @@ -2594,6 +2600,7 @@ def test_check_handle_timedout_partial_fee( limit_buy_order_old_partial_canceled, mocker ) -> None: rpc_mock = patch_RPCManager(mocker) + limit_buy_order_old_partial['id'] = open_trade.open_order_id cancel_order_mock = MagicMock(return_value=limit_buy_order_old_partial_canceled) mocker.patch('freqtrade.wallets.Wallets.get_free', MagicMock(return_value=0)) patch_exchange(mocker) @@ -2636,6 +2643,8 @@ def test_check_handle_timedout_partial_except( limit_buy_order_old_partial_canceled, mocker ) -> None: rpc_mock = patch_RPCManager(mocker) + limit_buy_order_old_partial_canceled['id'] = open_trade.open_order_id + limit_buy_order_old_partial['id'] = open_trade.open_order_id cancel_order_mock = MagicMock(return_value=limit_buy_order_old_partial_canceled) patch_exchange(mocker) mocker.patch.multiple( @@ -4805,8 +4814,8 @@ def test_get_valid_price(mocker, default_conf_usdt) -> None: (True, 'spot', 'binance', '', 5.0, 10.0, 1.0, None), (False, 'spot', 'gateio', '', 5.0, 10.0, 1.0, None), (True, 'spot', 'gateio', '', 5.0, 10.0, 1.0, None), - (False, 'spot', 'okex', '', 5.0, 10.0, 1.0, None), - (True, 'spot', 'okex', '', 5.0, 10.0, 1.0, None), + (False, 'spot', 'okx', '', 5.0, 10.0, 1.0, None), + (True, 'spot', 'okx', '', 5.0, 10.0, 1.0, None), # Binance, short (True, 'futures', 'binance', 'isolated', 5.0, 10.0, 1.0, 11.89108910891089), (True, 'futures', 'binance', 'isolated', 3.0, 10.0, 1.0, 13.211221122079207), @@ -4817,16 +4826,16 @@ def test_get_valid_price(mocker, default_conf_usdt) -> None: (False, 'futures', 'binance', 'isolated', 5, 8, 1.0, 6.454545454545454), (False, 'futures', 'binance', 'isolated', 3, 10, 1.0, 6.717171717171718), (False, 'futures', 'binance', 'isolated', 5, 10, 0.6, 7.39057239057239), - # Gateio/okex, short + # Gateio/okx, short (True, 'futures', 'gateio', 'isolated', 5, 10, 1.0, 11.87413417771621), (True, 'futures', 'gateio', 'isolated', 5, 10, 2.0, 11.87413417771621), (True, 'futures', 'gateio', 'isolated', 3, 10, 1.0, 13.476180850346978), (True, 'futures', 'gateio', 'isolated', 5, 8, 1.0, 9.499307342172967), - # Gateio/okex, long + # Gateio/okx, long (False, 'futures', 'gateio', 'isolated', 5.0, 10.0, 1.0, 8.085708510208207), (False, 'futures', 'gateio', 'isolated', 3.0, 10.0, 1.0, 6.738090425173506), - # (True, 'futures', 'okex', 'isolated', 11.87413417771621), - # (False, 'futures', 'okex', 'isolated', 8.085708510208207), + # (True, 'futures', 'okx', 'isolated', 11.87413417771621), + # (False, 'futures', 'okx', 'isolated', 8.085708510208207), ] ) def test_leverage_prep( @@ -4871,7 +4880,7 @@ def test_leverage_prep( leverage = 5, open_rate = 10, amount = 0.6 ((1.6 + 0.01) - (1 * 0.6 * 10)) / ((0.6 * 0.01) - (1 * 0.6)) = 7.39057239057239 - Gateio/Okex, Short + Gateio/Okx, Short leverage = 5, open_rate = 10, amount = 1.0 (open_rate + (wallet_balance / position)) / (1 + (mm_ratio + taker_fee_rate)) (10 + (2 / 1.0)) / (1 + (0.01 + 0.0006)) = 11.87413417771621 @@ -4882,7 +4891,7 @@ def test_leverage_prep( leverage = 5, open_rate = 8, amount = 1.0 (8 + (1.6 / 1.0)) / (1 + (0.01 + 0.0006)) = 9.499307342172967 - Gateio/Okex, Long + Gateio/Okx, Long leverage = 5, open_rate = 10, amount = 1.0 (open_rate - (wallet_balance / position)) / (1 - (mm_ratio + taker_fee_rate)) (10 - (2 / 1)) / (1 - (0.01 + 0.0006)) = 8.085708510208207 diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 5df3fc2bc..08d99dd1d 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -8,12 +8,13 @@ from unittest.mock import MagicMock import arrow import pytest -from sqlalchemy import create_engine, inspect, text +from sqlalchemy import create_engine, text from freqtrade import constants from freqtrade.enums import TradingMode from freqtrade.exceptions import DependencyException, OperationalException from freqtrade.persistence import LocalTrade, Order, Trade, clean_dry_run_db, init_db +from freqtrade.persistence.migrations import get_last_sequence_ids, set_sequence_ids from tests.conftest import (create_mock_trades, create_mock_trades_usdt, create_mock_trades_with_leverage, get_sides, log_has, log_has_re) @@ -1237,7 +1238,8 @@ def test_migrate_new(mocker, default_conf, fee, caplog): assert trade.stoploss_last_update is None assert log_has("trying trades_bak1", caplog) assert log_has("trying trades_bak2", caplog) - assert log_has("Running database migration for trades - backup: trades_bak2", caplog) + assert log_has("Running database migration for trades - backup: trades_bak2, orders_bak0", + caplog) assert trade.open_trade_value == trade._calc_open_trade_value() assert trade.close_profit_abs is None @@ -1250,65 +1252,6 @@ def test_migrate_new(mocker, default_conf, fee, caplog): assert orders[1].order_id == 'stop_order_id222' assert orders[1].ft_order_side == 'stoploss' - caplog.clear() - # Drop latest column - with engine.begin() as connection: - connection.execute(text("alter table orders rename to orders_bak")) - inspector = inspect(engine) - - with engine.begin() as connection: - for index in inspector.get_indexes('orders_bak'): - connection.execute(text(f"drop index {index['name']}")) - # Recreate table - connection.execute(text(""" - CREATE TABLE orders ( - id INTEGER NOT NULL, - ft_trade_id INTEGER, - ft_order_side VARCHAR NOT NULL, - ft_pair VARCHAR NOT NULL, - ft_is_open BOOLEAN NOT NULL, - order_id VARCHAR NOT NULL, - status VARCHAR, - symbol VARCHAR, - order_type VARCHAR, - side VARCHAR, - price FLOAT, - amount FLOAT, - filled FLOAT, - remaining FLOAT, - cost FLOAT, - order_date DATETIME, - order_filled_date DATETIME, - order_update_date DATETIME, - PRIMARY KEY (id), - CONSTRAINT _order_pair_order_id UNIQUE (ft_pair, order_id), - FOREIGN KEY(ft_trade_id) REFERENCES trades (id) - ) - """)) - - connection.execute(text(""" - insert into orders ( id, ft_trade_id, ft_order_side, ft_pair, ft_is_open, order_id, status, - symbol, order_type, side, price, amount, filled, remaining, cost, order_date, - order_filled_date, order_update_date) - select id, ft_trade_id, ft_order_side, ft_pair, ft_is_open, order_id, status, - symbol, order_type, side, price, amount, filled, remaining, cost, order_date, - order_filled_date, order_update_date - from orders_bak - """)) - - # Run init to test migration - init_db(default_conf['db_url'], default_conf['dry_run']) - - assert log_has("trying orders_bak1", caplog) - - orders = Order.query.all() - assert len(orders) == 2 - assert orders[0].order_id == 'buy_order' - assert orders[0].ft_order_side == 'buy' - - assert orders[1].order_id == 'stop_order_id222' - assert orders[1].ft_order_side == 'stoploss' - def test_migrate_mid_state(mocker, default_conf, fee, caplog): """ @@ -1370,7 +1313,40 @@ def test_migrate_mid_state(mocker, default_conf, fee, caplog): assert trade.initial_stop_loss == 0.0 assert trade.open_trade_value == trade._calc_open_trade_value() assert log_has("trying trades_bak0", caplog) - assert log_has("Running database migration for trades - backup: trades_bak0", caplog) + assert log_has("Running database migration for trades - backup: trades_bak0, orders_bak0", + caplog) + + +def test_migrate_get_last_sequence_ids(): + engine = MagicMock() + engine.begin = MagicMock() + engine.name = 'postgresql' + get_last_sequence_ids(engine, 'trades_bak', 'orders_bak') + + assert engine.begin.call_count == 2 + engine.reset_mock() + engine.begin.reset_mock() + + engine.name = 'somethingelse' + get_last_sequence_ids(engine, 'trades_bak', 'orders_bak') + + assert engine.begin.call_count == 0 + + +def test_migrate_set_sequence_ids(): + engine = MagicMock() + engine.begin = MagicMock() + engine.name = 'postgresql' + set_sequence_ids(engine, 22, 55) + + assert engine.begin.call_count == 1 + engine.reset_mock() + engine.begin.reset_mock() + + engine.name = 'somethingelse' + set_sequence_ids(engine, 22, 55) + + assert engine.begin.call_count == 0 def test_adjust_stop_loss(fee): @@ -1612,7 +1588,9 @@ def test_to_json(default_conf, fee): 'isolated_liq': None, 'is_short': None, 'trading_mode': None, - 'funding_fees': None + 'funding_fees': None, + 'filled_entry_orders': [], + 'filled_exit_orders': [], } # Simulate dry_run entries @@ -1686,7 +1664,9 @@ def test_to_json(default_conf, fee): 'isolated_liq': None, 'is_short': None, 'trading_mode': None, - 'funding_fees': None + 'funding_fees': None, + 'filled_entry_orders': [], + 'filled_exit_orders': [], } diff --git a/tests/test_wallets.py b/tests/test_wallets.py index 369197f6b..4dd1c925f 100644 --- a/tests/test_wallets.py +++ b/tests/test_wallets.py @@ -189,6 +189,7 @@ def test_get_trade_stake_amount_unlimited_amount(default_conf, ticker, balance_r (9, 11, 100, 10000, 11), # Below min stake (1, 15, 10, 10000, 0), # Below min stake and min_stake > stake_available (20, 50, 100, 10000, 0), # Below min stake and stake * 1.3 > min_stake + (1000, None, 1000, 10000, 1000), # No min-stake-amount could be determined ]) def test_validate_stake_amount(