diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c4ccc1b9f..b4e0bc024 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,6 +56,13 @@ To help with that, we encourage you to install the git pre-commit hook that will warn you when you try to commit code that fails these checks. Guide for installing them is [here](http://flake8.pycqa.org/en/latest/user/using-hooks.html). +##### Additional styles applied + +* Have docstrings on all public methods +* Use double-quotes for docstrings +* Multiline docstrings should be indented to the level of the first quote +* Doc-strings should follow the reST format (`:param xxx: ...`, `:return: ...`, `:raises KeyError: ... `) + ### 3. Test if all type-hints are correct #### Run mypy diff --git a/config_examples/config_full.example.json b/config_examples/config_full.example.json index 83b8a27d0..228a08a02 100644 --- a/config_examples/config_full.example.json +++ b/config_examples/config_full.example.json @@ -28,6 +28,7 @@ "unfilledtimeout": { "buy": 10, "sell": 30, + "exit_timeout_count": 0, "unit": "minutes" }, "bid_strategy": { diff --git a/docs/configuration.md b/docs/configuration.md index 9d0a243ce..38d55f251 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -104,6 +104,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `unfilledtimeout.buy` | **Required.** How long (in minutes or seconds) the bot will wait for an unfilled buy order to complete, after which the order will be cancelled and repeated at current (new) price, as long as there is a signal. [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Integer | `unfilledtimeout.sell` | **Required.** How long (in minutes or seconds) the bot will wait for an unfilled sell order to complete, after which the order will be cancelled and repeated at current (new) price, as long as there is a signal. [Strategy Override](#parameters-in-the-strategy).
**Datatype:** Integer | `unfilledtimeout.unit` | Unit to use in unfilledtimeout setting. Note: If you set unfilledtimeout.unit to "seconds", "internals.process_throttle_secs" must be inferior or equal to timeout [Strategy Override](#parameters-in-the-strategy).
*Defaults to `minutes`.*
**Datatype:** String +| `unfilledtimeout.exit_timeout_count` | How many times can exit orders time out. Once this number of timeouts is reached, an emergency sell is triggered. 0 to disable and allow unlimited order cancels. [Strategy Override](#parameters-in-the-strategy).
*Defaults to `0`.*
**Datatype:** Integer | `bid_strategy.price_side` | Select the side of the spread the bot should look at to get the buy rate. [More information below](#buy-price-side).
*Defaults to `bid`.*
**Datatype:** String (either `ask` or `bid`). | `bid_strategy.ask_last_balance` | **Required.** Interpolate the bidding price. More information [below](#buy-price-without-orderbook-enabled). | `bid_strategy.use_order_book` | Enable buying using the rates in [Order Book Bids](#buy-price-with-orderbook-enabled).
**Datatype:** Boolean @@ -215,7 +216,7 @@ With a reserve of 5%, the minimum stake amount would be ~12.6$ (`12 * (1 + 0.05) To limit this calculation in case of large stoploss values, the calculated minimum stake-limit will never be more than 50% above the real limit. !!! Warning - Since the limits on exchanges are usually stable and are not updated often, some pairs can show pretty high minimum limits, simply because the price increased a lot since the last limit adjustment by the exchange. + Since the limits on exchanges are usually stable and are not updated often, some pairs can show pretty high minimum limits, simply because the price increased a lot since the last limit adjustment by the exchange. Freqtrade adjusts the stake-amount to this value, unless it's > 30% more than the calculated/desired stake-amount - in which case the trade is rejected. #### Tradable balance diff --git a/docs/developer.md b/docs/developer.md index 01f274131..b69a70aa3 100644 --- a/docs/developer.md +++ b/docs/developer.md @@ -26,6 +26,8 @@ Alternatively (e.g. if your system is not supported by the setup.sh script), fol This will install all required tools for development, including `pytest`, `flake8`, `mypy`, and `coveralls`. +Before opening a pull request, please familiarize yourself with our [Contributing Guidelines](https://github.com/freqtrade/freqtrade/blob/develop/CONTRIBUTING.md). + ### Devcontainer setup The fastest and easiest way to get started is to use [VSCode](https://code.visualstudio.com/) with the Remote container extension. diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index 589bc23b2..d52b685c1 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -198,7 +198,7 @@ Not defining this parameter (or setting it to 0) will use all-time performance. The optional `min_profit` parameter defines the minimum profit a pair must have to be considered. Pairs below this level will be filtered out. -Using this parameter without `minutes` is highly discouraged, as it can lead to an empty pairlist without without a way to recover. +Using this parameter without `minutes` is highly discouraged, as it can lead to an empty pairlist without a way to recover. ```json "pairlists": [ @@ -211,6 +211,8 @@ Using this parameter without `minutes` is highly discouraged, as it can lead to ], ``` +As this Filter uses past performance of the bot, it'll have some startup-period - and should only be used after the bot has a few 100 trades in the database. + !!! Warning "Backtesting" `PerformanceFilter` does not support backtesting mode. diff --git a/docs/index.md b/docs/index.md index 833c49812..292955346 100644 --- a/docs/index.md +++ b/docs/index.md @@ -36,7 +36,7 @@ Freqtrade is a crypto-currency algorithmic trading software developed in python Please read the [exchange specific notes](exchanges.md) to learn about eventual, special configurations needed for each exchange. -- [X] [Binance](https://www.binance.com/) ([*Note for binance users](docs/exchanges.md#binance-blacklist)) +- [X] [Binance](https://www.binance.com/) ([*Note for binance users](exchanges.md#binance-blacklist)) - [X] [Bittrex](https://bittrex.com/) - [X] [FTX](https://ftx.com) - [X] [Gate.io](https://www.gate.io/ref/6266643) diff --git a/docs/installation.md b/docs/installation.md index d468786d3..ee7ffe55d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -60,7 +60,7 @@ OS Specific steps are listed first, the [Common](#common) section below is neces sudo apt-get update # install packages - sudo apt install -y python3-pip python3-venv python3-dev python3-pandas git + sudo apt install -y python3-pip python3-venv python3-dev python3-pandas git curl ``` === "RaspberryPi/Raspbian" @@ -71,7 +71,7 @@ OS Specific steps are listed first, the [Common](#common) section below is neces ```bash - sudo apt-get install python3-venv libatlas-base-dev cmake + sudo apt-get install python3-venv libatlas-base-dev cmake curl # Use pywheels.org to speed up installation sudo echo "[global]\nextra-index-url=https://www.piwheels.org/simple" > tee /etc/pip.conf diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index 40269b109..772919436 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,4 +1,4 @@ mkdocs==1.2.3 mkdocs-material==7.3.6 mdx_truly_sane_lists==1.2 -pymdown-extensions==9.0 +pymdown-extensions==9.1 diff --git a/docs/strategy-advanced.md b/docs/strategy-advanced.md index 10a399085..871d99307 100644 --- a/docs/strategy-advanced.md +++ b/docs/strategy-advanced.md @@ -143,6 +143,52 @@ def custom_sell(self, pair: str, trade: Trade, current_time: datetime, current_r !!! Note `buy_tag` is limited to 100 characters, remaining data will be truncated. +## Exit tag + +Similar to [Buy Tagging](#buy-tag), you can also specify a sell tag. + +``` python +def populate_sell_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + dataframe.loc[ + ( + (dataframe['rsi'] > 70) & + (dataframe['volume'] > 0) + ), + ['sell', 'exit_tag']] = (1, 'exit_rsi') + + return dataframe +``` + +The provided exit-tag is then used as sell-reason - and shown as such in backtest results. + +!!! Note + `sell_reason` is limited to 100 characters, remaining data will be truncated. + +## Bot loop start callback + +A simple callback which is called once at the start of every bot throttling iteration. +This can be used to perform calculations which are pair independent (apply to all pairs), loading of external data, etc. + +``` python +import requests + +class AwesomeStrategy(IStrategy): + + # ... populate_* methods + + def bot_loop_start(self, **kwargs) -> None: + """ + Called at the start of the bot iteration (one loop). + Might be used to perform pair-independent tasks + (e.g. gather some remote resource for comparison) + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + """ + if self.config['runmode'].value in ('live', 'dry_run'): + # Assign this to the class by using self.* + # can then be used by populate_* methods + self.remote_data = requests.get('https://some_remote_source.example.com') + +``` ## Custom stoploss @@ -501,32 +547,6 @@ class AwesomeStrategy(IStrategy): --- -## Bot loop start callback - -A simple callback which is called once at the start of every bot throttling iteration. -This can be used to perform calculations which are pair independent (apply to all pairs), loading of external data, etc. - -``` python -import requests - -class AwesomeStrategy(IStrategy): - - # ... populate_* methods - - def bot_loop_start(self, **kwargs) -> None: - """ - Called at the start of the bot iteration (one loop). - Might be used to perform pair-independent tasks - (e.g. gather some remote resource for comparison) - :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. - """ - if self.config['runmode'].value in ('live', 'dry_run'): - # Assign this to the class by using self.* - # can then be used by populate_* methods - self.remote_data = requests.get('https://some_remote_source.example.com') - -``` - ## Bot order confirmation ### Trade entry (buy order) confirmation diff --git a/docs/strategy-customization.md b/docs/strategy-customization.md index 62e7509b3..d54bae710 100644 --- a/docs/strategy-customization.md +++ b/docs/strategy-customization.md @@ -4,33 +4,23 @@ This page explains how to customize your strategies, add new indicators and set Please familiarize yourself with [Freqtrade basics](bot-basics.md) first, which provides overall info on how the bot operates. -## Install a custom strategy file - -This is very simple. Copy paste your strategy file into the directory `user_data/strategies`. - -Let assume you have a class called `AwesomeStrategy` in the file `AwesomeStrategy.py`: - -1. Move your file into `user_data/strategies` (you should have `user_data/strategies/AwesomeStrategy.py` -2. Start the bot with the param `--strategy AwesomeStrategy` (the parameter is the class name) - -```bash -freqtrade trade --strategy AwesomeStrategy -``` - ## Develop your own strategy The bot includes a default strategy file. Also, several other strategies are available in the [strategy repository](https://github.com/freqtrade/freqtrade-strategies). You will however most likely have your own idea for a strategy. -This document intends to help you develop one for yourself. +This document intends to help you convert your strategy idea into your own strategy. -To get started, use `freqtrade new-strategy --strategy AwesomeStrategy`. +To get started, use `freqtrade new-strategy --strategy AwesomeStrategy` (you can obviously use your own naming for your strategy). This will create a new strategy file from a template, which will be located under `user_data/strategies/AwesomeStrategy.py`. !!! Note This is just a template file, which will most likely not be profitable out of the box. +??? Hint "Different template levels" + `freqtrade new-strategy` has an additional parameter, `--template`, which controls the amount of pre-build information you get in the created strategy. Use `--template minimal` to get an empty strategy without any indicator examples, or `--template advanced` to get a template with most callbacks defined. + ### Anatomy of a strategy A strategy file contains all the information needed to build a good strategy: @@ -67,6 +57,46 @@ file as reference.** needs to take care to avoid having the strategy utilize data from the future. Some common patterns for this are listed in the [Common Mistakes](#common-mistakes-when-developing-strategies) section of this document. +### Dataframe + +Freqtrade uses [pandas](https://pandas.pydata.org/) to store/provide the candlestick (OHLCV) data. +Pandas is a great library developed for processing large amounts of data. + +Each row in a dataframe corresponds to one candle on a chart, with the latest candle always being the last in the dataframe (sorted by date). + +``` output +> dataframe.head() + date open high low close volume +0 2021-11-09 23:25:00+00:00 67279.67 67321.84 67255.01 67300.97 44.62253 +1 2021-11-09 23:30:00+00:00 67300.97 67301.34 67183.03 67187.01 61.38076 +2 2021-11-09 23:35:00+00:00 67187.02 67187.02 67031.93 67123.81 113.42728 +3 2021-11-09 23:40:00+00:00 67123.80 67222.40 67080.33 67160.48 78.96008 +4 2021-11-09 23:45:00+00:00 67160.48 67160.48 66901.26 66943.37 111.39292 +``` + +Pandas provides fast ways to calculate metrics. To benefit from this speed, it's advised to not use loops, but use vectorized methods instead. + +Vectorized operations perform calculations across the whole range of data and are therefore, compared to looping through each row, a lot faster when calculating indicators. + +As a dataframe is a table, simple python comparisons like the following will not work + +``` python + if dataframe['rsi'] > 30: + dataframe['buy'] = 1 +``` + +The above section will fail with `The truth value of a Series is ambiguous. [...]`. + +This must instead be written in a pandas-compatible way, so the operation is performed across the whole dataframe. + +``` python + dataframe.loc[ + (dataframe['rsi'] > 30) + , 'buy'] = 1 +``` + +With this section, you have a new column in your dataframe, which has `1` assigned whenever RSI is above 30. + ### Customize Indicators Buy and sell strategies need indicators. You can add more indicators by extending the list contained in the method `populate_indicators()` from your strategy file. @@ -134,7 +164,7 @@ Additional technical libraries can be installed as necessary, or custom indicato ### Strategy startup period -Most indicators have an instable startup period, in which they are either not available, or the calculation is incorrect. This can lead to inconsistencies, since Freqtrade does not know how long this instable period should be. +Most indicators have an instable startup period, in which they are either not available (NaN), or the calculation is incorrect. This can lead to inconsistencies, since Freqtrade does not know how long this instable period should be. To account for this, the strategy can be assigned the `startup_candle_count` attribute. This should be set to the maximum number of candles that the strategy requires to calculate stable indicators. @@ -146,8 +176,14 @@ In this example strategy, this should be set to 100 (`startup_candle_count = 100 By letting the bot know how much history is needed, backtest trades can start at the specified timerange during backtesting and hyperopt. +!!! Warning "Using x calls to get OHLCV" + If you receive a warning like `WARNING - Using 3 calls to get OHLCV. This can result in slower operations for the bot. Please check if you really need 1500 candles for your strategy` - you should consider if you really need this much historic data for your signals. + Having this will cause Freqtrade to make multiple calls for the same pair, which will obviously be slower than one network request. + As a consequence, Freqtrade will take longer to refresh candles - and should therefore be avoided if possible. + This is capped to 5 total calls to avoid overloading the exchange, or make freqtrade too slow. + !!! Warning - `startup_candle_count` should be below `ohlcv_candle_limit` (which is 500 for most exchanges) - since only this amount of candles will be available during Dry-Run/Live Trade operations. + `startup_candle_count` should be below `ohlcv_candle_limit * 5` (which is 500 * 5 for most exchanges) - since only this amount of candles will be available during Dry-Run/Live Trade operations. #### Example @@ -312,6 +348,19 @@ Currently this is `pair`, which can be accessed using `metadata['pair']` - and w The Metadata-dict should not be modified and does not persist information across multiple calls. Instead, have a look at the section [Storing information](strategy-advanced.md#Storing-information) +## Strategy file loading + +By default, freqtrade will attempt to load strategies from all `.py` files within `user_data/strategies`. + +Assuming your strategy is called `AwesomeStrategy`, stored in the file `user_data/strategies/AwesomeStrategy.py`, then you can start freqtrade with `freqtrade trade --strategy AwesomeStrategy`. +Note that we're using the class-name, not the file name. + +You can use `freqtrade list-strategies` to see a list of all strategies Freqtrade is able to load (all strategies in the correct folder). +It will also include a "status" field, highlighting potential problems. + +??? Hint "Customize strategy directory" + You can use a different directory by using `--strategy-path user_data/otherPath`. This parameter is available to all commands that require a strategy. + ## Informative Pairs ### Get data for non-tradeable pairs diff --git a/docs/telegram-usage.md b/docs/telegram-usage.md index 0c45fbbf1..c7f9c58f6 100644 --- a/docs/telegram-usage.md +++ b/docs/telegram-usage.md @@ -58,6 +58,8 @@ For the Freqtrade configuration, you can then use the the full value (including ```json "chat_id": "-1001332619709" ``` +!!! Warning "Using telegram groups" + When using telegram groups, you're giving every member of the telegram group access to your freqtrade bot and to all commands possible via telegram. Please make sure that you can trust everyone in the telegram group to avoid unpleasent surprises. ## Control telegram noise @@ -175,6 +177,8 @@ official commands. You can ask at any moment for help with `/help`. | `/performance` | Show performance of each finished trade grouped by pair | `/balance` | Show account balance per currency | `/daily ` | Shows profit or loss per day, over the last n days (n defaults to 7) +| `/weekly ` | Shows profit or loss per week, over the last n weeks (n defaults to 8) +| `/monthly ` | Shows profit or loss per month, over the last n months (n defaults to 6) | `/stats` | Shows Wins / losses by Sell reason as well as Avg. holding durations for buys and sells | `/whitelist` | Show the current whitelist | `/blacklist [pair]` | Show the current blacklist, or adds a pair to the blacklist. @@ -307,8 +311,7 @@ Return the balance of all crypto-currency your have on the exchange. ### /daily -Per default `/daily` will return the 7 last days. -The example below if for `/daily 3`: +Per default `/daily` will return the 7 last days. The example below if for `/daily 3`: > **Daily Profit over the last 3 days:** ``` @@ -319,6 +322,34 @@ Day Profit BTC Profit USD 2018-01-01 0.00269130 BTC 34.986 USD ``` +### /weekly + +Per default `/weekly` will return the 8 last weeks, including the current week. Each week starts +from Monday. The example below if for `/weekly 3`: + +> **Weekly Profit over the last 3 weeks (starting from Monday):** +``` +Monday Profit BTC Profit USD +---------- -------------- ------------ +2018-01-03 0.00224175 BTC 29,142 USD +2017-12-27 0.00033131 BTC 4,307 USD +2017-12-20 0.00269130 BTC 34.986 USD +``` + +### /monthly + +Per default `/monthly` will return the 6 last months, including the current month. The example below +if for `/monthly 3`: + +> **Monthly Profit over the last 3 months:** +``` +Month Profit BTC Profit USD +---------- -------------- ------------ +2018-01 0.00224175 BTC 29,142 USD +2017-12 0.00033131 BTC 4,307 USD +2017-11 0.00269130 BTC 34.986 USD +``` + ### /whitelist Shows the current whitelist diff --git a/freqtrade/constants.py b/freqtrade/constants.py index 3c18bc137..e41ecd4f8 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -160,6 +160,7 @@ CONF_SCHEMA = { 'properties': { 'buy': {'type': 'number', 'minimum': 1}, 'sell': {'type': 'number', 'minimum': 1}, + 'exit_timeout_count': {'type': 'number', 'minimum': 0, 'default': 0}, 'unit': {'type': 'string', 'enum': TIMEOUT_UNITS, 'default': 'minutes'} } }, @@ -210,7 +211,10 @@ CONF_SCHEMA = { 'sell': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, 'forcesell': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, 'forcebuy': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, - 'emergencysell': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, + 'emergencysell': { + 'type': 'string', + 'enum': ORDERTYPE_POSSIBILITIES, + 'default': 'market'}, 'stoploss': {'type': 'string', 'enum': ORDERTYPE_POSSIBILITIES}, 'stoploss_on_exchange': {'type': 'boolean'}, 'stoploss_on_exchange_interval': {'type': 'number'}, diff --git a/freqtrade/data/converter.py b/freqtrade/data/converter.py index ca6464965..d592b4990 100644 --- a/freqtrade/data/converter.py +++ b/freqtrade/data/converter.py @@ -113,7 +113,7 @@ def ohlcv_fill_up_missing_data(dataframe: DataFrame, timeframe: str, pair: str) pct_missing = (len_after - len_before) / len_before if len_before > 0 else 0 if len_before != len_after: message = (f"Missing data fillup for {pair}: before: {len_before} - after: {len_after}" - f" - {round(pct_missing * 100, 2)}%") + f" - {pct_missing:.2%}") if pct_missing > 0.01: logger.info(message) else: diff --git a/freqtrade/exceptions.py b/freqtrade/exceptions.py index 056be8720..6b0039a3f 100644 --- a/freqtrade/exceptions.py +++ b/freqtrade/exceptions.py @@ -1,5 +1,3 @@ - - class FreqtradeException(Exception): """ Freqtrade base exception. Handled at the outermost level. diff --git a/freqtrade/exchange/binance.py b/freqtrade/exchange/binance.py index 232e2cb55..787285a02 100644 --- a/freqtrade/exchange/binance.py +++ b/freqtrade/exchange/binance.py @@ -201,8 +201,9 @@ class Binance(Exchange): raise OperationalException(e) from e async def _async_get_historic_ohlcv(self, pair: str, timeframe: str, - since_ms: int, is_new_pair: bool - ) -> List: + since_ms: int, is_new_pair: bool = False, + raise_: bool = False + ) -> Tuple[str, str, List]: """ Overwrite to introduce "fast new pair" functionality by detecting the pair's listing date Does not work for other exchanges, which don't return the earliest data when called with "0" @@ -215,7 +216,8 @@ class Binance(Exchange): logger.info(f"Candle-data for {pair} available starting with " f"{arrow.get(since_ms // 1000).isoformat()}.") return await super()._async_get_historic_ohlcv( - pair=pair, timeframe=timeframe, since_ms=since_ms, is_new_pair=is_new_pair) + pair=pair, timeframe=timeframe, since_ms=since_ms, is_new_pair=is_new_pair, + raise_=raise_) def funding_fee_cutoff(self, open_date: datetime): """ diff --git a/freqtrade/exchange/common.py b/freqtrade/exchange/common.py index 644a13e93..a4c827e07 100644 --- a/freqtrade/exchange/common.py +++ b/freqtrade/exchange/common.py @@ -81,9 +81,16 @@ def retrier_async(f): count -= 1 kwargs.update({'count': count}) if isinstance(ex, DDosProtection): - backoff_delay = calculate_backoff(count + 1, API_RETRY_COUNT) - logger.info(f"Applying DDosProtection backoff delay: {backoff_delay}") - await asyncio.sleep(backoff_delay) + if "kucoin" in str(ex) and "429000" in str(ex): + # Temporary fix for 429000 error on kucoin + # see https://github.com/freqtrade/freqtrade/issues/5700 for details. + logger.warning( + f"Kucoin 429 error, avoid triggering DDosProtection backoff delay. " + f"{count} tries left before giving up") + else: + backoff_delay = calculate_backoff(count + 1, API_RETRY_COUNT) + logger.info(f"Applying DDosProtection backoff delay: {backoff_delay}") + await asyncio.sleep(backoff_delay) return await wrapper(*args, **kwargs) else: logger.warning('Giving up retrying: %s()', f.__name__) diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 20a0c7e69..8f91e6a47 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -168,9 +168,10 @@ class Exchange: self.validate_pairs(config['exchange']['pair_whitelist']) self.validate_ordertypes(config.get('order_types', {})) self.validate_order_time_in_force(config.get('order_time_in_force', {})) - self.validate_required_startup_candles(config.get('startup_candle_count', 0), - config.get('timeframe', '')) + self.required_candle_call_count = self.validate_required_startup_candles( + config.get('startup_candle_count', 0), config.get('timeframe', '')) self.validate_trading_mode_and_collateral(self.trading_mode, self.collateral) + # Converts the interval provided in minutes in config to seconds self.markets_refresh_interval: int = exchange_config.get( "markets_refresh_interval", 60) * 60 @@ -523,16 +524,29 @@ class Exchange: raise OperationalException( f'Time in force policies are not supported for {self.name} yet.') - def validate_required_startup_candles(self, startup_candles: int, timeframe: str) -> None: + def validate_required_startup_candles(self, startup_candles: int, timeframe: str) -> int: """ Checks if required startup_candles is more than ohlcv_candle_limit(). Requires a grace-period of 5 candles - so a startup-period up to 494 is allowed by default. """ candle_limit = self.ohlcv_candle_limit(timeframe) - if startup_candles + 5 > candle_limit: + # Require one more candle - to account for the still open candle. + candle_count = startup_candles + 1 + # Allow 5 calls to the exchange per pair + required_candle_call_count = int( + (candle_count / candle_limit) + (0 if candle_count % candle_limit == 0 else 1)) + + if required_candle_call_count > 5: + # Only allow 5 calls per pair to somewhat limit the impact raise OperationalException( - f"This strategy requires {startup_candles} candles to start. " - f"{self.name} only provides {candle_limit - 5} for {timeframe}.") + f"This strategy requires {startup_candles} candles to start, which is more than 5x " + f"the amount of candles {self.name} provides for {timeframe}.") + + if required_candle_call_count > 1: + logger.warning(f"Using {required_candle_call_count} calls to get OHLCV. " + f"This can result in slower operations for the bot. Please check " + f"if you really need {startup_candles} candles for your strategy") + return required_candle_call_count def validate_trading_mode_and_collateral( self, @@ -1306,9 +1320,11 @@ class Exchange: :param since_ms: Timestamp in milliseconds to get history from :return: List with candle (OHLCV) data """ - return asyncio.get_event_loop().run_until_complete( + pair, timeframe, data = asyncio.get_event_loop().run_until_complete( self._async_get_historic_ohlcv(pair=pair, timeframe=timeframe, since_ms=since_ms, is_new_pair=is_new_pair)) + logger.info(f"Downloaded data for {pair} with length {len(data)}.") + return data def get_historic_ohlcv_as_df(self, pair: str, timeframe: str, since_ms: int) -> DataFrame: @@ -1324,8 +1340,9 @@ class Exchange: drop_incomplete=self._ohlcv_partial_candle) async def _async_get_historic_ohlcv(self, pair: str, timeframe: str, - since_ms: int, is_new_pair: bool - ) -> List: + since_ms: int, is_new_pair: bool = False, + raise_: bool = False + ) -> Tuple[str, str, List]: """ Download historic ohlcv :param is_new_pair: used by binance subclass to allow "fast" new pair downloading @@ -1349,15 +1366,17 @@ class Exchange: for res in results: if isinstance(res, Exception): logger.warning("Async code raised an exception: %s", res.__class__.__name__) + if raise_: + raise continue - # Deconstruct tuple if it's not an exception - p, _, new_data = res - if p == pair: - data.extend(new_data) + else: + # Deconstruct tuple if it's not an exception + p, _, new_data = res + if p == pair: + data.extend(new_data) # Sort data again after extending the result - above calls return in "async order" data = sorted(data, key=lambda x: x[0]) - logger.info(f"Downloaded data for {pair} with length {len(data)}.") - return data + return pair, timeframe, data def refresh_latest_ohlcv(self, pair_list: ListPairsWithTimeframes, *, since_ms: Optional[int] = None, cache: bool = True @@ -1377,10 +1396,22 @@ class Exchange: cached_pairs = [] # Gather coroutines to run for pair, timeframe in set(pair_list): - if (((pair, timeframe) not in self._klines) + if ((pair, timeframe) not in self._klines or self._now_is_time_to_refresh(pair, timeframe)): - input_coroutines.append(self._async_get_candle_history(pair, timeframe, - since_ms=since_ms)) + if not since_ms and self.required_candle_call_count > 1: + # Multiple calls for one pair - to get more history + one_call = timeframe_to_msecs(timeframe) * self.ohlcv_candle_limit(timeframe) + move_to = one_call * self.required_candle_call_count + now = timeframe_to_next_date(timeframe) + since_ms = int((now - timedelta(seconds=move_to // 1000)).timestamp() * 1000) + + if since_ms: + input_coroutines.append(self._async_get_historic_ohlcv( + pair, timeframe, since_ms=since_ms, raise_=True)) + else: + # One call ... "regular" refresh + input_coroutines.append(self._async_get_candle_history( + pair, timeframe, since_ms=since_ms)) else: logger.debug( "Using cached candle (OHLCV) data for pair %s, timeframe %s ...", diff --git a/freqtrade/exchange/kucoin.py b/freqtrade/exchange/kucoin.py index 5d818f6a2..2884669a6 100644 --- a/freqtrade/exchange/kucoin.py +++ b/freqtrade/exchange/kucoin.py @@ -1,4 +1,4 @@ -""" Kucoin exchange subclass """ +"""Kucoin exchange subclass.""" import logging from typing import Dict @@ -9,9 +9,9 @@ logger = logging.getLogger(__name__) class Kucoin(Exchange): - """ - Kucoin exchange class. Contains adjustments needed for Freqtrade to work - with this exchange. + """Kucoin exchange class. + + Contains adjustments needed for Freqtrade to work with this exchange. Please note that this exchange is not included in the list of exchanges officially supported by the Freqtrade development team. So some features diff --git a/freqtrade/exchange/okex.py b/freqtrade/exchange/okex.py index 98e493d9b..62e6d977b 100644 --- a/freqtrade/exchange/okex.py +++ b/freqtrade/exchange/okex.py @@ -9,9 +9,9 @@ logger = logging.getLogger(__name__) class Okex(Exchange): - """ - Okex exchange class. Contains adjustments needed for Freqtrade to work - with this exchange. + """Okex exchange class. + + Contains adjustments needed for Freqtrade to work with this exchange. """ _ft_has: Dict = { diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index 708635991..d234ebb07 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -219,19 +219,20 @@ class FreqtradeBot(LoggingMixin): def check_for_open_trades(self): """ - Notify the user when the bot is stopped + Notify the user when the bot is stopped (not reloaded) and there are still open trades active. """ open_trades = Trade.get_trades([Trade.is_open.is_(True)]).all() - if len(open_trades) != 0: + if len(open_trades) != 0 and self.state != State.RELOAD_CONFIG: msg = { 'type': RPCMessageType.WARNING, - 'status': f"{len(open_trades)} open trades active.\n\n" - f"Handle these trades manually on {self.exchange.name}, " - f"or '/start' the bot again and use '/stopbuy' " - f"to handle open trades gracefully. \n" - f"{'Trades are simulated.' if self.config['dry_run'] else ''}", + 'status': + f"{len(open_trades)} open trades active.\n\n" + f"Handle these trades manually on {self.exchange.name}, " + f"or '/start' the bot again and use '/stopbuy' " + f"to handle open trades gracefully. \n" + f"{'Note: Trades are simulated (dry run).' if self.config['dry_run'] else ''}", } self.rpc.send_msg(msg) @@ -622,7 +623,7 @@ class FreqtradeBot(LoggingMixin): side='short' if is_short else 'long' ) - stake_amount = self.wallets._validate_stake_amount(pair, stake_amount, min_stake_amount) + stake_amount = self.wallets.validate_stake_amount(pair, stake_amount, min_stake_amount) if not stake_amount: return False @@ -1075,6 +1076,7 @@ class FreqtradeBot(LoggingMixin): side = trade.enter_side if is_entering else trade.exit_side timed_out = self._check_timed_out(side, order) time_method = 'check_sell_timeout' if order['side'] == 'sell' else 'check_buy_timeout' + max_timeouts = self.config.get('unfilledtimeout', {}).get('exit_timeout_count', 0) if not_closed and (fully_cancelled or timed_out or ( strategy_safe_wrapper(getattr(self.strategy, time_method), default_retval=False)( @@ -1087,6 +1089,13 @@ class FreqtradeBot(LoggingMixin): self.handle_cancel_enter(trade, order, constants.CANCEL_REASON['TIMEOUT']) else: self.handle_cancel_exit(trade, order, constants.CANCEL_REASON['TIMEOUT']) + canceled_count = trade.get_exit_order_count() + if max_timeouts > 0 and canceled_count >= max_timeouts: + logger.warning(f'Emergencyselling trade {trade}, as the sell order ' + f'timed out {max_timeouts} times.') + self.execute_trade_exit( + trade, order.get('price'), + sell_reason=SellCheckTuple(sell_type=SellType.EMERGENCY_SELL)) def cancel_all_open_orders(self) -> None: """ @@ -1468,7 +1477,7 @@ class FreqtradeBot(LoggingMixin): if self.exchange.check_order_canceled_empty(order): # Trade has been cancelled on exchange - # Handling of this will happen in check_handle_timeout. + # Handling of this will happen in check_handle_timedout. return True # Try update amount (binance-fix) @@ -1559,14 +1568,17 @@ class FreqtradeBot(LoggingMixin): return self.apply_fee_conditional(trade, trade_base_currency, amount=order_amount, fee_abs=fee_cost) return order_amount - return self.fee_detection_from_trades(trade, order, order_amount) + return self.fee_detection_from_trades(trade, order, order_amount, order.get('trades', [])) - def fee_detection_from_trades(self, trade: Trade, order: Dict, order_amount: float) -> float: + def fee_detection_from_trades(self, trade: Trade, order: Dict, order_amount: float, + trades: List) -> float: """ - fee-detection fallback to Trades. Parses result of fetch_my_trades to get correct fee. + fee-detection fallback to Trades. + Either uses provided trades list or the result of fetch_my_trades to get correct fee. """ - trades = self.exchange.get_trades_for_order(self.exchange.get_order_id_conditional(order), - trade.pair, trade.open_date) + if not trades: + trades = self.exchange.get_trades_for_order( + self.exchange.get_order_id_conditional(order), trade.pair, trade.open_date) if len(trades) == 0: logger.info("Applying fee on amount for %s failed: myTrade-Dict empty found", trade) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 26408e341..0f4d17fd8 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -379,16 +379,6 @@ class Backtesting: if sell.sell_flag: trade.close_date = sell_candle_time - trade.sell_reason = sell.sell_reason - - # Checks and adds an exit tag, after checking that the length of the - # sell_row has the length for an exit tag column - if( - len(sell_row) > EXIT_TAG_IDX - and sell_row[EXIT_TAG_IDX] is not None - and len(sell_row[EXIT_TAG_IDX]) > 0 - ): - trade.sell_reason = sell_row[EXIT_TAG_IDX] trade_dur = int((trade.close_date_utc - trade.open_date_utc).total_seconds() // 60) closerate = self._get_close_rate(sell_row, trade, sell, trade_dur) @@ -403,6 +393,17 @@ class Backtesting: current_time=sell_candle_time): return None + trade.sell_reason = sell.sell_reason + + # Checks and adds an exit tag, after checking that the length of the + # sell_row has the length for an exit tag column + if( + len(sell_row) > EXIT_TAG_IDX + and sell_row[EXIT_TAG_IDX] is not None + and len(sell_row[EXIT_TAG_IDX]) > 0 + ): + trade.sell_reason = sell_row[EXIT_TAG_IDX] + trade.close(closerate, show_msg=False) return trade @@ -451,7 +452,7 @@ class Backtesting: pair=pair, current_time=row[DATE_IDX].to_pydatetime(), current_rate=row[OPEN_IDX], proposed_stake=stake_amount, min_stake=min_stake_amount, max_stake=max_stake_amount, side=direction) - stake_amount = self.wallets._validate_stake_amount(pair, stake_amount, min_stake_amount) + stake_amount = self.wallets.validate_stake_amount(pair, stake_amount, min_stake_amount) if not stake_amount: return None diff --git a/freqtrade/optimize/hyperopt_tools.py b/freqtrade/optimize/hyperopt_tools.py index 0b2efa5c2..1204320da 100755 --- a/freqtrade/optimize/hyperopt_tools.py +++ b/freqtrade/optimize/hyperopt_tools.py @@ -284,10 +284,10 @@ class HyperoptTools(): return (f"{results_metrics['total_trades']:6d} trades. " f"{results_metrics['wins']}/{results_metrics['draws']}" f"/{results_metrics['losses']} Wins/Draws/Losses. " - f"Avg profit {results_metrics['profit_mean'] * 100: 6.2f}%. " - f"Median profit {results_metrics['profit_median'] * 100: 6.2f}%. " - f"Total profit {results_metrics['profit_total_abs']: 11.8f} {stake_currency} " - f"({results_metrics['profit_total'] * 100: 7.2f}%). " + f"Avg profit {results_metrics['profit_mean']:7.2%}. " + f"Median profit {results_metrics['profit_median']:7.2%}. " + f"Total profit {results_metrics['profit_total_abs']:11.8f} {stake_currency} " + f"({results_metrics['profit_total']:8.2%}). " f"Avg duration {results_metrics['holding_avg']} min." ) diff --git a/freqtrade/optimize/optimize_reports.py b/freqtrade/optimize/optimize_reports.py index 14d928e54..c4002fcbe 100644 --- a/freqtrade/optimize/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports.py @@ -725,22 +725,22 @@ def text_table_add_metrics(strat_results: Dict) -> str: strat_results['stake_currency'])), ('Absolute profit ', round_coin_value(strat_results['profit_total_abs'], strat_results['stake_currency'])), - ('Total profit %', f"{round(strat_results['profit_total'] * 100, 2)}%"), + ('Total profit %', f"{strat_results['profit_total']:.2%}"), ('Trades per day', strat_results['trades_per_day']), ('Avg. daily profit %', - f"{round(strat_results['profit_total'] / strat_results['backtest_days'] * 100, 2)}%"), + f"{(strat_results['profit_total'] / strat_results['backtest_days']):.2%}"), ('Avg. stake amount', round_coin_value(strat_results['avg_stake_amount'], strat_results['stake_currency'])), ('Total trade volume', round_coin_value(strat_results['total_volume'], strat_results['stake_currency'])), ('', ''), # Empty line to improve readability ('Best Pair', f"{strat_results['best_pair']['key']} " - f"{round(strat_results['best_pair']['profit_sum_pct'], 2)}%"), + f"{strat_results['best_pair']['profit_sum']:.2%}"), ('Worst Pair', f"{strat_results['worst_pair']['key']} " - f"{round(strat_results['worst_pair']['profit_sum_pct'], 2)}%"), - ('Best trade', f"{best_trade['pair']} {round(best_trade['profit_ratio'] * 100, 2)}%"), + f"{strat_results['worst_pair']['profit_sum']:.2%}"), + ('Best trade', f"{best_trade['pair']} {best_trade['profit_ratio']:.2%}"), ('Worst trade', f"{worst_trade['pair']} " - f"{round(worst_trade['profit_ratio'] * 100, 2)}%"), + f"{worst_trade['profit_ratio']:.2%}"), ('Best day', round_coin_value(strat_results['backtest_best_day_abs'], strat_results['stake_currency'])), @@ -758,7 +758,7 @@ def text_table_add_metrics(strat_results: Dict) -> str: ('Max balance', round_coin_value(strat_results['csum_max'], strat_results['stake_currency'])), - ('Drawdown', f"{round(strat_results['max_drawdown'] * 100, 2)}%"), + ('Drawdown', f"{strat_results['max_drawdown']:.2%}"), ('Drawdown', round_coin_value(strat_results['max_drawdown_abs'], strat_results['stake_currency'])), ('Drawdown high', round_coin_value(strat_results['max_drawdown_high'], @@ -767,7 +767,7 @@ def text_table_add_metrics(strat_results: Dict) -> str: strat_results['stake_currency'])), ('Drawdown Start', strat_results['drawdown_start']), ('Drawdown End', strat_results['drawdown_end']), - ('Market change', f"{round(strat_results['market_change'] * 100, 2)}%"), + ('Market change', f"{strat_results['market_change']:.2%}"), ] return tabulate(metrics, headers=["Metric", "Value"], tablefmt="orgtbl") @@ -864,5 +864,5 @@ def show_sorted_pairlist(config: Dict, backtest_stats: Dict): print(f"Pairs for Strategy {strategy}: \n[") for result in results['results_per_pair']: if result["key"] != 'TOTAL': - print(f'"{result["key"]}", // {round(result["profit_mean_pct"], 2)}%') + print(f'"{result["key"]}", // {result["profit_mean"]:.2%}') print("]") diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index 9ba88057c..cb38bc01d 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -200,6 +200,8 @@ class Order(_DECL_BASE): @staticmethod def get_open_orders() -> List['Order']: """ + Retrieve open orders from the database + :return: List of open orders """ return Order.query.filter(Order.ft_is_open.is_(True)).all() @@ -620,6 +622,13 @@ class LocalTrade(): def update_order(self, order: Dict) -> None: Order.update_orders(self.orders, order) + def get_exit_order_count(self) -> int: + """ + Get amount of failed exiting orders + assumes full exits. + """ + return len([o for o in self.orders if o.ft_order_side == 'sell']) + def _calc_open_trade_value(self) -> float: """ Calculate the open_rate including open_fee. @@ -1002,7 +1011,7 @@ class Trade(_DECL_BASE, LocalTrade): return Trade.query @staticmethod - def get_open_order_trades(): + def get_open_order_trades() -> List['Trade']: """ Returns all open trades NOTE: Not supported in Backtesting. @@ -1190,6 +1199,7 @@ class Trade(_DECL_BASE, LocalTrade): if not any(item["mix_tag"] == mix_tag for item in return_list): return_list.append({'mix_tag': mix_tag, 'profit': profit, + 'profit_pct': round(profit * 100, 2), 'profit_abs': profit_abs, 'count': count}) else: @@ -1198,11 +1208,11 @@ class Trade(_DECL_BASE, LocalTrade): return_list[i] = { 'mix_tag': mix_tag, 'profit': profit + return_list[i]["profit"], + 'profit_pct': round(profit + return_list[i]["profit"] * 100, 2), 'profit_abs': profit_abs + return_list[i]["profit_abs"], 'count': 1 + return_list[i]["count"]} i += 1 - [x.update({'profit': round(x['profit'] * 100, 2)}) for x in return_list] return return_list @staticmethod diff --git a/freqtrade/plot/plotting.py b/freqtrade/plot/plotting.py index 43b61cf67..26be7f235 100644 --- a/freqtrade/plot/plotting.py +++ b/freqtrade/plot/plotting.py @@ -169,8 +169,8 @@ def add_max_drawdown(fig, row, trades: pd.DataFrame, df_comb: pd.DataFrame, df_comb.loc[timeframe_to_prev_date(timeframe, lowdate), 'cum_profit'], ], mode='markers', - name=f"Max drawdown {max_drawdown * 100:.2f}%", - text=f"Max drawdown {max_drawdown * 100:.2f}%", + name=f"Max drawdown {max_drawdown:.2%}", + text=f"Max drawdown {max_drawdown:.2%}", marker=dict( symbol='square-open', size=9, @@ -192,7 +192,7 @@ def plot_trades(fig, trades: pd.DataFrame) -> make_subplots: # Trades can be empty if trades is not None and len(trades) > 0: # Create description for sell summarizing the trade - trades['desc'] = trades.apply(lambda row: f"{round(row['profit_ratio'] * 100, 1)}%, " + trades['desc'] = trades.apply(lambda row: f"{row['profit_ratio']:.2%}, " f"{row['sell_reason']}, " f"{row['trade_duration']} min", axis=1) diff --git a/freqtrade/plugins/pairlist/PriceFilter.py b/freqtrade/plugins/pairlist/PriceFilter.py index 5b5afb557..63623d8c8 100644 --- a/freqtrade/plugins/pairlist/PriceFilter.py +++ b/freqtrade/plugins/pairlist/PriceFilter.py @@ -50,7 +50,7 @@ class PriceFilter(IPairList): """ active_price_filters = [] if self._low_price_ratio != 0: - active_price_filters.append(f"below {self._low_price_ratio * 100}%") + active_price_filters.append(f"below {self._low_price_ratio:.1%}") if self._min_price != 0: active_price_filters.append(f"below {self._min_price:.8f}") if self._max_price != 0: @@ -82,7 +82,7 @@ class PriceFilter(IPairList): changeperc = compare / ticker['last'] if changeperc > self._low_price_ratio: self.log_once(f"Removed {pair} from whitelist, " - f"because 1 unit is {changeperc * 100:.3f}%", logger.info) + f"because 1 unit is {changeperc:.3%}", logger.info) return False # Perform low_amount check diff --git a/freqtrade/plugins/pairlist/SpreadFilter.py b/freqtrade/plugins/pairlist/SpreadFilter.py index 1b152774b..2d6e728ec 100644 --- a/freqtrade/plugins/pairlist/SpreadFilter.py +++ b/freqtrade/plugins/pairlist/SpreadFilter.py @@ -34,7 +34,7 @@ class SpreadFilter(IPairList): Short whitelist method description - used for startup-messages """ return (f"{self.name} - Filtering pairs with ask/bid diff above " - f"{self._max_spread_ratio * 100}%.") + f"{self._max_spread_ratio:.2%}.") def _validate_pair(self, pair: str, ticker: Dict[str, Any]) -> bool: """ @@ -47,7 +47,7 @@ class SpreadFilter(IPairList): spread = 1 - ticker['bid'] / ticker['ask'] if spread > self._max_spread_ratio: self.log_once(f"Removed {pair} from whitelist, because spread " - f"{spread * 100:.3f}% > {self._max_spread_ratio * 100}%", + f"{spread * 100:.3%} > {self._max_spread_ratio:.3%}", logger.info) return False else: diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index 113e93b5a..acc735832 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -95,6 +95,7 @@ class Profit(BaseModel): avg_duration: str best_pair: str best_rate: float + best_pair_profit_ratio: float winning_trades: int losing_trades: int @@ -123,7 +124,26 @@ class Daily(BaseModel): stake_currency: str +class UnfilledTimeout(BaseModel): + buy: int + sell: int + unit: str + exit_timeout_count: Optional[int] + + +class OrderTypes(BaseModel): + buy: str + sell: str + emergencysell: Optional[str] + forcesell: Optional[str] + forcebuy: Optional[str] + stoploss: str + stoploss_on_exchange: bool + stoploss_on_exchange_interval: Optional[int] + + class ShowConfig(BaseModel): + version: str dry_run: bool stake_currency: str stake_amount: Union[float, str] @@ -136,6 +156,8 @@ class ShowConfig(BaseModel): trailing_stop_positive: Optional[float] trailing_stop_positive_offset: Optional[float] trailing_only_offset_is_reached: Optional[bool] + unfilledtimeout: UnfilledTimeout + order_types: OrderTypes use_custom_stoploss: Optional[bool] timeframe: Optional[str] timeframe_ms: int diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 4a44ebe77..509272788 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -9,9 +9,11 @@ from typing import Any, Dict, List, Optional, Tuple, Union import arrow import psutil +from dateutil.relativedelta import relativedelta from numpy import NAN, inf, int64, mean from pandas import DataFrame +from freqtrade import __version__ from freqtrade.configuration.timerange import TimeRange from freqtrade.constants import CANCEL_REASON, DATETIME_PRINT_FORMAT from freqtrade.data.history import load_data @@ -104,6 +106,7 @@ class RPC: information via rpc. """ val = { + 'version': __version__, 'dry_run': config['dry_run'], 'stake_currency': config['stake_currency'], 'stake_currency_decimals': decimals_per_coin(config['stake_currency']), @@ -117,7 +120,9 @@ class RPC: 'trailing_stop_positive': config.get('trailing_stop_positive'), 'trailing_stop_positive_offset': config.get('trailing_stop_positive_offset'), 'trailing_only_offset_is_reached': config.get('trailing_only_offset_is_reached'), + 'unfilledtimeout': config.get('unfilledtimeout'), 'use_custom_stoploss': config.get('use_custom_stoploss'), + 'order_types': config.get('order_types'), 'bot_name': config.get('bot_name', 'freqtrade'), 'timeframe': config.get('timeframe'), 'timeframe_ms': timeframe_to_msecs(config['timeframe'] @@ -222,9 +227,8 @@ class RPC: trade.pair, refresh=False, side=closing_side) except (PricingError, ExchangeError): current_rate = NAN - trade_percent = (100 * trade.calc_profit_ratio(current_rate)) trade_profit = trade.calc_profit(current_rate) - profit_str = f'{trade_percent:.2f}%' + profit_str = f'{trade.calc_profit_ratio(current_rate):.2%}' if self._fiat_converter: fiat_profit = self._fiat_converter.convert_amount( trade_profit, @@ -253,7 +257,7 @@ class RPC: def _rpc_daily_profit( self, timescale: int, stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]: - today = datetime.utcnow().date() + today = datetime.now(timezone.utc).date() profit_days: Dict[date, Dict] = {} if not (isinstance(timescale, int) and timescale > 0): @@ -292,6 +296,91 @@ class RPC: 'data': data } + def _rpc_weekly_profit( + self, timescale: int, + stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]: + today = datetime.now(timezone.utc).date() + first_iso_day_of_week = today - timedelta(days=today.weekday()) # Monday + profit_weeks: Dict[date, Dict] = {} + + if not (isinstance(timescale, int) and timescale > 0): + raise RPCException('timescale must be an integer greater than 0') + + for week in range(0, timescale): + profitweek = first_iso_day_of_week - timedelta(weeks=week) + trades = Trade.get_trades(trade_filter=[ + Trade.is_open.is_(False), + Trade.close_date >= profitweek, + Trade.close_date < (profitweek + timedelta(weeks=1)) + ]).order_by(Trade.close_date).all() + curweekprofit = sum( + trade.close_profit_abs for trade in trades if trade.close_profit_abs is not None) + profit_weeks[profitweek] = { + 'amount': curweekprofit, + 'trades': len(trades) + } + + data = [ + { + 'date': key, + 'abs_profit': value["amount"], + 'fiat_value': self._fiat_converter.convert_amount( + value['amount'], + stake_currency, + fiat_display_currency + ) if self._fiat_converter else 0, + 'trade_count': value["trades"], + } + for key, value in profit_weeks.items() + ] + return { + 'stake_currency': stake_currency, + 'fiat_display_currency': fiat_display_currency, + 'data': data + } + + def _rpc_monthly_profit( + self, timescale: int, + stake_currency: str, fiat_display_currency: str) -> Dict[str, Any]: + first_day_of_month = datetime.now(timezone.utc).date().replace(day=1) + profit_months: Dict[date, Dict] = {} + + if not (isinstance(timescale, int) and timescale > 0): + raise RPCException('timescale must be an integer greater than 0') + + for month in range(0, timescale): + profitmonth = first_day_of_month - relativedelta(months=month) + trades = Trade.get_trades(trade_filter=[ + Trade.is_open.is_(False), + Trade.close_date >= profitmonth, + Trade.close_date < (profitmonth + relativedelta(months=1)) + ]).order_by(Trade.close_date).all() + curmonthprofit = sum( + trade.close_profit_abs for trade in trades if trade.close_profit_abs is not None) + profit_months[profitmonth] = { + 'amount': curmonthprofit, + 'trades': len(trades) + } + + data = [ + { + 'date': f"{key.year}-{key.month:02d}", + 'abs_profit': value["amount"], + 'fiat_value': self._fiat_converter.convert_amount( + value['amount'], + stake_currency, + fiat_display_currency + ) if self._fiat_converter else 0, + 'trade_count': value["trades"], + } + for key, value in profit_months.items() + ] + return { + 'stake_currency': stake_currency, + 'fiat_display_currency': fiat_display_currency, + 'data': data + } + def _rpc_trade_history(self, limit: int, offset: int = 0, order_by_id: bool = False) -> Dict: """ Returns the X last trades """ order_by = Trade.id if order_by_id else Trade.close_date.desc() @@ -448,7 +537,8 @@ class RPC: 'latest_trade_timestamp': int(last_date.timestamp() * 1000) if last_date else 0, 'avg_duration': str(timedelta(seconds=sum(durations) / num)).split('.')[0], 'best_pair': best_pair[0] if best_pair else '', - 'best_rate': round(best_pair[1] * 100, 2) if best_pair else 0, + 'best_rate': round(best_pair[1] * 100, 2) if best_pair else 0, # Deprecated + 'best_pair_profit_ratio': best_pair[1] if best_pair else 0, 'winning_trades': winning_trades, 'losing_trades': losing_trades, } @@ -824,15 +914,15 @@ class RPC: if has_content: dataframe.loc[:, '__date_ts'] = dataframe.loc[:, 'date'].view(int64) // 1000 // 1000 - # Move open to separate column when signal for easy plotting + # Move signal close to separate column when signal for easy plotting if 'buy' in dataframe.columns: buy_mask = (dataframe['buy'] == 1) buy_signals = int(buy_mask.sum()) - dataframe.loc[buy_mask, '_buy_signal_open'] = dataframe.loc[buy_mask, 'open'] + dataframe.loc[buy_mask, '_buy_signal_close'] = dataframe.loc[buy_mask, 'close'] if 'sell' in dataframe.columns: sell_mask = (dataframe['sell'] == 1) sell_signals = int(sell_mask.sum()) - dataframe.loc[sell_mask, '_sell_signal_open'] = dataframe.loc[sell_mask, 'open'] + dataframe.loc[sell_mask, '_sell_signal_close'] = dataframe.loc[sell_mask, 'close'] dataframe = dataframe.replace([inf, -inf], NAN) dataframe = dataframe.replace({NAN: None}) diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index 77d5f06e2..0e1a6fe27 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -159,6 +159,8 @@ class Telegram(RPCHandler): CommandHandler('mix_tags', self._mix_tag_performance), CommandHandler('stats', self._stats), CommandHandler('daily', self._daily), + CommandHandler('weekly', self._weekly), + CommandHandler('monthly', self._monthly), CommandHandler('count', self._count), CommandHandler('locks', self._locks), CommandHandler(['unlock', 'delete_locks'], self._delete_locks), @@ -175,6 +177,8 @@ class Telegram(RPCHandler): callbacks = [ CallbackQueryHandler(self._status_table, pattern='update_status_table'), CallbackQueryHandler(self._daily, pattern='update_daily'), + CallbackQueryHandler(self._weekly, pattern='update_weekly'), + CallbackQueryHandler(self._monthly, pattern='update_monthly'), CallbackQueryHandler(self._profit, pattern='update_profit'), CallbackQueryHandler(self._balance, pattern='update_balance'), CallbackQueryHandler(self._performance, pattern='update_performance'), @@ -215,26 +219,28 @@ class Telegram(RPCHandler): msg['stake_amount'], msg['stake_currency'], msg['fiat_currency']) else: msg['stake_amount_fiat'] = 0 + is_fill = msg['type'] == RPCMessageType.BUY_FILL + emoji = '\N{CHECK MARK}' if is_fill else '\N{LARGE BLUE CIRCLE}' - content = [] - content.append( - f"\N{LARGE BLUE CIRCLE} *{msg['exchange']}:* Buying {msg['pair']}" + message = ( + f"{emoji} *{msg['exchange']}:* {'Bought' if is_fill else 'Buying'} {msg['pair']}" f" (#{msg['trade_id']})\n" - ) - if msg.get('buy_tag', None): - content.append(f"*Buy Tag:* `{msg['buy_tag']}`\n") - content.append(f"*Amount:* `{msg['amount']:.8f}`\n") - content.append(f"*Open Rate:* `{msg['limit']:.8f}`\n") - content.append(f"*Current Rate:* `{msg['current_rate']:.8f}`\n") - content.append( - f"*Total:* `({round_coin_value(msg['stake_amount'], msg['stake_currency'])}" - ) - if msg.get('fiat_currency', None): - content.append( - f", {round_coin_value(msg['stake_amount_fiat'], msg['fiat_currency'])}" ) + message += f"*Buy Tag:* `{msg['buy_tag']}`\n" if msg.get('buy_tag', None) else "" + message += f"*Amount:* `{msg['amount']:.8f}`\n" + + if msg['type'] == RPCMessageType.BUY_FILL: + message += f"*Open Rate:* `{msg['open_rate']:.8f}`\n" + + elif msg['type'] == RPCMessageType.BUY: + message += f"*Open Rate:* `{msg['limit']:.8f}`\n"\ + f"*Current Rate:* `{msg['current_rate']:.8f}`\n" + + message += f"*Total:* `({round_coin_value(msg['stake_amount'], msg['stake_currency'])}" + + if msg.get('fiat_currency', None): + message += f", {round_coin_value(msg['stake_amount_fiat'], msg['fiat_currency'])}" - message = ''.join(content) message += ")`" return message @@ -254,54 +260,57 @@ class Telegram(RPCHandler): and self._rpc._fiat_converter): msg['profit_fiat'] = self._rpc._fiat_converter.convert_amount( msg['profit_amount'], msg['stake_currency'], msg['fiat_currency']) - msg['profit_extra'] = (' ({gain}: {profit_amount:.8f} {stake_currency}' - ' / {profit_fiat:.3f} {fiat_currency})').format(**msg) + msg['profit_extra'] = ( + f" ({msg['gain']}: {msg['profit_amount']:.8f} {msg['stake_currency']}" + f" / {msg['profit_fiat']:.3f} {msg['fiat_currency']})") else: msg['profit_extra'] = '' + is_fill = msg['type'] == RPCMessageType.SELL_FILL + message = ( + f"{msg['emoji']} *{msg['exchange']}:* " + f"{'Sold' if is_fill else 'Selling'} {msg['pair']} (#{msg['trade_id']})\n" + f"*{'Profit' if is_fill else 'Unrealized Profit'}:* " + f"`{msg['profit_ratio']:.2%}{msg['profit_extra']}`\n" + f"*Buy Tag:* `{msg['buy_tag']}`\n" + f"*Sell Reason:* `{msg['sell_reason']}`\n" + f"*Duration:* `{msg['duration']} ({msg['duration_min']:.1f} min)`\n" + f"*Amount:* `{msg['amount']:.8f}`\n") - message = ("{emoji} *{exchange}:* Selling {pair} (#{trade_id})\n" - "*Profit:* `{profit_percent:.2f}%{profit_extra}`\n" - "*Buy Tag:* `{buy_tag}`\n" - "*Sell Reason:* `{sell_reason}`\n" - "*Duration:* `{duration} ({duration_min:.1f} min)`\n" - "*Amount:* `{amount:.8f}`\n" - "*Open Rate:* `{open_rate:.8f}`\n" - "*Current Rate:* `{current_rate:.8f}`\n" - "*Close Rate:* `{limit:.8f}`").format(**msg) + if msg['type'] == RPCMessageType.SELL: + message += (f"*Open Rate:* `{msg['open_rate']:.8f}`\n" + f"*Current Rate:* `{msg['current_rate']:.8f}`\n" + f"*Close Rate:* `{msg['limit']:.8f}`") + + elif msg['type'] == RPCMessageType.SELL_FILL: + message += f"*Close Rate:* `{msg['close_rate']:.8f}`" return message def compose_message(self, msg: Dict[str, Any], msg_type: RPCMessageType) -> str: - - if msg_type == RPCMessageType.BUY: + if msg_type in [RPCMessageType.BUY, RPCMessageType.BUY_FILL]: message = self._format_buy_msg(msg) + elif msg_type in [RPCMessageType.SELL, RPCMessageType.SELL_FILL]: + message = self._format_sell_msg(msg) + elif msg_type in (RPCMessageType.BUY_CANCEL, RPCMessageType.SELL_CANCEL): msg['message_side'] = 'buy' if msg_type == RPCMessageType.BUY_CANCEL else 'sell' message = ("\N{WARNING SIGN} *{exchange}:* " "Cancelling open {message_side} Order for {pair} (#{trade_id}). " "Reason: {reason}.".format(**msg)) - elif msg_type == RPCMessageType.BUY_FILL: - message = ("\N{LARGE CIRCLE} *{exchange}:* " - "Buy order for {pair} (#{trade_id}) filled " - "for {open_rate}.".format(**msg)) - elif msg_type == RPCMessageType.SELL_FILL: - message = ("\N{LARGE CIRCLE} *{exchange}:* " - "Sell order for {pair} (#{trade_id}) filled " - "for {close_rate}.".format(**msg)) - elif msg_type == RPCMessageType.SELL: - message = self._format_sell_msg(msg) elif msg_type == RPCMessageType.PROTECTION_TRIGGER: message = ( "*Protection* triggered due to {reason}. " "`{pair}` will be locked until `{lock_end_time}`." ).format(**msg) + elif msg_type == RPCMessageType.PROTECTION_TRIGGER_GLOBAL: message = ( "*Protection* triggered due to {reason}. " "*All pairs* will be locked until `{lock_end_time}`." ).format(**msg) + elif msg_type == RPCMessageType.STATUS: message = '*Status:* `{status}`'.format(**msg) @@ -353,7 +362,7 @@ class Telegram(RPCHandler): elif float(msg['profit_percent']) >= 0.0: return "\N{EIGHT SPOKED ASTERISK}" elif msg['sell_reason'] == "stop_loss": - return"\N{WARNING SIGN}" + return "\N{WARNING SIGN}" else: return "\N{CROSS MARK}" @@ -393,19 +402,19 @@ class Telegram(RPCHandler): "*Close Rate:* `{close_rate}`" if r['close_rate'] else "", "*Current Rate:* `{current_rate:.8f}`", ("*Current Profit:* " if r['is_open'] else "*Close Profit: *") - + "`{profit_pct:.2f}%`", + + "`{profit_ratio:.2%}`", ] if (r['stop_loss_abs'] != r['initial_stop_loss_abs'] - and r['initial_stop_loss_pct'] is not None): + 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_pct:.2f}%)`") + "`({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_pct:.2f}%)`" if r['stop_loss_pct'] else "")) + ("`({stop_loss_ratio:.2%})`" if r['stop_loss_ratio'] else "")) lines.append("*Stoploss distance:* `{stoploss_current_dist:.8f}` " - "`({stoploss_current_dist_pct:.2f}%)`") + "`({stoploss_current_dist_ratio:.2%})`") if r['open_order']: if r['sell_order_status']: lines.append("*Open Order:* `{open_order}` - `{sell_order_status}`") @@ -501,6 +510,86 @@ class Telegram(RPCHandler): except RPCException as e: self._send_msg(str(e)) + @authorized_only + def _weekly(self, update: Update, context: CallbackContext) -> None: + """ + Handler for /weekly + Returns a weekly profit (in BTC) over the last n weeks. + :param bot: telegram bot + :param update: message update + :return: None + """ + stake_cur = self._config['stake_currency'] + fiat_disp_cur = self._config.get('fiat_display_currency', '') + try: + timescale = int(context.args[0]) if context.args else 8 + except (TypeError, ValueError, IndexError): + timescale = 8 + try: + stats = self._rpc._rpc_weekly_profit( + timescale, + stake_cur, + fiat_disp_cur + ) + stats_tab = tabulate( + [[week['date'], + f"{round_coin_value(week['abs_profit'], stats['stake_currency'])}", + f"{week['fiat_value']:.3f} {stats['fiat_display_currency']}", + f"{week['trade_count']} trades"] for week in stats['data']], + headers=[ + 'Monday', + f'Profit {stake_cur}', + f'Profit {fiat_disp_cur}', + 'Trades', + ], + tablefmt='simple') + message = f'Weekly Profit over the last {timescale} weeks ' \ + f'(starting from Monday):\n
{stats_tab}
' + self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True, + callback_path="update_weekly", query=update.callback_query) + except RPCException as e: + self._send_msg(str(e)) + + @authorized_only + def _monthly(self, update: Update, context: CallbackContext) -> None: + """ + Handler for /monthly + Returns a monthly profit (in BTC) over the last n months. + :param bot: telegram bot + :param update: message update + :return: None + """ + stake_cur = self._config['stake_currency'] + fiat_disp_cur = self._config.get('fiat_display_currency', '') + try: + timescale = int(context.args[0]) if context.args else 6 + except (TypeError, ValueError, IndexError): + timescale = 6 + try: + stats = self._rpc._rpc_monthly_profit( + timescale, + stake_cur, + fiat_disp_cur + ) + stats_tab = tabulate( + [[month['date'], + f"{round_coin_value(month['abs_profit'], stats['stake_currency'])}", + f"{month['fiat_value']:.3f} {stats['fiat_display_currency']}", + f"{month['trade_count']} trades"] for month in stats['data']], + headers=[ + 'Month', + f'Profit {stake_cur}', + f'Profit {fiat_disp_cur}', + 'Trades', + ], + tablefmt='simple') + message = f'Monthly Profit over the last {timescale} months' \ + f':\n
{stats_tab}
' + self._send_msg(message, parse_mode=ParseMode.HTML, reload_able=True, + callback_path="update_monthly", query=update.callback_query) + except RPCException as e: + self._send_msg(str(e)) + @authorized_only def _profit(self, update: Update, context: CallbackContext) -> None: """ @@ -528,11 +617,11 @@ class Telegram(RPCHandler): fiat_disp_cur, start_date) profit_closed_coin = stats['profit_closed_coin'] - profit_closed_percent_mean = stats['profit_closed_percent_mean'] + profit_closed_ratio_mean = stats['profit_closed_ratio_mean'] profit_closed_percent = stats['profit_closed_percent'] profit_closed_fiat = stats['profit_closed_fiat'] profit_all_coin = stats['profit_all_coin'] - profit_all_percent_mean = stats['profit_all_percent_mean'] + profit_all_ratio_mean = stats['profit_all_ratio_mean'] profit_all_percent = stats['profit_all_percent'] profit_all_fiat = stats['profit_all_fiat'] trade_count = stats['trade_count'] @@ -540,7 +629,7 @@ class Telegram(RPCHandler): latest_trade_date = stats['latest_trade_date'] avg_duration = stats['avg_duration'] best_pair = stats['best_pair'] - best_rate = stats['best_rate'] + best_pair_profit_ratio = stats['best_pair_profit_ratio'] if stats['trade_count'] == 0: markdown_msg = 'No trades yet.' else: @@ -548,7 +637,7 @@ class Telegram(RPCHandler): if stats['closed_trade_count'] > 0: markdown_msg = ("*ROI:* Closed trades\n" f"∙ `{round_coin_value(profit_closed_coin, stake_cur)} " - f"({profit_closed_percent_mean:.2f}%) " + f"({profit_closed_ratio_mean:.2%}) " f"({profit_closed_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n" f"∙ `{round_coin_value(profit_closed_fiat, fiat_disp_cur)}`\n") else: @@ -557,7 +646,7 @@ class Telegram(RPCHandler): markdown_msg += ( f"*ROI:* All trades\n" f"∙ `{round_coin_value(profit_all_coin, stake_cur)} " - f"({profit_all_percent_mean:.2f}%) " + f"({profit_all_ratio_mean:.2%}) " f"({profit_all_percent} \N{GREEK CAPITAL LETTER SIGMA}%)`\n" f"∙ `{round_coin_value(profit_all_fiat, fiat_disp_cur)}`\n" f"*Total Trade Count:* `{trade_count}`\n" @@ -568,7 +657,7 @@ class Telegram(RPCHandler): ) if stats['closed_trade_count'] > 0: markdown_msg += (f"\n*Avg. Duration:* `{avg_duration}`\n" - f"*Best Performing:* `{best_pair}: {best_rate:.2f}%`") + f"*Best Performing:* `{best_pair}: {best_pair_profit_ratio:.2%}`") self._send_msg(markdown_msg, reload_able=True, callback_path="update_profit", query=update.callback_query) @@ -597,10 +686,16 @@ class Telegram(RPCHandler): count['losses'] ] for reason, count in stats['sell_reasons'].items() ] - sell_reasons_msg = tabulate( - sell_reasons_tabulate, - headers=['Sell Reason', 'Sells', 'Wins', 'Losses'] - ) + sell_reasons_msg = 'No trades yet.' + for reason in chunks(sell_reasons_tabulate, 25): + sell_reasons_msg = tabulate( + reason, + headers=['Sell Reason', 'Sells', 'Wins', 'Losses'] + ) + if len(sell_reasons_tabulate) > 25: + self._send_msg(sell_reasons_msg, ParseMode.MARKDOWN) + sell_reasons_msg = '' + durations = stats['durations'] duration_msg = tabulate( [ @@ -671,10 +766,10 @@ class Telegram(RPCHandler): output += ("\n*Estimated Value*:\n" f"\t`{result['stake']}: " f"{round_coin_value(result['total'], result['stake'], False)}`" - f" `({result['starting_capital_pct']}%)`\n" + f" `({result['starting_capital_ratio']:.2%})`\n" f"\t`{result['symbol']}: " f"{round_coin_value(result['value'], result['symbol'], False)}`" - f" `({result['starting_capital_fiat_pct']}%)`\n") + f" `({result['starting_capital_fiat_ratio']:.2%})`\n") self._send_msg(output, reload_able=True, callback_path="update_balance", query=update.callback_query) except RPCException as e: @@ -809,7 +904,7 @@ class Telegram(RPCHandler): trades_tab = tabulate( [[arrow.get(trade['close_date']).humanize(), trade['pair'] + " (#" + str(trade['trade_id']) + ")", - f"{(100 * trade['close_profit']):.2f}% ({trade['close_profit_abs']})"] + f"{(trade['close_profit']):.2%} ({trade['close_profit_abs']})"] for trade in trades['trades']], headers=[ 'Close Date', @@ -861,7 +956,7 @@ class Telegram(RPCHandler): stat_line = ( f"{i+1}.\t {trade['pair']}\t" f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} " - f"({trade['profit_pct']:.2f}%) " + f"({trade['profit_ratio']:.2%}) " f"({trade['count']})\n") if len(output + stat_line) >= MAX_TELEGRAM_MESSAGE_LENGTH: @@ -896,7 +991,7 @@ class Telegram(RPCHandler): stat_line = ( f"{i+1}.\t {trade['buy_tag']}\t" f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} " - f"({trade['profit_pct']:.2f}%) " + f"({trade['profit_ratio']:.2%}) " f"({trade['count']})\n") if len(output + stat_line) >= MAX_TELEGRAM_MESSAGE_LENGTH: @@ -931,7 +1026,7 @@ class Telegram(RPCHandler): stat_line = ( f"{i+1}.\t {trade['sell_reason']}\t" f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} " - f"({trade['profit_pct']:.2f}%) " + f"({trade['profit_ratio']:.2%}) " f"({trade['count']})\n") if len(output + stat_line) >= MAX_TELEGRAM_MESSAGE_LENGTH: @@ -966,7 +1061,7 @@ class Telegram(RPCHandler): stat_line = ( f"{i+1}.\t {trade['mix_tag']}\t" f"{round_coin_value(trade['profit_abs'], self._config['stake_currency'])} " - f"({trade['profit']:.2f}%) " + f"({trade['profit']:.2%}) " f"({trade['count']})\n") if len(output + stat_line) >= MAX_TELEGRAM_MESSAGE_LENGTH: @@ -1149,44 +1244,56 @@ class Telegram(RPCHandler): forcebuy_text = ("*/forcebuy []:* `Instantly buys the given pair. " "Optionally takes a rate at which to buy " "(only applies to limit orders).` \n") - message = ("*/start:* `Starts the trader`\n" - "*/stop:* `Stops the trader`\n" - "*/status |[table]:* `Lists all open trades`\n" - " * :* `Lists one or more specific trades.`\n" - " `Separate multiple with a blank space.`\n" - " *table :* `will display trades in a table`\n" - " `pending buy orders are marked with an asterisk (*)`\n" - " `pending sell orders are marked with a double asterisk (**)`\n" - "*/buys :* `Shows the buy_tag performance`\n" - "*/sells :* `Shows the sell reason performance`\n" - "*/mix_tags :* `Shows combined buy tag + sell reason performance`\n" - "*/trades [limit]:* `Lists last closed trades (limited to 10 by default)`\n" - "*/profit []:* `Lists cumulative profit from all finished trades, " - "over the last n days`\n" - "*/forcesell |all:* `Instantly sells the given trade or all trades, " - "regardless of profit`\n" - f"{forcebuy_text if self._config.get('forcebuy_enable', False) else ''}" - "*/delete :* `Instantly delete the given trade in the database`\n" - "*/performance:* `Show performance of each finished trade grouped by pair`\n" - "*/daily :* `Shows profit or loss per day, over the last n days`\n" - "*/stats:* `Shows Wins / losses by Sell reason as well as " - "Avg. holding durationsfor buys and sells.`\n" - "*/count:* `Show number of active trades compared to allowed number of trades`\n" - "*/locks:* `Show currently locked pairs`\n" - "*/unlock :* `Unlock this Pair (or this lock id if it's numeric)`\n" - "*/balance:* `Show account balance per currency`\n" - "*/stopbuy:* `Stops buying, but handles open trades gracefully` \n" - "*/reload_config:* `Reload configuration file` \n" - "*/show_config:* `Show running configuration` \n" - "*/logs [limit]:* `Show latest logs - defaults to 10` \n" - "*/whitelist:* `Show current whitelist` \n" - "*/blacklist [pair]:* `Show current blacklist, or adds one or more pairs " - "to the blacklist.` \n" - "*/edge:* `Shows validated pairs by Edge if it is enabled` \n" - "*/help:* `This help message`\n" - "*/version:* `Show version`") + message = ( + "_BotControl_\n" + "------------\n" + "*/start:* `Starts the trader`\n" + "*/stop:* Stops the trader\n" + "*/stopbuy:* `Stops buying, but handles open trades gracefully` \n" + "*/forcesell |all:* `Instantly sells the given trade or all trades, " + "regardless of profit`\n" + f"{forcebuy_text if self._config.get('forcebuy_enable', False) else ''}" + "*/delete :* `Instantly delete the given trade in the database`\n" + "*/whitelist:* `Show current whitelist` \n" + "*/blacklist [pair]:* `Show current blacklist, or adds one or more pairs " + "to the blacklist.` \n" + "*/reload_config:* `Reload configuration file` \n" + "*/unlock :* `Unlock this Pair (or this lock id if it's numeric)`\n" - self._send_msg(message) + "_Current state_\n" + "------------\n" + "*/show_config:* `Show running configuration` \n" + "*/locks:* `Show currently locked pairs`\n" + "*/balance:* `Show account balance per currency`\n" + "*/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" + + "_Statistics_\n" + "------------\n" + "*/status |[table]:* `Lists all open trades`\n" + " * :* `Lists one or more specific trades.`\n" + " `Separate multiple with a blank space.`\n" + " *table :* `will display trades in a table`\n" + " `pending buy orders are marked with an asterisk (*)`\n" + " `pending sell orders are marked with a double asterisk (**)`\n" + "*/buys :* `Shows the buy_tag performance`\n" + "*/sells :* `Shows the sell reason performance`\n" + "*/mix_tags :* `Shows combined buy tag + sell reason performance`\n" + "*/trades [limit]:* `Lists last closed trades (limited to 10 by default)`\n" + "*/profit []:* `Lists cumulative profit from all finished trades, " + "over the last n days`\n" + "*/performance:* `Show performance of each finished trade grouped by pair`\n" + "*/daily :* `Shows profit or loss per day, over the last n days`\n" + "*/weekly :* `Shows statistics per week, over the last n weeks`\n" + "*/monthly :* `Shows statistics per month, over the last n months`\n" + "*/stats:* `Shows Wins / losses by Sell reason as well as " + "Avg. holding durationsfor buys and sells.`\n" + "*/help:* `This help message`\n" + "*/version:* `Show version`" + ) + + self._send_msg(message, parse_mode=ParseMode.MARKDOWN) @authorized_only def _version(self, update: Update, context: CallbackContext) -> None: diff --git a/freqtrade/strategy/hyper.py b/freqtrade/strategy/hyper.py index eaf41263a..278954bb2 100644 --- a/freqtrade/strategy/hyper.py +++ b/freqtrade/strategy/hyper.py @@ -292,7 +292,7 @@ class BooleanParameter(CategoricalParameter): load=load, **kwargs) -class HyperStrategyMixin(object): +class HyperStrategyMixin: """ A helper base class which allows HyperOptAuto class to reuse implementations of buy/sell strategy logic. diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index 214632b34..36bf09f5f 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -30,7 +30,7 @@ logger = logging.getLogger(__name__) CUSTOM_SELL_MAX_LENGTH = 64 -class SellCheckTuple(object): +class SellCheckTuple: """ NamedTuple for Sell type + reason """ @@ -868,7 +868,7 @@ class IStrategy(ABC, HyperStrategyMixin): if self.trailing_stop_positive is not None and bound_profit > sl_offset: stop_loss_value = self.trailing_stop_positive logger.debug(f"{trade.pair} - Using positive stoploss: {stop_loss_value} " - f"offset: {sl_offset:.4g} profit: {current_profit:.4f}%") + f"offset: {sl_offset:.4g} profit: {current_profit:.2%}") trade.adjust_stop_loss(bound or current_rate, stop_loss_value) diff --git a/freqtrade/strategy/strategy_wrapper.py b/freqtrade/strategy/strategy_wrapper.py index 121189b68..9aead8395 100644 --- a/freqtrade/strategy/strategy_wrapper.py +++ b/freqtrade/strategy/strategy_wrapper.py @@ -1,4 +1,5 @@ import logging +from copy import deepcopy from freqtrade.exceptions import StrategyError @@ -14,6 +15,9 @@ def strategy_safe_wrapper(f, message: str = "", default_retval=None, supress_err """ def wrapper(*args, **kwargs): try: + if 'trade' in kwargs: + # Protect accidental modifications from within the strategy + kwargs['trade'] = deepcopy(kwargs['trade']) return f(*args, **kwargs) except ValueError as error: logger.warning( diff --git a/freqtrade/wallets.py b/freqtrade/wallets.py index 237c1dc2c..d10847099 100644 --- a/freqtrade/wallets.py +++ b/freqtrade/wallets.py @@ -73,7 +73,7 @@ class Wallets: tot_profit = Trade.get_total_closed_profit() else: tot_profit = LocalTrade.total_profit - tot_in_trades = sum([trade.stake_amount for trade in open_trades]) + tot_in_trades = sum(trade.stake_amount for trade in open_trades) current_stake = self.start_cap + tot_profit - tot_in_trades _wallets[self._config['stake_currency']] = Wallet( @@ -238,7 +238,7 @@ class Wallets: return self._check_available_stake_amount(stake_amount, available_amount) - def _validate_stake_amount(self, pair, stake_amount, min_stake_amount): + def validate_stake_amount(self, pair, stake_amount, min_stake_amount): if not stake_amount: logger.debug(f"Stake amount is {stake_amount}, ignoring possible trade for {pair}.") return 0 @@ -250,17 +250,27 @@ class Wallets: logger.warning("Minimum stake amount > available balance.") return 0 if min_stake_amount is not None and stake_amount < min_stake_amount: - stake_amount = min_stake_amount if self._log: logger.info( f"Stake amount for pair {pair} is too small " f"({stake_amount} < {min_stake_amount}), adjusting to {min_stake_amount}." ) + if stake_amount * 1.3 < min_stake_amount: + # Top-cap stake-amount adjustments to +30%. + if self._log: + logger.info( + f"Adjusted stake amount for pair {pair} is more than 30% bigger than " + f"the desired stake ({stake_amount} * 1.3 > {max_stake_amount}), " + f"ignoring trade." + ) + return 0 + stake_amount = min_stake_amount + if stake_amount > max_stake_amount: - stake_amount = max_stake_amount if self._log: logger.info( f"Stake amount for pair {pair} is too big " f"({stake_amount} > {max_stake_amount}), adjusting to {max_stake_amount}." ) + stake_amount = max_stake_amount return stake_amount diff --git a/requirements-dev.txt b/requirements-dev.txt index c2b62196c..ab06468b9 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -3,7 +3,7 @@ -r requirements-plot.txt -r requirements-hyperopt.txt -coveralls==3.2.0 +coveralls==3.3.1 flake8==4.0.1 flake8-tidy-imports==4.5.0 mypy==0.910 @@ -12,15 +12,18 @@ pytest-asyncio==0.16.0 pytest-cov==3.0.0 pytest-mock==3.6.1 pytest-random-order==1.0.4 -isort==5.9.3 +isort==5.10.1 # For datetime mocking time-machine==2.4.0 # Convert jupyter notebooks to markdown documents -nbconvert==6.2.0 +nbconvert==6.3.0 # mypy types types-cachetools==4.2.4 types-filelock==3.2.1 -types-requests==2.25.11 +types-requests==2.26.0 types-tabulate==0.8.3 + +# Extensions to datetime library +types-python-dateutil==2.8.2 \ No newline at end of file diff --git a/requirements-hyperopt.txt b/requirements-hyperopt.txt index 29616d4b2..7efbb47cd 100644 --- a/requirements-hyperopt.txt +++ b/requirements-hyperopt.txt @@ -2,7 +2,7 @@ -r requirements.txt # Required for hyperopt -scipy==1.7.1 +scipy==1.7.2 scikit-learn==1.0.1 scikit-optimize==0.9.0 filelock==3.3.2 diff --git a/requirements.txt b/requirements.txt index c6044cf7d..bf0cbc88f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ -numpy==1.21.3 +numpy==1.21.4 pandas==1.3.4 pandas-ta==0.3.14b @@ -6,18 +6,18 @@ ccxt==1.61.24 # Pin cryptography for now due to rust build errors with piwheels cryptography==35.0.0 aiohttp==3.7.4.post0 -SQLAlchemy==1.4.26 -python-telegram-bot==13.7 +SQLAlchemy==1.4.27 +python-telegram-bot==13.8.1 arrow==1.2.1 cachetools==4.2.2 requests==2.26.0 urllib3==1.26.7 -jsonschema==4.1.2 +jsonschema==4.2.1 TA-Lib==0.4.21 technical==1.3.0 tabulate==0.8.9 pycoingecko==2.2.0 -jinja2==3.0.2 +jinja2==3.0.3 tables==3.6.1 blosc==1.10.6 @@ -41,7 +41,9 @@ psutil==5.8.0 colorama==0.4.4 # Building config files interactively questionary==1.10.0 -prompt-toolkit==3.0.21 +prompt-toolkit==3.0.22 +# Extensions to datetime library +python-dateutil==2.8.2 #Futures schedule==1.1.0 diff --git a/scripts/rest_client.py b/scripts/rest_client.py index ccb34d81f..b1234d329 100755 --- a/scripts/rest_client.py +++ b/scripts/rest_client.py @@ -39,7 +39,7 @@ class FtRestClient(): def _call(self, method, apipath, params: dict = None, data=None, files=None): if str(method).upper() not in ('GET', 'POST', 'PUT', 'DELETE'): - raise ValueError('invalid method <{0}>'.format(method)) + raise ValueError(f'invalid method <{method}>') basepath = f"{self._serverurl}/api/v1/{apipath}" hd = {"Accept": "application/json", @@ -124,7 +124,7 @@ class FtRestClient(): :param lock_id: ID for the lock to delete :return: json object """ - return self._delete("locks/{}".format(lock_id)) + return self._delete(f"locks/{lock_id}") def daily(self, days=None): """Return the profits for each day, and amount of trades. @@ -220,7 +220,7 @@ class FtRestClient(): :param trade_id: Specify which trade to get. :return: json object """ - return self._get("trade/{}".format(trade_id)) + return self._get(f"trade/{trade_id}") def delete_trade(self, trade_id): """Delete trade from the database. @@ -229,7 +229,7 @@ class FtRestClient(): :param trade_id: Deletes the trade with this ID from the database. :return: json object """ - return self._delete("trades/{}".format(trade_id)) + return self._delete(f"trades/{trade_id}") def whitelist(self): """Show the current whitelist. diff --git a/tests/conftest.py b/tests/conftest.py index 2a6ff9f22..e184903d1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,7 +17,7 @@ from telegram import Chat, Message, Update from freqtrade import constants from freqtrade.commands import Arguments from freqtrade.data.converter import ohlcv_to_dataframe -from freqtrade.edge import Edge, PairInfo +from freqtrade.edge import PairInfo from freqtrade.enums import Collateral, RunMode, TradingMode from freqtrade.enums.signaltype import SignalDirection from freqtrade.exchange import Exchange @@ -163,11 +163,6 @@ def patch_edge(mocker) -> None: mocker.patch('freqtrade.edge.Edge.calculate', MagicMock(return_value=True)) -def get_patched_edge(mocker, config) -> Edge: - patch_edge(mocker) - edge = Edge(config) - return edge - # Functions for recurrent object patching @@ -2370,6 +2365,46 @@ def market_buy_order_usdt(): } +@pytest.fixture +def market_buy_order_usdt_doublefee(market_buy_order_usdt): + order = deepcopy(market_buy_order_usdt) + order['fee'] = None + # Market orders filled with 2 trades can have fees in different currencies + # assuming the account runs out of BNB. + order['fees'] = [ + {'cost': 0.00025125, 'currency': 'BNB'}, + {'cost': 0.05030681, 'currency': 'USDT'}, + ] + order['trades'] = [{ + 'timestamp': None, + 'datetime': None, + 'symbol': 'ETH/USDT', + 'id': None, + 'order': '123', + 'type': 'market', + 'side': 'sell', + 'takerOrMaker': None, + 'price': 2.01, + 'amount': 25.0, + 'cost': 50.25, + 'fee': {'cost': 0.00025125, 'currency': 'BNB'} + }, { + 'timestamp': None, + 'datetime': None, + 'symbol': 'ETH/USDT', + 'id': None, + 'order': '123', + 'type': 'market', + 'side': 'sell', + 'takerOrMaker': None, + 'price': 2.0, + 'amount': 5, + 'cost': 10, + 'fee': {'cost': 0.0100306, 'currency': 'USDT'} + }] + return order + + @pytest.fixture def market_sell_order_usdt(): return { diff --git a/tests/exchange/test_binance.py b/tests/exchange/test_binance.py index 0c3e86fdd..c4277daad 100644 --- a/tests/exchange/test_binance.py +++ b/tests/exchange/test_binance.py @@ -360,13 +360,16 @@ async def test__async_get_historic_ohlcv_binance(default_conf, mocker, caplog): exchange._api_async.fetch_ohlcv = get_mock_coro(ohlcv) pair = 'ETH/BTC' - res = await exchange._async_get_historic_ohlcv(pair, "5m", - 1500000000000, is_new_pair=False) + respair, restf, res = await exchange._async_get_historic_ohlcv( + pair, "5m", 1500000000000, is_new_pair=False) + assert respair == pair + assert restf == '5m' # Call with very old timestamp - causes tons of requests assert exchange._api_async.fetch_ohlcv.call_count > 400 # assert res == ohlcv exchange._api_async.fetch_ohlcv.reset_mock() - res = await exchange._async_get_historic_ohlcv(pair, "5m", 1500000000000, is_new_pair=True) + _, _, res = await exchange._async_get_historic_ohlcv( + pair, "5m", 1500000000000, is_new_pair=True) # Called twice - one "init" call - and one to get the actual data. assert exchange._api_async.fetch_ohlcv.call_count == 2 @@ -375,7 +378,7 @@ async def test__async_get_historic_ohlcv_binance(default_conf, mocker, caplog): @pytest.mark.parametrize("trading_mode,collateral,config", [ - ("", "", {}), + ("spot", "", {}), ("margin", "cross", {"options": {"defaultType": "margin"}}), ("futures", "isolated", {"options": {"defaultType": "future"}}), ]) diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index e974cbd43..9da05f8e0 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -240,9 +240,9 @@ def test_validate_order_time_in_force(default_conf, mocker, caplog): (2.9999, 4, 0.005, 2.995), ]) def test_amount_to_precision(default_conf, mocker, amount, precision_mode, precision, expected): - ''' + """ Test rounds down - ''' + """ markets = PropertyMock(return_value={'ETH/BTC': {'precision': {'amount': precision}}}) @@ -281,9 +281,7 @@ def test_amount_to_precision(default_conf, mocker, amount, precision_mode, preci ]) def test_price_to_precision(default_conf, mocker, price, precision_mode, precision, expected): - ''' - Test price to precision - ''' + """Test price to precision""" markets = PropertyMock(return_value={'ETH/BTC': {'precision': {'price': precision}}}) exchange = get_patched_exchange(mocker, default_conf, id="binance") @@ -967,9 +965,22 @@ def test_validate_required_startup_candles(default_conf, mocker, caplog): default_conf['startup_candle_count'] = 20 ex = Exchange(default_conf) assert ex - default_conf['startup_candle_count'] = 600 + # assumption is that the exchange provides 500 candles per call.s + assert ex.validate_required_startup_candles(200, '5m') == 1 + assert ex.validate_required_startup_candles(499, '5m') == 1 + assert ex.validate_required_startup_candles(600, '5m') == 2 + assert ex.validate_required_startup_candles(501, '5m') == 2 + assert ex.validate_required_startup_candles(499, '5m') == 1 + assert ex.validate_required_startup_candles(1000, '5m') == 3 + assert ex.validate_required_startup_candles(2499, '5m') == 5 + assert log_has_re(r'Using 5 calls to get OHLCV. This.*', caplog) - with pytest.raises(OperationalException, match=r'This strategy requires 600.*'): + with pytest.raises(OperationalException, match=r'This strategy requires 2500.*'): + ex.validate_required_startup_candles(2500, '5m') + + # Ensure the same also happens on init + default_conf['startup_candle_count'] = 6000 + with pytest.raises(OperationalException, match=r'This strategy requires 6000.*'): Exchange(default_conf) @@ -1570,6 +1581,7 @@ def test_get_historic_ohlcv(default_conf, mocker, caplog, exchange_name): assert exchange._async_get_candle_history.call_count == 2 # Returns twice the above OHLCV data assert len(ret) == 2 + assert log_has_re(r'Downloaded data for .* with length .*\.', caplog) caplog.clear() @@ -1651,12 +1663,13 @@ async def test__async_get_historic_ohlcv(default_conf, mocker, caplog, exchange_ exchange._api_async.fetch_ohlcv = get_mock_coro(ohlcv) pair = 'ETH/USDT' - res = await exchange._async_get_historic_ohlcv(pair, "5m", - 1500000000000, is_new_pair=False) + respair, restf, res = await exchange._async_get_historic_ohlcv( + pair, "5m", 1500000000000, is_new_pair=False) + assert respair == pair + assert restf == '5m' # Call with very old timestamp - causes tons of requests assert exchange._api_async.fetch_ohlcv.call_count > 200 assert res[0] == ohlcv[0] - assert log_has_re(r'Downloaded data for .* with length .*\.', caplog) def test_refresh_latest_ohlcv(mocker, default_conf, caplog) -> None: @@ -1694,12 +1707,14 @@ def test_refresh_latest_ohlcv(mocker, default_conf, caplog) -> None: assert exchange._api_async.fetch_ohlcv.call_count == 2 exchange._api_async.fetch_ohlcv.reset_mock() + exchange.required_candle_call_count = 2 res = exchange.refresh_latest_ohlcv(pairs) assert len(res) == len(pairs) assert log_has(f'Refreshing candle (OHLCV) data for {len(pairs)} pairs', caplog) assert exchange._klines - assert exchange._api_async.fetch_ohlcv.call_count == 2 + assert exchange._api_async.fetch_ohlcv.call_count == 4 + exchange._api_async.fetch_ohlcv.reset_mock() for pair in pairs: assert isinstance(exchange.klines(pair), DataFrame) assert len(exchange.klines(pair)) > 0 @@ -1715,7 +1730,7 @@ def test_refresh_latest_ohlcv(mocker, default_conf, caplog) -> None: res = exchange.refresh_latest_ohlcv([('IOTA/ETH', '5m'), ('XRP/ETH', '5m')]) assert len(res) == len(pairs) - assert exchange._api_async.fetch_ohlcv.call_count == 2 + assert exchange._api_async.fetch_ohlcv.call_count == 0 assert log_has(f"Using cached candle (OHLCV) data for pair {pairs[0][0]}, " f"timeframe {pairs[0][1]} ...", caplog) @@ -2066,15 +2081,6 @@ def test_get_sell_rate_exception(default_conf, mocker, caplog): assert exchange.get_rate(pair, refresh=True, side="sell") == 0.13 -def make_fetch_ohlcv_mock(data): - def fetch_ohlcv_mock(pair, timeframe, since): - if since: - assert since > data[-1][0] - return [] - return data - return fetch_ohlcv_mock - - @pytest.mark.parametrize("exchange_name", EXCHANGES) @pytest.mark.asyncio async def test___async_get_candle_history_sort(default_conf, mocker, exchange_name): diff --git a/tests/optimize/test_hyperopt.py b/tests/optimize/test_hyperopt.py index 1dbea2af1..57c648b05 100644 --- a/tests/optimize/test_hyperopt.py +++ b/tests/optimize/test_hyperopt.py @@ -427,6 +427,7 @@ def test_generate_optimizer(mocker, hyperopt_conf) -> None: return_value=(Arrow(2017, 12, 10), Arrow(2017, 12, 13))) patch_exchange(mocker) mocker.patch.object(Path, 'open') + mocker.patch('freqtrade.configuration.config_validation.validate_config_schema') mocker.patch('freqtrade.optimize.hyperopt.load', return_value={'XRP/BTC': None}) optimizer_param = { diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index be3360cde..9821c9468 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -1018,7 +1018,7 @@ def test_mix_tag_performance_handle(default_conf, ticker, limit_buy_order, fee, assert len(res) == 1 assert res[0]['mix_tag'] == 'Other Other' assert res[0]['count'] == 1 - assert prec_satoshi(res[0]['profit'], 6.2) + assert prec_satoshi(res[0]['profit_pct'], 6.2) trade.buy_tag = "TESTBUY" trade.sell_reason = "TESTSELL" @@ -1027,7 +1027,7 @@ def test_mix_tag_performance_handle(default_conf, ticker, limit_buy_order, fee, assert len(res) == 1 assert res[0]['mix_tag'] == 'TESTBUY TESTSELL' assert res[0]['count'] == 1 - assert prec_satoshi(res[0]['profit'], 6.2) + assert prec_satoshi(res[0]['profit_pct'], 6.2) def test_mix_tag_performance_handle_2(mocker, default_conf, markets, fee): @@ -1046,10 +1046,10 @@ def test_mix_tag_performance_handle_2(mocker, default_conf, markets, fee): assert len(res) == 2 assert res[0]['mix_tag'] == 'TEST1 sell_signal' assert res[0]['count'] == 1 - assert prec_satoshi(res[0]['profit'], 0.5) + assert prec_satoshi(res[0]['profit_pct'], 0.5) assert res[1]['mix_tag'] == 'Other roi' assert res[1]['count'] == 1 - assert prec_satoshi(res[1]['profit'], 1.0) + assert prec_satoshi(res[1]['profit_pct'], 1.0) # Test for a specific pair res = rpc._rpc_mix_tag_performance('ETC/BTC') @@ -1057,7 +1057,7 @@ def test_mix_tag_performance_handle_2(mocker, default_conf, markets, fee): assert len(res) == 1 assert res[0]['count'] == 1 assert res[0]['mix_tag'] == 'TEST1 sell_signal' - assert prec_satoshi(res[0]['profit'], 0.5) + assert prec_satoshi(res[0]['profit_pct'], 0.5) def test_rpc_count(mocker, default_conf, ticker, fee) -> None: diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 4fe2f8daa..6f1003a9d 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -521,7 +521,7 @@ def test_api_locks(botclient): assert rc.json()['lock_count'] == 0 -def test_api_show_config(botclient, mocker): +def test_api_show_config(botclient): ftbot, client = botclient patch_get_signal(ftbot) @@ -537,6 +537,8 @@ def test_api_show_config(botclient, mocker): assert not rc.json()['trailing_stop'] assert 'bid_strategy' in rc.json() assert 'ask_strategy' in rc.json() + assert 'unfilledtimeout' in rc.json() + assert 'version' in rc.json() def test_api_daily(botclient, mocker, ticker, fee, markets): @@ -704,7 +706,8 @@ def test_api_edge_disabled(botclient, mocker, ticker, fee, markets): 'is_short,expected', [( True, - {'best_pair': 'ETC/BTC', 'best_rate': -0.5, 'profit_all_coin': 43.61269123, + {'best_pair': 'ETC/BTC', 'best_rate': -0.5, 'best_pair_profit_ratio': -0.005, + 'profit_all_coin': 43.61269123, 'profit_all_fiat': 538398.67323435, 'profit_all_percent_mean': 66.41, 'profit_all_ratio_mean': 0.664109545, 'profit_all_percent_sum': 398.47, 'profit_all_ratio_sum': 3.98465727, 'profit_all_percent': 4.36, @@ -716,7 +719,8 @@ def test_api_edge_disabled(botclient, mocker, ticker, fee, markets): ), ( False, - {'best_pair': 'XRP/BTC', 'best_rate': 1.0, 'profit_all_coin': -44.0631579, + {'best_pair': 'XRP/BTC', 'best_rate': 1.0, 'best_pair_profit_ratio': 0.01, + 'profit_all_coin': -44.0631579, 'profit_all_fiat': -543959.6842755, 'profit_all_percent_mean': -66.41, 'profit_all_ratio_mean': -0.6641100666666667, 'profit_all_percent_sum': -398.47, 'profit_all_ratio_sum': -3.9846604, 'profit_all_percent': -4.41, @@ -728,7 +732,8 @@ def test_api_edge_disabled(botclient, mocker, ticker, fee, markets): ), ( None, - {'best_pair': 'XRP/BTC', 'best_rate': 1.0, 'profit_all_coin': -14.43790415, + {'best_pair': 'XRP/BTC', 'best_rate': 1.0, 'best_pair_profit_ratio': 0.01, + 'profit_all_coin': -14.43790415, 'profit_all_fiat': -178235.92673175, 'profit_all_percent_mean': 0.08, 'profit_all_ratio_mean': 0.000835751666666662, 'profit_all_percent_sum': 0.5, 'profit_all_ratio_sum': 0.005014509999999972, 'profit_all_percent': -1.44, @@ -763,6 +768,7 @@ def test_api_profit(botclient, mocker, ticker, fee, markets, is_short, expected) assert rc.json() == { 'avg_duration': ANY, 'best_pair': expected['best_pair'], + 'best_pair_profit_ratio': expected['best_pair_profit_ratio'], 'best_rate': expected['best_rate'], 'first_trade_date': ANY, 'first_trade_timestamp': ANY, @@ -1185,7 +1191,7 @@ def test_api_pair_candles(botclient, ohlcv_history): assert isinstance(rc.json()['columns'], list) assert rc.json()['columns'] == ['date', 'open', 'high', 'low', 'close', 'volume', 'sma', 'buy', 'sell', - '__date_ts', '_buy_signal_open', '_sell_signal_open'] + '__date_ts', '_buy_signal_close', '_sell_signal_close'] assert 'pair' in rc.json() assert rc.json()['pair'] == 'XRP/BTC' @@ -1196,7 +1202,8 @@ def test_api_pair_candles(botclient, ohlcv_history): [['2017-11-26 08:50:00', 8.794e-05, 8.948e-05, 8.794e-05, 8.88e-05, 0.0877869, None, 0, 0, 1511686200000, None, None], ['2017-11-26 08:55:00', 8.88e-05, 8.942e-05, 8.88e-05, - 8.893e-05, 0.05874751, 8.886500000000001e-05, 1, 0, 1511686500000, 8.88e-05, None], + 8.893e-05, 0.05874751, 8.886500000000001e-05, 1, 0, 1511686500000, 8.893e-05, + 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, 1511686800000, None, None] diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index 07407d7f0..f64f05ddd 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -4,7 +4,7 @@ import logging import re -from datetime import datetime +from datetime import datetime, timedelta from functools import reduce from random import choice, randint from string import ascii_uppercase @@ -94,10 +94,11 @@ def test_telegram_init(default_conf, mocker, caplog) -> None: message_str = ("rpc.telegram is listening for following commands: [['status'], ['profit'], " "['balance'], ['start'], ['stop'], ['forcesell'], ['forcebuy'], ['trades'], " "['delete'], ['performance'], ['buys'], ['sells'], ['mix_tags'], " - "['stats'], ['daily'], ['count'], ['locks'], " - "['unlock', 'delete_locks'], ['reload_config', 'reload_conf'], " - "['show_config', 'show_conf'], ['stopbuy'], " - "['whitelist'], ['blacklist'], ['logs'], ['edge'], ['help'], ['version']" + "['stats'], ['daily'], ['weekly'], ['monthly'], " + "['count'], ['locks'], ['unlock', 'delete_locks'], " + "['reload_config', 'reload_conf'], ['show_config', 'show_conf'], " + "['stopbuy'], ['whitelist'], ['blacklist'], " + "['logs'], ['edge'], ['help'], ['version']" "]") assert log_has(message_str, caplog) @@ -188,16 +189,16 @@ def test_telegram_status(default_conf, update, mocker) -> None: 'amount': 90.99181074, 'stake_amount': 90.99181074, 'buy_tag': None, - 'close_profit_pct': None, + 'close_profit_ratio': None, 'profit': -0.0059, - 'profit_pct': -0.59, + 'profit_ratio': -0.0059, 'initial_stop_loss_abs': 1.098e-05, 'stop_loss_abs': 1.099e-05, 'sell_order_status': None, - 'initial_stop_loss_pct': -0.05, + 'initial_stop_loss_ratio': -0.0005, 'stoploss_current_dist': 1e-08, - 'stoploss_current_dist_pct': -0.02, - 'stop_loss_pct': -0.01, + 'stoploss_current_dist_ratio': -0.0002, + 'stop_loss_ratio': -0.0001, 'open_order': '(limit buy rem=0.00000000)', 'is_open': True }]), @@ -354,7 +355,8 @@ def test_daily_handle(default_conf, update, ticker, limit_buy_order, fee, context.args = ["2"] telegram._daily(update=update, context=context) assert msg_mock.call_count == 1 - assert 'Daily' in msg_mock.call_args_list[0][0][0] + assert "Daily Profit over the last 2 days:" in msg_mock.call_args_list[0][0][0] + assert 'Day ' in msg_mock.call_args_list[0][0][0] assert str(datetime.utcnow().date()) in msg_mock.call_args_list[0][0][0] assert str(' 0.00006217 BTC') in msg_mock.call_args_list[0][0][0] assert str(' 0.933 USD') in msg_mock.call_args_list[0][0][0] @@ -366,7 +368,7 @@ def test_daily_handle(default_conf, update, ticker, limit_buy_order, fee, context.args = [] telegram._daily(update=update, context=context) assert msg_mock.call_count == 1 - assert 'Daily' in msg_mock.call_args_list[0][0][0] + assert "Daily Profit over the last 7 days:" in msg_mock.call_args_list[0][0][0] assert str(datetime.utcnow().date()) in msg_mock.call_args_list[0][0][0] assert str(' 0.00006217 BTC') in msg_mock.call_args_list[0][0][0] assert str(' 0.933 USD') in msg_mock.call_args_list[0][0][0] @@ -422,7 +424,242 @@ def test_daily_wrong_input(default_conf, update, ticker, mocker) -> None: context = MagicMock() context.args = ["today"] telegram._daily(update=update, context=context) - assert str('Daily Profit over the last 7 days') in msg_mock.call_args_list[0][0][0] + assert str('Daily Profit over the last 7 days:') in msg_mock.call_args_list[0][0][0] + + +def test_weekly_handle(default_conf, update, ticker, limit_buy_order, fee, + limit_sell_order, mocker) -> None: + default_conf['max_open_trades'] = 1 + mocker.patch( + 'freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', + return_value=15000.0 + ) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + fetch_ticker=ticker, + get_fee=fee, + ) + + telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) + + patch_get_signal(freqtradebot) + + # Create some test data + freqtradebot.enter_positions() + trade = Trade.query.first() + assert trade + + # Simulate fulfilled LIMIT_BUY order for trade + trade.update(limit_buy_order) + + # Simulate fulfilled LIMIT_SELL order for trade + trade.update(limit_sell_order) + + trade.close_date = datetime.utcnow() + trade.is_open = False + + # Try valid data + # /weekly 2 + context = MagicMock() + context.args = ["2"] + telegram._weekly(update=update, context=context) + assert msg_mock.call_count == 1 + assert "Weekly Profit over the last 2 weeks (starting from Monday):" \ + in msg_mock.call_args_list[0][0][0] + assert 'Monday ' in msg_mock.call_args_list[0][0][0] + today = datetime.utcnow().date() + first_iso_day_of_current_week = today - timedelta(days=today.weekday()) + assert str(first_iso_day_of_current_week) in msg_mock.call_args_list[0][0][0] + assert str(' 0.00006217 BTC') in msg_mock.call_args_list[0][0][0] + assert str(' 0.933 USD') in msg_mock.call_args_list[0][0][0] + assert str(' 1 trade') in msg_mock.call_args_list[0][0][0] + assert str(' 0 trade') in msg_mock.call_args_list[0][0][0] + + # Reset msg_mock + msg_mock.reset_mock() + context.args = [] + telegram._weekly(update=update, context=context) + assert msg_mock.call_count == 1 + assert "Weekly Profit over the last 8 weeks (starting from Monday):" \ + in msg_mock.call_args_list[0][0][0] + assert 'Weekly' in msg_mock.call_args_list[0][0][0] + assert str(' 0.00006217 BTC') in msg_mock.call_args_list[0][0][0] + assert str(' 0.933 USD') in msg_mock.call_args_list[0][0][0] + assert str(' 1 trade') in msg_mock.call_args_list[0][0][0] + assert str(' 0 trade') in msg_mock.call_args_list[0][0][0] + + # Reset msg_mock + msg_mock.reset_mock() + freqtradebot.config['max_open_trades'] = 2 + # Add two other trades + n = freqtradebot.enter_positions() + assert n == 2 + + trades = Trade.query.all() + for trade in trades: + trade.update(limit_buy_order) + trade.update(limit_sell_order) + trade.close_date = datetime.utcnow() + trade.is_open = False + + # /weekly 1 + # By default, the 8 previous weeks are shown + # So the previous modified trade should be excluded from the stats + context = MagicMock() + context.args = ["1"] + telegram._weekly(update=update, context=context) + assert str(' 0.00018651 BTC') in msg_mock.call_args_list[0][0][0] + assert str(' 2.798 USD') in msg_mock.call_args_list[0][0][0] + assert str(' 3 trades') in msg_mock.call_args_list[0][0][0] + + +def test_weekly_wrong_input(default_conf, update, ticker, mocker) -> None: + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + fetch_ticker=ticker + ) + + telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) + patch_get_signal(freqtradebot) + + # Try invalid data + msg_mock.reset_mock() + freqtradebot.state = State.RUNNING + # /weekly -3 + context = MagicMock() + context.args = ["-3"] + telegram._weekly(update=update, context=context) + assert msg_mock.call_count == 1 + assert 'must be an integer greater than 0' in msg_mock.call_args_list[0][0][0] + + # Try invalid data + msg_mock.reset_mock() + freqtradebot.state = State.RUNNING + # /weekly this week + context = MagicMock() + context.args = ["this week"] + telegram._weekly(update=update, context=context) + assert str('Weekly Profit over the last 8 weeks (starting from Monday):') \ + in msg_mock.call_args_list[0][0][0] + + +def test_monthly_handle(default_conf, update, ticker, limit_buy_order, fee, + limit_sell_order, mocker) -> None: + default_conf['max_open_trades'] = 1 + mocker.patch( + 'freqtrade.rpc.rpc.CryptoToFiatConverter._find_price', + return_value=15000.0 + ) + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + fetch_ticker=ticker, + get_fee=fee, + ) + + telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) + + patch_get_signal(freqtradebot) + + # Create some test data + freqtradebot.enter_positions() + trade = Trade.query.first() + assert trade + + # Simulate fulfilled LIMIT_BUY order for trade + trade.update(limit_buy_order) + + # Simulate fulfilled LIMIT_SELL order for trade + trade.update(limit_sell_order) + + trade.close_date = datetime.utcnow() + trade.is_open = False + + # Try valid data + # /monthly 2 + context = MagicMock() + context.args = ["2"] + telegram._monthly(update=update, context=context) + assert msg_mock.call_count == 1 + assert 'Monthly Profit over the last 2 months:' in msg_mock.call_args_list[0][0][0] + assert 'Month ' in msg_mock.call_args_list[0][0][0] + today = datetime.utcnow().date() + current_month = f"{today.year}-{today.month} " + assert current_month in msg_mock.call_args_list[0][0][0] + assert str(' 0.00006217 BTC') in msg_mock.call_args_list[0][0][0] + assert str(' 0.933 USD') in msg_mock.call_args_list[0][0][0] + assert str(' 1 trade') in msg_mock.call_args_list[0][0][0] + assert str(' 0 trade') in msg_mock.call_args_list[0][0][0] + + # Reset msg_mock + msg_mock.reset_mock() + context.args = [] + telegram._monthly(update=update, context=context) + assert msg_mock.call_count == 1 + # Default to 6 months + assert 'Monthly Profit over the last 6 months:' in msg_mock.call_args_list[0][0][0] + assert 'Month ' in msg_mock.call_args_list[0][0][0] + assert current_month in msg_mock.call_args_list[0][0][0] + assert str(' 0.00006217 BTC') in msg_mock.call_args_list[0][0][0] + assert str(' 0.933 USD') in msg_mock.call_args_list[0][0][0] + assert str(' 1 trade') in msg_mock.call_args_list[0][0][0] + assert str(' 0 trade') in msg_mock.call_args_list[0][0][0] + + # Reset msg_mock + msg_mock.reset_mock() + freqtradebot.config['max_open_trades'] = 2 + # Add two other trades + n = freqtradebot.enter_positions() + assert n == 2 + + trades = Trade.query.all() + for trade in trades: + trade.update(limit_buy_order) + trade.update(limit_sell_order) + trade.close_date = datetime.utcnow() + trade.is_open = False + + # /monthly 12 + context = MagicMock() + context.args = ["12"] + telegram._monthly(update=update, context=context) + assert msg_mock.call_count == 1 + assert 'Monthly Profit over the last 12 months:' in msg_mock.call_args_list[0][0][0] + assert str(' 0.00018651 BTC') in msg_mock.call_args_list[0][0][0] + assert str(' 2.798 USD') in msg_mock.call_args_list[0][0][0] + assert str(' 3 trades') in msg_mock.call_args_list[0][0][0] + + # The one-digit months should contain a zero, Eg: September 2021 = "2021-09" + # Since we loaded the last 12 months, any month should appear + assert str('-09') in msg_mock.call_args_list[0][0][0] + + +def test_monthly_wrong_input(default_conf, update, ticker, mocker) -> None: + mocker.patch.multiple( + 'freqtrade.exchange.Exchange', + fetch_ticker=ticker + ) + + telegram, freqtradebot, msg_mock = get_telegram_testobject(mocker, default_conf) + patch_get_signal(freqtradebot) + + # Try invalid data + msg_mock.reset_mock() + freqtradebot.state = State.RUNNING + # /monthly -3 + context = MagicMock() + context.args = ["-3"] + telegram._monthly(update=update, context=context) + assert msg_mock.call_count == 1 + assert 'must be an integer greater than 0' in msg_mock.call_args_list[0][0][0] + + # Try invalid data + msg_mock.reset_mock() + freqtradebot.state = State.RUNNING + # /monthly february + context = MagicMock() + context.args = ["february"] + telegram._monthly(update=update, context=context) + assert str('Monthly Profit over the last 6 months:') in msg_mock.call_args_list[0][0][0] def test_profit_handle(default_conf, update, ticker, ticker_sell_up, fee, @@ -495,7 +732,7 @@ def test_telegram_stats(default_conf, update, ticker, ticker_sell_up, fee, telegram._stats(update=update, context=MagicMock()) assert msg_mock.call_count == 1 - # assert 'No trades yet.' in msg_mock.call_args_list[0][0][0] + assert 'No trades yet.' in msg_mock.call_args_list[0][0][0] msg_mock.reset_mock() # Create some test data @@ -1453,17 +1690,25 @@ def test_send_msg_buy_fill_notification(default_conf, mocker) -> None: telegram.send_msg({ 'type': RPCMessageType.BUY_FILL, - 'buy_tag': 'buy_signal_01', 'trade_id': 1, + 'buy_tag': 'buy_signal_01', 'exchange': 'Binance', - 'pair': 'ETH/USDT', - 'open_rate': 200, - 'stake_amount': 100, - 'amount': 0.5, - 'open_date': arrow.utcnow().datetime + 'pair': 'ETH/BTC', + 'stake_amount': 0.001, + # 'stake_amount_fiat': 0.0, + 'stake_currency': 'BTC', + 'fiat_currency': 'USD', + 'open_rate': 1.099e-05, + 'amount': 1333.3333333333335, + 'open_date': arrow.utcnow().shift(hours=-1) }) - assert (msg_mock.call_args[0][0] == '\N{LARGE CIRCLE} *Binance:* ' - 'Buy order for ETH/USDT (#1) filled for 200.') + + assert msg_mock.call_args[0][0] \ + == '\N{CHECK MARK} *Binance:* Bought ETH/BTC (#1)\n' \ + '*Buy Tag:* `buy_signal_01`\n' \ + '*Amount:* `1333.33333333`\n' \ + '*Open Rate:* `0.00001099`\n' \ + '*Total:* `(0.00100000 BTC, 12.345 USD)`' def test_send_msg_sell_notification(default_conf, mocker) -> None: @@ -1494,7 +1739,7 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None: }) assert msg_mock.call_args[0][0] \ == ('\N{WARNING SIGN} *Binance:* Selling KEY/ETH (#1)\n' - '*Profit:* `-57.41% (loss: -0.05746268 ETH / -24.812 USD)`\n' + '*Unrealized Profit:* `-57.41% (loss: -0.05746268 ETH / -24.812 USD)`\n' '*Buy Tag:* `buy_signal1`\n' '*Sell Reason:* `stop_loss`\n' '*Duration:* `1:00:00 (60.0 min)`\n' @@ -1526,7 +1771,7 @@ def test_send_msg_sell_notification(default_conf, mocker) -> None: }) assert msg_mock.call_args[0][0] \ == ('\N{WARNING SIGN} *Binance:* Selling KEY/ETH (#1)\n' - '*Profit:* `-57.41%`\n' + '*Unrealized Profit:* `-57.41%`\n' '*Buy Tag:* `buy_signal1`\n' '*Sell Reason:* `stop_loss`\n' '*Duration:* `1 day, 2:30:00 (1590.0 min)`\n' @@ -1580,25 +1825,30 @@ def test_send_msg_sell_fill_notification(default_conf, mocker) -> None: 'type': RPCMessageType.SELL_FILL, 'trade_id': 1, 'exchange': 'Binance', - 'pair': 'ETH/USDT', + 'pair': 'KEY/ETH', 'gain': 'loss', 'limit': 3.201e-05, - 'amount': 0.1, + 'amount': 1333.3333333333335, 'order_type': 'market', - 'open_rate': 500, - 'close_rate': 550, - 'current_rate': 3.201e-05, + 'open_rate': 7.5e-05, + 'close_rate': 3.201e-05, 'profit_amount': -0.05746268, 'profit_ratio': -0.57405275, 'stake_currency': 'ETH', - 'fiat_currency': 'USD', 'buy_tag': 'buy_signal1', 'sell_reason': SellType.STOP_LOSS.value, - 'open_date': arrow.utcnow().shift(hours=-1), + 'open_date': arrow.utcnow().shift(days=-1, hours=-2, minutes=-30), 'close_date': arrow.utcnow(), }) assert msg_mock.call_args[0][0] \ - == ('\N{LARGE CIRCLE} *Binance:* Sell order for ETH/USDT (#1) filled for 550.') + == ('\N{WARNING SIGN} *Binance:* Sold KEY/ETH (#1)\n' + '*Profit:* `-57.41%`\n' + '*Buy Tag:* `buy_signal1`\n' + '*Sell Reason:* `stop_loss`\n' + '*Duration:* `1 day, 2:30:00 (1590.0 min)`\n' + '*Amount:* `1333.33333333`\n' + '*Close Rate:* `0.00003201`' + ) def test_send_msg_status_notification(default_conf, mocker) -> None: @@ -1690,7 +1940,7 @@ def test_send_msg_sell_notification_no_fiat(default_conf, mocker) -> None: 'close_date': arrow.utcnow(), }) assert msg_mock.call_args[0][0] == ('\N{WARNING SIGN} *Binance:* Selling KEY/ETH (#1)\n' - '*Profit:* `-57.41%`\n' + '*Unrealized Profit:* `-57.41%`\n' '*Buy Tag:* `buy_signal1`\n' '*Sell Reason:* `stop_loss`\n' '*Duration:* `2:35:03 (155.1 min)`\n' diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index fbf700d94..08b2801f7 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -171,12 +171,8 @@ def test_edge_called_in_process(mocker, edge_conf) -> None: patch_RPCManager(mocker) patch_edge(mocker) - def _refresh_whitelist(list): - return ['ETH/USDT', 'LTC/BTC', 'XRP/BTC', 'NEO/BTC'] - patch_exchange(mocker) freqtrade = FreqtradeBot(edge_conf) - freqtrade.pairlists._validate_whitelist = _refresh_whitelist patch_get_signal(freqtrade) freqtrade.process() assert freqtrade.active_pair_whitelist == ['NEO/BTC', 'LTC/BTC'] @@ -328,7 +324,7 @@ def test_create_trade_no_stake_amount(default_conf_usdt, ticker_usdt, fee, mocke @pytest.mark.parametrize("is_short", [False, True]) @pytest.mark.parametrize('stake_amount,create,amount_enough,max_open_trades', [ (5.0, True, True, 99), - (0.00005, True, False, 99), + (0.049, True, False, 99), # Amount will be adjusted to min - which is 0.051 (0, False, True, 99), (UNLIMITED_STAKE_AMOUNT, False, True, 0), ]) @@ -678,9 +674,6 @@ def test_process_informative_pairs_added(default_conf_usdt, ticker_usdt, mocker) patch_RPCManager(mocker) patch_exchange(mocker) - def _refresh_whitelist(list): - return ['ETH/USDT', 'LTC/BTC', 'XRP/BTC', 'NEO/BTC'] - refresh_mock = MagicMock() mocker.patch.multiple( 'freqtrade.exchange.Exchange', @@ -697,7 +690,6 @@ def test_process_informative_pairs_added(default_conf_usdt, ticker_usdt, mocker) mocker.patch('time.sleep', return_value=None) freqtrade = FreqtradeBot(default_conf_usdt) - freqtrade.pairlists._validate_whitelist = _refresh_whitelist freqtrade.strategy.informative_pairs = inf_pairs # patch_get_signal(freqtrade) @@ -1733,7 +1725,6 @@ def test_exit_positions_exception( trade = MagicMock() trade.is_short = is_short trade.open_order_id = None - trade.open_fee = 0.001 trade.pair = 'ETH/USDT' trades = [trade] @@ -1853,8 +1844,6 @@ def test_update_trade_state_exception(mocker, default_conf_usdt, is_short, limit trade = MagicMock() trade.open_order_id = '123' - trade.open_fee = 0.001 - trade.is_short = is_short # Test raise of OperationalException exception mocker.patch( @@ -1872,7 +1861,6 @@ def test_update_trade_state_orderexception(mocker, default_conf_usdt, caplog) -> trade = MagicMock() trade.open_order_id = '123' - trade.open_fee = 0.001 # Test raise of OperationalException exception grm_mock = mocker.patch("freqtrade.freqtradebot.FreqtradeBot.get_real_amount", MagicMock()) @@ -2364,12 +2352,13 @@ def test_check_handle_timedout_buy_exception( @pytest.mark.parametrize("is_short", [False, True]) def test_check_handle_timedout_sell_usercustom( default_conf_usdt, ticker_usdt, limit_sell_order_old, mocker, - is_short, open_trade_usdt + is_short, open_trade_usdt, caplog ) -> None: - default_conf_usdt["unfilledtimeout"] = {"buy": 1440, "sell": 1440} + default_conf_usdt["unfilledtimeout"] = {"buy": 1440, "sell": 1440, "exit_timeout_count": 1} rpc_mock = patch_RPCManager(mocker) cancel_order_mock = MagicMock() patch_exchange(mocker) + et_mock = mocker.patch('freqtrade.freqtradebot.FreqtradeBot.execute_trade_exit') mocker.patch.multiple( 'freqtrade.exchange.Exchange', fetch_ticker=ticker_usdt, @@ -2412,6 +2401,14 @@ def test_check_handle_timedout_sell_usercustom( assert open_trade_usdt.is_open is True assert freqtrade.strategy.check_sell_timeout.call_count == 1 + # 2nd canceled trade ... + caplog.clear() + open_trade_usdt.open_order_id = 'order_id_2' + mocker.patch('freqtrade.persistence.Trade.get_exit_order_count', return_value=1) + freqtrade.check_handle_timedout() + assert log_has_re('Emergencyselling trade.*', caplog) + assert et_mock.call_count == 1 + @pytest.mark.parametrize("is_short", [False, True]) def test_check_handle_timedout_sell( @@ -2837,6 +2834,8 @@ def test_execute_trade_exit_up(default_conf_usdt, ticker_usdt, fee, ticker_usdt_ ) assert rpc_mock.call_count == 0 assert freqtrade.strategy.confirm_trade_exit.call_count == 1 + assert id(freqtrade.strategy.confirm_trade_exit.call_args_list[0][1]['trade']) != id(trade) + assert freqtrade.strategy.confirm_trade_exit.call_args_list[0][1]['trade'].id == trade.id # Repatch with true freqtrade.strategy.confirm_trade_exit = MagicMock(return_value=True) @@ -3700,7 +3699,7 @@ def test_trailing_stop_loss_positive( # stop-loss not reached, adjusted stoploss assert freqtrade.handle_trade(trade) is False caplog_text = (f"ETH/USDT - Using positive stoploss: 0.01 offset: {offset} profit: " - f"{'0.0249' if not is_short else '0.0224'}%") + f"{'2.49' if not is_short else '2.24'}%") if trail_if_reached: assert not log_has(caplog_text, caplog) assert not log_has("ETH/USDT - Adjusting stoploss...", caplog) @@ -3721,7 +3720,7 @@ def test_trailing_stop_loss_positive( assert freqtrade.handle_trade(trade) is False assert log_has( f"ETH/USDT - Using positive stoploss: 0.01 offset: {offset} profit: " - f"{'0.0572' if not is_short else '0.0567'}%", + f"{'5.72' if not is_short else '5.67'}%", caplog ) assert log_has("ETH/USDT - Adjusting stoploss...", caplog) @@ -3997,6 +3996,31 @@ def test_get_real_amount_invalid_order(default_conf_usdt, trades_for_order, buy_ assert freqtrade.get_real_amount(trade, limit_buy_order_usdt) == amount +def test_get_real_amount_fees_order(default_conf_usdt, market_buy_order_usdt_doublefee, + fee, mocker): + + tfo_mock = mocker.patch('freqtrade.exchange.Exchange.get_trades_for_order', return_value=[]) + mocker.patch('freqtrade.exchange.Exchange.get_valid_pair_combination', return_value='BNB/USDT') + mocker.patch('freqtrade.exchange.Exchange.fetch_ticker', return_value={'last': 200}) + trade = Trade( + pair='LTC/USDT', + amount=30.0, + exchange='binance', + fee_open=fee.return_value, + fee_close=fee.return_value, + open_rate=0.245441, + open_order_id="123456" + ) + freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) + + # Amount does not change + assert trade.fee_open == 0.0025 + assert freqtrade.get_real_amount(trade, market_buy_order_usdt_doublefee) == 30.0 + assert tfo_mock.call_count == 0 + # Fetch fees from trades dict if available to get "proper" values + assert round(trade.fee_open, 4) == 0.001 + + def test_get_real_amount_wrong_amount(default_conf_usdt, trades_for_order, buy_order_fee, fee, mocker): limit_buy_order_usdt = deepcopy(buy_order_fee) diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 2d4a7406f..2f5f61a15 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -14,8 +14,8 @@ 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 tests.conftest import (create_mock_trades, create_mock_trades_with_leverage, get_sides, - log_has, log_has_re) +from tests.conftest import (create_mock_trades, create_mock_trades_usdt, + create_mock_trades_with_leverage, get_sides, log_has, log_has_re) spot, margin, futures = TradingMode.SPOT, TradingMode.MARGIN, TradingMode.FUTURES @@ -1980,6 +1980,13 @@ def test_get_best_pair_lev(fee): assert res[1] == 0.1713156134055116 +def test_get_exit_order_count(fee): + + create_mock_trades_usdt(fee) + trade = Trade.get_trades([Trade.pair == 'ETC/USDT']).first() + assert trade.get_exit_order_count() == 1 + + @pytest.mark.usefixtures("init_persistence") def test_update_order_from_ccxt(caplog): # Most basic order return (only has orderid) diff --git a/tests/test_plotting.py b/tests/test_plotting.py index b2bd75db7..bcc779373 100644 --- a/tests/test_plotting.py +++ b/tests/test_plotting.py @@ -1,4 +1,3 @@ - from copy import deepcopy from pathlib import Path from unittest.mock import MagicMock @@ -172,7 +171,7 @@ def test_plot_trades(testdatadir, caplog): assert len(trades) == len(trade_buy.x) assert trade_buy.marker.color == 'cyan' assert trade_buy.marker.symbol == 'circle-open' - assert trade_buy.text[0] == '4.0%, roi, 15 min' + assert trade_buy.text[0] == '3.99%, roi, 15 min' trade_sell = find_trace_in_fig_data(figure.data, 'Sell - Profit') assert isinstance(trade_sell, go.Scatter) @@ -180,7 +179,7 @@ def test_plot_trades(testdatadir, caplog): assert len(trades.loc[trades['profit_ratio'] > 0]) == len(trade_sell.x) assert trade_sell.marker.color == 'green' assert trade_sell.marker.symbol == 'square-open' - assert trade_sell.text[0] == '4.0%, roi, 15 min' + assert trade_sell.text[0] == '3.99%, roi, 15 min' trade_sell_loss = find_trace_in_fig_data(figure.data, 'Sell - Loss') assert isinstance(trade_sell_loss, go.Scatter) @@ -188,7 +187,7 @@ def test_plot_trades(testdatadir, caplog): assert len(trades.loc[trades['profit_ratio'] <= 0]) == len(trade_sell_loss.x) assert trade_sell_loss.marker.color == 'red' assert trade_sell_loss.marker.symbol == 'square-open' - assert trade_sell_loss.text[5] == '-10.4%, stop_loss, 720 min' + assert trade_sell_loss.text[5] == '-10.45%, stop_loss, 720 min' def test_generate_candlestick_graph_no_signals_no_trades(default_conf, mocker, testdatadir, caplog): diff --git a/tests/test_wallets.py b/tests/test_wallets.py index 53e3b758e..3e02cdb09 100644 --- a/tests/test_wallets.py +++ b/tests/test_wallets.py @@ -185,17 +185,18 @@ def test_get_trade_stake_amount_unlimited_amount(default_conf, ticker, balance_r (100, 11, 500, 100), (1000, 11, 500, 500), # Above max-stake (20, 15, 10, 0), # Minimum stake > max-stake - (1, 11, 100, 11), # Below min stake + (9, 11, 100, 11), # Below min stake (1, 15, 10, 0), # Below min stake and min_stake > max_stake + (20, 50, 100, 0), # Below min stake and stake * 1.3 > min_stake ]) -def test__validate_stake_amount(mocker, default_conf, - stake_amount, min_stake_amount, max_stake_amount, expected): +def test_validate_stake_amount(mocker, default_conf, + stake_amount, min_stake_amount, max_stake_amount, expected): freqtrade = get_patched_freqtradebot(mocker, default_conf) mocker.patch("freqtrade.wallets.Wallets.get_available_stake_amount", return_value=max_stake_amount) - res = freqtrade.wallets._validate_stake_amount('XRP/USDT', stake_amount, min_stake_amount) + res = freqtrade.wallets.validate_stake_amount('XRP/USDT', stake_amount, min_stake_amount) assert res == expected