diff --git a/Dockerfile b/Dockerfile index f7e26efe3..8f5b85698 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9.7-slim-buster as base +FROM python:3.9.9-slim-bullseye as base # Setup env ENV LANG C.UTF-8 diff --git a/docker/Dockerfile.armhf b/docker/Dockerfile.armhf index f9827774e..16f2aebcd 100644 --- a/docker/Dockerfile.armhf +++ b/docker/Dockerfile.armhf @@ -1,4 +1,4 @@ -FROM python:3.7.10-slim-buster as base +FROM python:3.9.9-slim-bullseye as base # Setup env ENV LANG C.UTF-8 diff --git a/docs/backtesting.md b/docs/backtesting.md index 49a94b05e..a49e4700a 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -115,7 +115,7 @@ The result of backtesting will confirm if your bot has better odds of making a p All profit calculations include fees, and freqtrade will use the exchange's default fees for the calculation. !!! Warning "Using dynamic pairlists for backtesting" - Using dynamic pairlists is possible, however it relies on the current market conditions - which will not reflect the historic status of the pairlist. + Using dynamic pairlists is possible (not all of the handlers are allowed to be used in backtest mode), however it relies on the current market conditions - which will not reflect the historic status of the pairlist. Also, when using pairlists other than StaticPairlist, reproducibility of backtesting-results cannot be guaranteed. Please read the [pairlists documentation](plugins.md#pairlists) for more information. diff --git a/docs/configuration.md b/docs/configuration.md index 6c810fba2..00ab66ceb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -126,9 +126,10 @@ Mandatory parameters are marked as **Required**, which means that they are requi | `exchange.key` | API key to use for the exchange. Only required when you are in production mode.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `exchange.secret` | API secret to use for the exchange. Only required when you are in production mode.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `exchange.password` | API password to use for the exchange. Only required when you are in production mode and for exchanges that use password for API requests.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String +| `exchange.uid` | API uid to use for the exchange. Only required when you are in production mode and for exchanges that use uid for API requests.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String | `exchange.pair_whitelist` | List of pairs to use by the bot for trading and to check for potential trades during backtesting. Supports regex pairs as `.*/BTC`. Not used by VolumePairList. [More information](plugins.md#pairlists-and-pairlist-handlers).
**Datatype:** List | `exchange.pair_blacklist` | List of pairs the bot must absolutely avoid for trading and backtesting. [More information](plugins.md#pairlists-and-pairlist-handlers).
**Datatype:** List -| `exchange.ccxt_config` | Additional CCXT parameters passed to both ccxt instances (sync and async). This is usually the correct place for ccxt configurations. Parameters may differ from exchange to exchange and are documented in the [ccxt documentation](https://ccxt.readthedocs.io/en/latest/manual.html#instantiation)
**Datatype:** Dict +| `exchange.ccxt_config` | Additional CCXT parameters passed to both ccxt instances (sync and async). This is usually the correct place for additional ccxt configurations. Parameters may differ from exchange to exchange and are documented in the [ccxt documentation](https://ccxt.readthedocs.io/en/latest/manual.html#instantiation). Please avoid adding exchange secrets here (use the dedicated fields instead), as they may be contained in logs.
**Datatype:** Dict | `exchange.ccxt_sync_config` | Additional CCXT parameters passed to the regular (sync) ccxt instance. Parameters may differ from exchange to exchange and are documented in the [ccxt documentation](https://ccxt.readthedocs.io/en/latest/manual.html#instantiation)
**Datatype:** Dict | `exchange.ccxt_async_config` | Additional CCXT parameters passed to the async ccxt instance. Parameters may differ from exchange to exchange and are documented in the [ccxt documentation](https://ccxt.readthedocs.io/en/latest/manual.html#instantiation)
**Datatype:** Dict | `exchange.markets_refresh_interval` | The interval in minutes in which markets are reloaded.
*Defaults to `60` minutes.*
**Datatype:** Positive Integer @@ -202,9 +203,8 @@ There are several methods to configure how much of the stake currency the bot wi #### Minimum trade stake The minimum stake amount will depend on exchange and pair and is usually listed in the exchange support pages. -Assuming the minimum tradable amount for XRP/USD is 20 XRP (given by the exchange), and the price is 0.6$. -The minimum stake amount to buy this pair is, therefore, `20 * 0.6 ~= 12`. +Assuming the minimum tradable amount for XRP/USD is 20 XRP (given by the exchange), and the price is 0.6$, the minimum stake amount to buy this pair is `20 * 0.6 ~= 12`. This exchange has also a limit on USD - where all orders must be > 10$ - which however does not apply in this case. To guarantee safe execution, freqtrade will not allow buying with a stake-amount of 10.1$, instead, it'll make sure that there's enough space to place a stoploss below the pair (+ an offset, defined by `amount_reserve_percent`, which defaults to 5%). diff --git a/docs/includes/pairlists.md b/docs/includes/pairlists.md index 4f56f8e98..f6e8cb6d4 100644 --- a/docs/includes/pairlists.md +++ b/docs/includes/pairlists.md @@ -220,6 +220,9 @@ As this Filter uses past performance of the bot, it'll have some startup-period Filters low-value coins which would not allow setting stoplosses. +!!! Warning "Backtesting" + `PrecisionFilter` does not support backtesting mode using multiple strategies. + #### PriceFilter The `PriceFilter` allows filtering of pairs by price. Currently the following price filters are supported: @@ -257,7 +260,7 @@ Min price precision for SHITCOIN/BTC is 8 decimals. If its price is 0.00000011 - Shuffles (randomizes) pairs in the pairlist. It can be used for preventing the bot from trading some of the pairs more frequently then others when you want all pairs be treated with the same priority. !!! Tip - You may set the `seed` value for this Pairlist to obtain reproducible results, which can be useful for repeated backtesting sessions. If `seed` is not set, the pairs are shuffled in the non-repeatable random order. + You may set the `seed` value for this Pairlist to obtain reproducible results, which can be useful for repeated backtesting sessions. If `seed` is not set, the pairs are shuffled in the non-repeatable random order. ShuffleFilter will automatically detect runmodes and apply the `seed` only for backtesting modes - if a `seed` value is set. #### SpreadFilter @@ -292,7 +295,7 @@ If the trading range over the last 10 days is <1% or >99%, remove the pair from #### VolatilityFilter -Volatility is the degree of historical variation of a pairs over time, is is measured by the standard deviation of logarithmic daily returns. Returns are assumed to be normally distributed, although actual distribution might be different. In a normal distribution, 68% of observations fall within one standard deviation and 95% of observations fall within two standard deviations. Assuming a volatility of 0.05 means that the expected returns for 20 out of 30 days is expected to be less than 5% (one standard deviation). Volatility is a positive ratio of the expected deviation of return and can be greater than 1.00. Please refer to the wikipedia definition of [`volatility`](https://en.wikipedia.org/wiki/Volatility_(finance)). +Volatility is the degree of historical variation of a pairs over time, it is measured by the standard deviation of logarithmic daily returns. Returns are assumed to be normally distributed, although actual distribution might be different. In a normal distribution, 68% of observations fall within one standard deviation and 95% of observations fall within two standard deviations. Assuming a volatility of 0.05 means that the expected returns for 20 out of 30 days is expected to be less than 5% (one standard deviation). Volatility is a positive ratio of the expected deviation of return and can be greater than 1.00. Please refer to the wikipedia definition of [`volatility`](https://en.wikipedia.org/wiki/Volatility_(finance)). This filter removes pairs if the average volatility over a `lookback_days` days is below `min_volatility` or above `max_volatility`. Since this is a filter that requires additional data, the results are cached for `refresh_period`. diff --git a/docs/plotting.md b/docs/plotting.md index 9fae38504..b2d7654f6 100644 --- a/docs/plotting.md +++ b/docs/plotting.md @@ -164,16 +164,17 @@ The resulting plot will have the following elements: An advanced plot configuration can be specified in the strategy in the `plot_config` parameter. -Additional features when using plot_config include: +Additional features when using `plot_config` include: * Specify colors per indicator * Specify additional subplots -* Specify indicator pairs to fill area in between +* Specify indicator pairs to fill area in between The sample plot configuration below specifies fixed colors for the indicators. Otherwise, consecutive plots may produce different color schemes each time, making comparisons difficult. It also allows multiple subplots to display both MACD and RSI at the same time. Plot type can be configured using `type` key. Possible types are: + * `scatter` corresponding to `plotly.graph_objects.Scatter` class (default). * `bar` corresponding to `plotly.graph_objects.Bar` class. @@ -182,40 +183,89 @@ Extra parameters to `plotly.graph_objects.*` constructor can be specified in `pl Sample configuration with inline comments explaining the process: ``` python - plot_config = { - 'main_plot': { - # Configuration for main plot indicators. - # Specifies `ema10` to be red, and `ema50` to be a shade of gray - 'ema10': {'color': 'red'}, - 'ema50': {'color': '#CCCCCC'}, - # By omitting color, a random color is selected. - 'sar': {}, - # fill area between senkou_a and senkou_b - 'senkou_a': { - 'color': 'green', #optional - 'fill_to': 'senkou_b', - 'fill_label': 'Ichimoku Cloud', #optional - 'fill_color': 'rgba(255,76,46,0.2)', #optional - }, - # plot senkou_b, too. Not only the area to it. - 'senkou_b': {} +@property +def plot_config(self): + """ + There are a lot of solutions how to build the return dictionary. + The only important point is the return value. + Example: + plot_config = {'main_plot': {}, 'subplots': {}} + + """ + plot_config = {} + plot_config['main_plot'] = { + # Configuration for main plot indicators. + # Assumes 2 parameters, emashort and emalong to be specified. + f'ema_{self.emashort.value}': {'color': 'red'}, + f'ema_{self.emalong.value}': {'color': '#CCCCCC'}, + # By omitting color, a random color is selected. + 'sar': {}, + # fill area between senkou_a and senkou_b + 'senkou_a': { + 'color': 'green', #optional + 'fill_to': 'senkou_b', + 'fill_label': 'Ichimoku Cloud', #optional + 'fill_color': 'rgba(255,76,46,0.2)', #optional }, - 'subplots': { - # Create subplot MACD - "MACD": { - 'macd': {'color': 'blue', 'fill_to': 'macdhist'}, - 'macdsignal': {'color': 'orange'}, - 'macdhist': {'type': 'bar', 'plotly': {'opacity': 0.9}} - }, - # Additional subplot RSI - "RSI": { - 'rsi': {'color': 'red'} - } + # plot senkou_b, too. Not only the area to it. + 'senkou_b': {} + } + plot_config['subplots'] = { + # Create subplot MACD + "MACD": { + 'macd': {'color': 'blue', 'fill_to': 'macdhist'}, + 'macdsignal': {'color': 'orange'}, + 'macdhist': {'type': 'bar', 'plotly': {'opacity': 0.9}} + }, + # Additional subplot RSI + "RSI": { + 'rsi': {'color': 'red'} } } + return plot_config ``` +??? Note "As attribute (former method)" + Assigning plot_config is also possible as Attribute (this used to be the default way). + This has the disadvantage that strategy parameters are not available, preventing certain configurations from working. + + ``` python + plot_config = { + 'main_plot': { + # Configuration for main plot indicators. + # Specifies `ema10` to be red, and `ema50` to be a shade of gray + 'ema10': {'color': 'red'}, + 'ema50': {'color': '#CCCCCC'}, + # By omitting color, a random color is selected. + 'sar': {}, + # fill area between senkou_a and senkou_b + 'senkou_a': { + 'color': 'green', #optional + 'fill_to': 'senkou_b', + 'fill_label': 'Ichimoku Cloud', #optional + 'fill_color': 'rgba(255,76,46,0.2)', #optional + }, + # plot senkou_b, too. Not only the area to it. + 'senkou_b': {} + }, + 'subplots': { + # Create subplot MACD + "MACD": { + 'macd': {'color': 'blue', 'fill_to': 'macdhist'}, + 'macdsignal': {'color': 'orange'}, + 'macdhist': {'type': 'bar', 'plotly': {'opacity': 0.9}} + }, + # Additional subplot RSI + "RSI": { + 'rsi': {'color': 'red'} + } + } + } + + ``` + + !!! Note The above configuration assumes that `ema10`, `ema50`, `senkou_a`, `senkou_b`, `macd`, `macdsignal`, `macdhist` and `rsi` are columns in the DataFrame created by the strategy. diff --git a/docs/requirements-docs.txt b/docs/requirements-docs.txt index 772919436..351f45af6 100644 --- a/docs/requirements-docs.txt +++ b/docs/requirements-docs.txt @@ -1,4 +1,4 @@ mkdocs==1.2.3 -mkdocs-material==7.3.6 +mkdocs-material==8.0.1 mdx_truly_sane_lists==1.2 pymdown-extensions==9.1 diff --git a/docs/rest-api.md b/docs/rest-api.md index 7299e0282..8c2599cbc 100644 --- a/docs/rest-api.md +++ b/docs/rest-api.md @@ -38,6 +38,11 @@ Sample configuration: !!! Danger "Security warning" By default, the configuration listens on localhost only (so it's not reachable from other systems). We strongly recommend to not expose this API to the internet and choose a strong, unique password, since others will potentially be able to control your bot. +??? Note "API/UI Access on a remote servers" + If you're running on a VPS, you should consider using either a ssh tunnel, or setup a VPN (openVPN, wireguard) to connect to your bot. + This will ensure that freqUI is not directly exposed to the internet, which is not recommended for security reasons (freqUI does not support https out of the box). + Setup of these tools is not part of this tutorial, however many good tutorials can be found on the internet. + You can then access the API by going to `http://127.0.0.1:8080/api/v1/ping` in a browser to check if the API is running correctly. This should return the response: diff --git a/docs/strategy-advanced.md b/docs/strategy-advanced.md index 47d7ee6ae..573d184ff 100644 --- a/docs/strategy-advanced.md +++ b/docs/strategy-advanced.md @@ -77,43 +77,6 @@ class AwesomeStrategy(IStrategy): *** -## Custom sell signal - -It is possible to define custom sell signals, indicating that specified position should be sold. This is very useful when we need to customize sell conditions for each individual trade, or if you need the trade profit to take the sell decision. - -For example you could implement a 1:2 risk-reward ROI with `custom_sell()`. - -Using custom_sell() signals in place of stoploss though *is not recommended*. It is a inferior method to using `custom_stoploss()` in this regard - which also allows you to keep the stoploss on exchange. - -!!! Note - Returning a `string` or `True` from this method is equal to setting sell signal on a candle at specified time. This method is not called when sell signal is set already, or if sell signals are disabled (`use_sell_signal=False` or `sell_profit_only=True` while profit is below `sell_profit_offset`). `string` max length is 64 characters. Exceeding this limit will cause the message to be truncated to 64 characters. - -An example of how we can use different indicators depending on the current profit and also sell trades that were open longer than one day: - -``` python -class AwesomeStrategy(IStrategy): - def custom_sell(self, pair: str, trade: 'Trade', current_time: 'datetime', current_rate: float, - current_profit: float, **kwargs): - dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) - last_candle = dataframe.iloc[-1].squeeze() - - # Above 20% profit, sell when rsi < 80 - if current_profit > 0.2: - if last_candle['rsi'] < 80: - return 'rsi_below_80' - - # Between 2% and 10%, sell if EMA-long above EMA-short - if 0.02 < current_profit < 0.1: - if last_candle['emalong'] > last_candle['emashort']: - return 'ema_long_below_80' - - # Sell any positions at a loss if they are held for more than one day. - if current_profit < 0.0 and (current_time - trade.open_date_utc).days >= 1: - return 'unclog' -``` - -See [Dataframe access](#dataframe-access) for more information about dataframe use in strategy callbacks. - ## Buy Tag When your strategy has multiple buy signals, you can name the signal that triggered. @@ -164,506 +127,6 @@ The provided exit-tag is then used as sell-reason - and shown as such in backtes !!! 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 - -The stoploss price can only ever move upwards - if the stoploss value returned from `custom_stoploss` would result in a lower stoploss price than was previously set, it will be ignored. The traditional `stoploss` value serves as an absolute lower level and will be instated as the initial stoploss. - -The usage of the custom stoploss method must be enabled by setting `use_custom_stoploss=True` on the strategy object. -The method must return a stoploss value (float / number) as a percentage of the current price. -E.g. If the `current_rate` is 200 USD, then returning `0.02` will set the stoploss price 2% lower, at 196 USD. - -The absolute value of the return value is used (the sign is ignored), so returning `0.05` or `-0.05` have the same result, a stoploss 5% below the current price. - -To simulate a regular trailing stoploss of 4% (trailing 4% behind the maximum reached price) you would use the following very simple method: - -``` python -# additional imports required -from datetime import datetime -from freqtrade.persistence import Trade - -class AwesomeStrategy(IStrategy): - - # ... populate_* methods - - use_custom_stoploss = True - - def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, - current_rate: float, current_profit: float, **kwargs) -> float: - """ - Custom stoploss logic, returning the new distance relative to current_rate (as ratio). - e.g. returning -0.05 would create a stoploss 5% below current_rate. - The custom stoploss can never be below self.stoploss, which serves as a hard maximum loss. - - For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ - - When not implemented by a strategy, returns the initial stoploss value - Only called when use_custom_stoploss is set to True. - - :param pair: Pair that's currently analyzed - :param trade: trade object. - :param current_time: datetime object, containing the current datetime - :param current_rate: Rate, calculated based on pricing settings in ask_strategy. - :param current_profit: Current profit (as ratio), calculated based on current_rate. - :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. - :return float: New stoploss value, relative to the current rate - """ - return -0.04 -``` - -Stoploss on exchange works similar to `trailing_stop`, and the stoploss on exchange is updated as configured in `stoploss_on_exchange_interval` ([More details about stoploss on exchange](stoploss.md#stop-loss-on-exchange-freqtrade)). - -!!! Note "Use of dates" - All time-based calculations should be done based on `current_time` - using `datetime.now()` or `datetime.utcnow()` is discouraged, as this will break backtesting support. - -!!! Tip "Trailing stoploss" - It's recommended to disable `trailing_stop` when using custom stoploss values. Both can work in tandem, but you might encounter the trailing stop to move the price higher while your custom function would not want this, causing conflicting behavior. - -### Custom stoploss examples - -The next section will show some examples on what's possible with the custom stoploss function. -Of course, many more things are possible, and all examples can be combined at will. - -#### Time based trailing stop - -Use the initial stoploss for the first 60 minutes, after this change to 10% trailing stoploss, and after 2 hours (120 minutes) we use a 5% trailing stoploss. - -``` python -from datetime import datetime, timedelta -from freqtrade.persistence import Trade - -class AwesomeStrategy(IStrategy): - - # ... populate_* methods - - use_custom_stoploss = True - - def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, - current_rate: float, current_profit: float, **kwargs) -> float: - - # Make sure you have the longest interval first - these conditions are evaluated from top to bottom. - if current_time - timedelta(minutes=120) > trade.open_date_utc: - return -0.05 - elif current_time - timedelta(minutes=60) > trade.open_date_utc: - return -0.10 - return 1 -``` - -#### Different stoploss per pair - -Use a different stoploss depending on the pair. -In this example, we'll trail the highest price with 10% trailing stoploss for `ETH/BTC` and `XRP/BTC`, with 5% trailing stoploss for `LTC/BTC` and with 15% for all other pairs. - -``` python -from datetime import datetime -from freqtrade.persistence import Trade - -class AwesomeStrategy(IStrategy): - - # ... populate_* methods - - use_custom_stoploss = True - - def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, - current_rate: float, current_profit: float, **kwargs) -> float: - - if pair in ('ETH/BTC', 'XRP/BTC'): - return -0.10 - elif pair in ('LTC/BTC'): - return -0.05 - return -0.15 -``` - -#### Trailing stoploss with positive offset - -Use the initial stoploss until the profit is above 4%, then use a trailing stoploss of 50% of the current profit with a minimum of 2.5% and a maximum of 5%. - -Please note that the stoploss can only increase, values lower than the current stoploss are ignored. - -``` python -from datetime import datetime, timedelta -from freqtrade.persistence import Trade - -class AwesomeStrategy(IStrategy): - - # ... populate_* methods - - use_custom_stoploss = True - - def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, - current_rate: float, current_profit: float, **kwargs) -> float: - - if current_profit < 0.04: - return -1 # return a value bigger than the initial stoploss to keep using the initial stoploss - - # After reaching the desired offset, allow the stoploss to trail by half the profit - desired_stoploss = current_profit / 2 - - # Use a minimum of 2.5% and a maximum of 5% - return max(min(desired_stoploss, 0.05), 0.025) -``` - -#### Calculating stoploss relative to open price - -Stoploss values returned from `custom_stoploss()` always specify a percentage relative to `current_rate`. In order to set a stoploss relative to the *open* price, we need to use `current_profit` to calculate what percentage relative to the `current_rate` will give you the same result as if the percentage was specified from the open price. - -The helper function [`stoploss_from_open()`](strategy-customization.md#stoploss_from_open) can be used to convert from an open price relative stop, to a current price relative stop which can be returned from `custom_stoploss()`. - -### Calculating stoploss percentage from absolute price - -Stoploss values returned from `custom_stoploss()` always specify a percentage relative to `current_rate`. In order to set a stoploss at specified absolute price level, we need to use `stop_rate` to calculate what percentage relative to the `current_rate` will give you the same result as if the percentage was specified from the open price. - -The helper function [`stoploss_from_absolute()`](strategy-customization.md#stoploss_from_absolute) can be used to convert from an absolute price, to a current price relative stop which can be returned from `custom_stoploss()`. - -#### Stepped stoploss - -Instead of continuously trailing behind the current price, this example sets fixed stoploss price levels based on the current profit. - -* Use the regular stoploss until 20% profit is reached -* Once profit is > 20% - set stoploss to 7% above open price. -* Once profit is > 25% - set stoploss to 15% above open price. -* Once profit is > 40% - set stoploss to 25% above open price. - -``` python -from datetime import datetime -from freqtrade.persistence import Trade -from freqtrade.strategy import stoploss_from_open - -class AwesomeStrategy(IStrategy): - - # ... populate_* methods - - use_custom_stoploss = True - - def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, - current_rate: float, current_profit: float, **kwargs) -> float: - - # evaluate highest to lowest, so that highest possible stop is used - if current_profit > 0.40: - return stoploss_from_open(0.25, current_profit) - elif current_profit > 0.25: - return stoploss_from_open(0.15, current_profit) - elif current_profit > 0.20: - return stoploss_from_open(0.07, current_profit) - - # return maximum stoploss value, keeping current stoploss price unchanged - return 1 -``` - -#### Custom stoploss using an indicator from dataframe example - -Absolute stoploss value may be derived from indicators stored in dataframe. Example uses parabolic SAR below the price as stoploss. - -``` python -class AwesomeStrategy(IStrategy): - - def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: - # <...> - dataframe['sar'] = ta.SAR(dataframe) - - use_custom_stoploss = True - - def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, - current_rate: float, current_profit: float, **kwargs) -> float: - - dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) - last_candle = dataframe.iloc[-1].squeeze() - - # Use parabolic sar as absolute stoploss price - stoploss_price = last_candle['sar'] - - # Convert absolute price to percentage relative to current_rate - if stoploss_price < current_rate: - return (stoploss_price / current_rate) - 1 - - # return maximum stoploss value, keeping current stoploss price unchanged - return 1 -``` - -See [Dataframe access](#dataframe-access) for more information about dataframe use in strategy callbacks. - ---- - -## Custom order price rules - -By default, freqtrade use the orderbook to automatically set an order price([Relevant documentation](configuration.md#prices-used-for-orders)), you also have the option to create custom order prices based on your strategy. - -You can use this feature by creating a `custom_entry_price()` function in your strategy file to customize entry prices and `custom_exit_price()` for exits. - -!!! Note - If your custom pricing function return None or an invalid value, price will fall back to `proposed_rate`, which is based on the regular pricing configuration. - -### Custom order entry and exit price example - -``` python -from datetime import datetime, timedelta, timezone -from freqtrade.persistence import Trade - -class AwesomeStrategy(IStrategy): - - # ... populate_* methods - - def custom_entry_price(self, pair: str, current_time: datetime, - proposed_rate, **kwargs) -> float: - - dataframe, last_updated = self.dp.get_analyzed_dataframe(pair=pair, - timeframe=self.timeframe) - new_entryprice = dataframe['bollinger_10_lowerband'].iat[-1] - - return new_entryprice - - def custom_exit_price(self, pair: str, trade: Trade, - current_time: datetime, proposed_rate: float, - current_profit: float, **kwargs) -> float: - - dataframe, last_updated = self.dp.get_analyzed_dataframe(pair=pair, - timeframe=self.timeframe) - new_exitprice = dataframe['bollinger_10_upperband'].iat[-1] - - return new_exitprice - -``` - -!!! Warning - Modifying entry and exit prices will only work for limit orders. Depending on the price chosen, this can result in a lot of unfilled orders. By default the maximum allowed distance between the current price and the custom price is 2%, this value can be changed in config with the `custom_price_max_distance_ratio` parameter. - -!!! Example - If the new_entryprice is 97, the proposed_rate is 100 and the `custom_price_max_distance_ratio` is set to 2%, The retained valid custom entry price will be 98. - -!!! Warning "No backtesting support" - Custom entry-prices are currently not supported during backtesting. - -## Custom order timeout rules - -Simple, time-based order-timeouts can be configured either via strategy or in the configuration in the `unfilledtimeout` section. - -However, freqtrade also offers a custom callback for both order types, which allows you to decide based on custom criteria if an order did time out or not. - -!!! Note - Unfilled order timeouts are not relevant during backtesting or hyperopt, and are only relevant during real (live) trading. Therefore these methods are only called in these circumstances. - -### Custom order timeout example - -A simple example, which applies different unfilled-timeouts depending on the price of the asset can be seen below. -It applies a tight timeout for higher priced assets, while allowing more time to fill on cheap coins. - -The function must return either `True` (cancel order) or `False` (keep order alive). - -``` python -from datetime import datetime, timedelta, timezone -from freqtrade.persistence import Trade - -class AwesomeStrategy(IStrategy): - - # ... populate_* methods - - # Set unfilledtimeout to 25 hours, since the maximum timeout from below is 24 hours. - unfilledtimeout = { - 'buy': 60 * 25, - 'sell': 60 * 25 - } - - def check_buy_timeout(self, pair: str, trade: 'Trade', order: dict, **kwargs) -> bool: - if trade.open_rate > 100 and trade.open_date_utc < datetime.now(timezone.utc) - timedelta(minutes=5): - return True - elif trade.open_rate > 10 and trade.open_date_utc < datetime.now(timezone.utc) - timedelta(minutes=3): - return True - elif trade.open_rate < 1 and trade.open_date_utc < datetime.now(timezone.utc) - timedelta(hours=24): - return True - return False - - - def check_sell_timeout(self, pair: str, trade: 'Trade', order: dict, **kwargs) -> bool: - if trade.open_rate > 100 and trade.open_date_utc < datetime.now(timezone.utc) - timedelta(minutes=5): - return True - elif trade.open_rate > 10 and trade.open_date_utc < datetime.now(timezone.utc) - timedelta(minutes=3): - return True - elif trade.open_rate < 1 and trade.open_date_utc < datetime.now(timezone.utc) - timedelta(hours=24): - return True - return False -``` - -!!! Note - For the above example, `unfilledtimeout` must be set to something bigger than 24h, otherwise that type of timeout will apply first. - -### Custom order timeout example (using additional data) - -``` python -from datetime import datetime -from freqtrade.persistence import Trade - -class AwesomeStrategy(IStrategy): - - # ... populate_* methods - - # Set unfilledtimeout to 25 hours, since the maximum timeout from below is 24 hours. - unfilledtimeout = { - 'buy': 60 * 25, - 'sell': 60 * 25 - } - - def check_buy_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool: - ob = self.dp.orderbook(pair, 1) - current_price = ob['bids'][0][0] - # Cancel buy order if price is more than 2% above the order. - if current_price > order['price'] * 1.02: - return True - return False - - - def check_sell_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool: - ob = self.dp.orderbook(pair, 1) - current_price = ob['asks'][0][0] - # Cancel sell order if price is more than 2% below the order. - if current_price < order['price'] * 0.98: - return True - return False -``` - ---- - -## Bot order confirmation - -### Trade entry (buy order) confirmation - -`confirm_trade_entry()` can be used to abort a trade entry at the latest second (maybe because the price is not what we expect). - -``` python -class AwesomeStrategy(IStrategy): - - # ... populate_* methods - - def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float, - time_in_force: str, current_time: datetime, **kwargs) -> bool: - """ - Called right before placing a buy order. - Timing for this function is critical, so avoid doing heavy computations or - network requests in this method. - - For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ - - When not implemented by a strategy, returns True (always confirming). - - :param pair: Pair that's about to be bought. - :param order_type: Order type (as configured in order_types). usually limit or market. - :param amount: Amount in target (quote) currency that's going to be traded. - :param rate: Rate that's going to be used when using limit orders - :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). - :param current_time: datetime object, containing the current datetime - :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. - :return bool: When True is returned, then the buy-order is placed on the exchange. - False aborts the process - """ - return True - -``` - -### Trade exit (sell order) confirmation - -`confirm_trade_exit()` can be used to abort a trade exit (sell) at the latest second (maybe because the price is not what we expect). - -``` python -from freqtrade.persistence import Trade - - -class AwesomeStrategy(IStrategy): - - # ... populate_* methods - - def confirm_trade_exit(self, pair: str, trade: Trade, order_type: str, amount: float, - rate: float, time_in_force: str, sell_reason: str, - current_time: datetime, **kwargs) -> bool: - """ - Called right before placing a regular sell order. - Timing for this function is critical, so avoid doing heavy computations or - network requests in this method. - - For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ - - When not implemented by a strategy, returns True (always confirming). - - :param pair: Pair that's about to be sold. - :param order_type: Order type (as configured in order_types). usually limit or market. - :param amount: Amount in quote currency. - :param rate: Rate that's going to be used when using limit orders - :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). - :param sell_reason: Sell reason. - Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss', - 'sell_signal', 'force_sell', 'emergency_sell'] - :param current_time: datetime object, containing the current datetime - :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. - :return bool: When True is returned, then the sell-order is placed on the exchange. - False aborts the process - """ - if sell_reason == 'force_sell' and trade.calc_profit_ratio(rate) < 0: - # Reject force-sells with negative profit - # This is just a sample, please adjust to your needs - # (this does not necessarily make sense, assuming you know when you're force-selling) - return False - return True - -``` - -### Stake size management - -It is possible to manage your risk by reducing or increasing stake amount when placing a new trade. - -```python -class AwesomeStrategy(IStrategy): - def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float, - proposed_stake: float, min_stake: float, max_stake: float, - **kwargs) -> float: - - dataframe, _ = self.dp.get_analyzed_dataframe(pair=pair, timeframe=self.timeframe) - current_candle = dataframe.iloc[-1].squeeze() - - if current_candle['fastk_rsi_1h'] > current_candle['fastd_rsi_1h']: - if self.config['stake_amount'] == 'unlimited': - # Use entire available wallet during favorable conditions when in compounding mode. - return max_stake - else: - # Compound profits during favorable conditions instead of using a static stake. - return self.wallets.get_total_stake_amount() / self.config['max_open_trades'] - - # Use default stake amount. - return proposed_stake -``` - -Freqtrade will fall back to the `proposed_stake` value should your code raise an exception. The exception itself will be logged. - -!!! Tip - You do not _have_ to ensure that `min_stake <= returned_value <= max_stake`. Trades will succeed as the returned value will be clamped to supported range and this acton will be logged. - -!!! Tip - Returning `0` or `None` will prevent trades from being placed. - ---- - ## Derived strategies The strategies can be derived from other strategies. This avoids duplication of your custom strategy code. You can use this technique to override small parts of your main strategy, leaving the rest untouched: diff --git a/docs/strategy-callbacks.md b/docs/strategy-callbacks.md new file mode 100644 index 000000000..7a7756652 --- /dev/null +++ b/docs/strategy-callbacks.md @@ -0,0 +1,568 @@ +# Strategy Callbacks + +While the main strategy functions (`populate_indicators()`, `populate_buy_trend()`, `populate_sell_trend()`) should be used in a vectorized way, and are only called [once during backtesting](bot-basics.md#backtesting-hyperopt-execution-logic), callbacks are called "whenever needed". + +As such, you should avoid doing heavy calculations in callbacks to avoid delays during operations. +Depending on the callback used, they may be called when entering / exiting a trade, or throughout the duration of a trade. + +Currently available callbacks: + +* [`bot_loop_start()`](#bot-loop-start) +* [`custom_stake_amount()`](#custom-stake-size) +* [`custom_sell()`](#custom-sell-signal) +* [`custom_stoploss()`](#custom-stoploss) +* [`custom_entry_price()` and `custom_exit_price()`](#custom-order-price-rules) +* [`check_buy_timeout()` and `check_sell_timeout()](#custom-order-timeout-rules) +* [`confirm_trade_entry()`](#trade-entry-buy-order-confirmation) +* [`confirm_trade_exit()`](#trade-exit-sell-order-confirmation) + +!!! Tip "Callback calling sequence" + You can find the callback calling sequence in [bot-basics](bot-basics.md#bot-execution-logic) + +## Bot loop start + +A simple callback which is called once at the start of every bot throttling iteration (roughly every 5 seconds, unless configured differently). +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 Stake size + +Called before entering a trade, makes it possible to manage your position size when placing a new trade. + +```python +class AwesomeStrategy(IStrategy): + def custom_stake_amount(self, pair: str, current_time: datetime, current_rate: float, + proposed_stake: float, min_stake: float, max_stake: float, + **kwargs) -> float: + + dataframe, _ = self.dp.get_analyzed_dataframe(pair=pair, timeframe=self.timeframe) + current_candle = dataframe.iloc[-1].squeeze() + + if current_candle['fastk_rsi_1h'] > current_candle['fastd_rsi_1h']: + if self.config['stake_amount'] == 'unlimited': + # Use entire available wallet during favorable conditions when in compounding mode. + return max_stake + else: + # Compound profits during favorable conditions instead of using a static stake. + return self.wallets.get_total_stake_amount() / self.config['max_open_trades'] + + # Use default stake amount. + return proposed_stake +``` + +Freqtrade will fall back to the `proposed_stake` value should your code raise an exception. The exception itself will be logged. + +!!! Tip + You do not _have_ to ensure that `min_stake <= returned_value <= max_stake`. Trades will succeed as the returned value will be clamped to supported range and this acton will be logged. + +!!! Tip + Returning `0` or `None` will prevent trades from being placed. + +## Custom sell signal + +Called for open trade every throttling iteration (roughly every 5 seconds) until a trade is closed. + +Allows to define custom sell signals, indicating that specified position should be sold. This is very useful when we need to customize sell conditions for each individual trade, or if you need trade data to make an exit decision. + +For example you could implement a 1:2 risk-reward ROI with `custom_sell()`. + +Using custom_sell() signals in place of stoploss though *is not recommended*. It is a inferior method to using `custom_stoploss()` in this regard - which also allows you to keep the stoploss on exchange. + +!!! Note + Returning a (none-empty) `string` or `True` from this method is equal to setting sell signal on a candle at specified time. This method is not called when sell signal is set already, or if sell signals are disabled (`use_sell_signal=False` or `sell_profit_only=True` while profit is below `sell_profit_offset`). `string` max length is 64 characters. Exceeding this limit will cause the message to be truncated to 64 characters. + +An example of how we can use different indicators depending on the current profit and also sell trades that were open longer than one day: + +``` python +class AwesomeStrategy(IStrategy): + def custom_sell(self, pair: str, trade: 'Trade', current_time: 'datetime', current_rate: float, + current_profit: float, **kwargs): + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + last_candle = dataframe.iloc[-1].squeeze() + + # Above 20% profit, sell when rsi < 80 + if current_profit > 0.2: + if last_candle['rsi'] < 80: + return 'rsi_below_80' + + # Between 2% and 10%, sell if EMA-long above EMA-short + if 0.02 < current_profit < 0.1: + if last_candle['emalong'] > last_candle['emashort']: + return 'ema_long_below_80' + + # Sell any positions at a loss if they are held for more than one day. + if current_profit < 0.0 and (current_time - trade.open_date_utc).days >= 1: + return 'unclog' +``` + +See [Dataframe access](strategy-advanced.md#dataframe-access) for more information about dataframe use in strategy callbacks. + +## Custom stoploss + +Called for open trade every throttling iteration (roughly every 5 seconds) until a trade is closed. +The usage of the custom stoploss method must be enabled by setting `use_custom_stoploss=True` on the strategy object. + +The stoploss price can only ever move upwards - if the stoploss value returned from `custom_stoploss` would result in a lower stoploss price than was previously set, it will be ignored. The traditional `stoploss` value serves as an absolute lower level and will be instated as the initial stoploss (before this method is called for the first time for a trade). + +The method must return a stoploss value (float / number) as a percentage of the current price. +E.g. If the `current_rate` is 200 USD, then returning `0.02` will set the stoploss price 2% lower, at 196 USD. + +The absolute value of the return value is used (the sign is ignored), so returning `0.05` or `-0.05` have the same result, a stoploss 5% below the current price. + +To simulate a regular trailing stoploss of 4% (trailing 4% behind the maximum reached price) you would use the following very simple method: + +``` python +# additional imports required +from datetime import datetime +from freqtrade.persistence import Trade + +class AwesomeStrategy(IStrategy): + + # ... populate_* methods + + use_custom_stoploss = True + + def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, + current_rate: float, current_profit: float, **kwargs) -> float: + """ + Custom stoploss logic, returning the new distance relative to current_rate (as ratio). + e.g. returning -0.05 would create a stoploss 5% below current_rate. + The custom stoploss can never be below self.stoploss, which serves as a hard maximum loss. + + For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ + + When not implemented by a strategy, returns the initial stoploss value + Only called when use_custom_stoploss is set to True. + + :param pair: Pair that's currently analyzed + :param trade: trade object. + :param current_time: datetime object, containing the current datetime + :param current_rate: Rate, calculated based on pricing settings in ask_strategy. + :param current_profit: Current profit (as ratio), calculated based on current_rate. + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return float: New stoploss value, relative to the current rate + """ + return -0.04 +``` + +Stoploss on exchange works similar to `trailing_stop`, and the stoploss on exchange is updated as configured in `stoploss_on_exchange_interval` ([More details about stoploss on exchange](stoploss.md#stop-loss-on-exchange-freqtrade)). + +!!! Note "Use of dates" + All time-based calculations should be done based on `current_time` - using `datetime.now()` or `datetime.utcnow()` is discouraged, as this will break backtesting support. + +!!! Tip "Trailing stoploss" + It's recommended to disable `trailing_stop` when using custom stoploss values. Both can work in tandem, but you might encounter the trailing stop to move the price higher while your custom function would not want this, causing conflicting behavior. + +### Custom stoploss examples + +The next section will show some examples on what's possible with the custom stoploss function. +Of course, many more things are possible, and all examples can be combined at will. + +#### Time based trailing stop + +Use the initial stoploss for the first 60 minutes, after this change to 10% trailing stoploss, and after 2 hours (120 minutes) we use a 5% trailing stoploss. + +``` python +from datetime import datetime, timedelta +from freqtrade.persistence import Trade + +class AwesomeStrategy(IStrategy): + + # ... populate_* methods + + use_custom_stoploss = True + + def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, + current_rate: float, current_profit: float, **kwargs) -> float: + + # Make sure you have the longest interval first - these conditions are evaluated from top to bottom. + if current_time - timedelta(minutes=120) > trade.open_date_utc: + return -0.05 + elif current_time - timedelta(minutes=60) > trade.open_date_utc: + return -0.10 + return 1 +``` + +#### Different stoploss per pair + +Use a different stoploss depending on the pair. +In this example, we'll trail the highest price with 10% trailing stoploss for `ETH/BTC` and `XRP/BTC`, with 5% trailing stoploss for `LTC/BTC` and with 15% for all other pairs. + +``` python +from datetime import datetime +from freqtrade.persistence import Trade + +class AwesomeStrategy(IStrategy): + + # ... populate_* methods + + use_custom_stoploss = True + + def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, + current_rate: float, current_profit: float, **kwargs) -> float: + + if pair in ('ETH/BTC', 'XRP/BTC'): + return -0.10 + elif pair in ('LTC/BTC'): + return -0.05 + return -0.15 +``` + +#### Trailing stoploss with positive offset + +Use the initial stoploss until the profit is above 4%, then use a trailing stoploss of 50% of the current profit with a minimum of 2.5% and a maximum of 5%. + +Please note that the stoploss can only increase, values lower than the current stoploss are ignored. + +``` python +from datetime import datetime, timedelta +from freqtrade.persistence import Trade + +class AwesomeStrategy(IStrategy): + + # ... populate_* methods + + use_custom_stoploss = True + + def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, + current_rate: float, current_profit: float, **kwargs) -> float: + + if current_profit < 0.04: + return -1 # return a value bigger than the initial stoploss to keep using the initial stoploss + + # After reaching the desired offset, allow the stoploss to trail by half the profit + desired_stoploss = current_profit / 2 + + # Use a minimum of 2.5% and a maximum of 5% + return max(min(desired_stoploss, 0.05), 0.025) +``` + +#### Stepped stoploss + +Instead of continuously trailing behind the current price, this example sets fixed stoploss price levels based on the current profit. + +* Use the regular stoploss until 20% profit is reached +* Once profit is > 20% - set stoploss to 7% above open price. +* Once profit is > 25% - set stoploss to 15% above open price. +* Once profit is > 40% - set stoploss to 25% above open price. + +``` python +from datetime import datetime +from freqtrade.persistence import Trade +from freqtrade.strategy import stoploss_from_open + +class AwesomeStrategy(IStrategy): + + # ... populate_* methods + + use_custom_stoploss = True + + def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, + current_rate: float, current_profit: float, **kwargs) -> float: + + # evaluate highest to lowest, so that highest possible stop is used + if current_profit > 0.40: + return stoploss_from_open(0.25, current_profit) + elif current_profit > 0.25: + return stoploss_from_open(0.15, current_profit) + elif current_profit > 0.20: + return stoploss_from_open(0.07, current_profit) + + # return maximum stoploss value, keeping current stoploss price unchanged + return 1 +``` + +#### Custom stoploss using an indicator from dataframe example + +Absolute stoploss value may be derived from indicators stored in dataframe. Example uses parabolic SAR below the price as stoploss. + +``` python +class AwesomeStrategy(IStrategy): + + def populate_indicators(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + # <...> + dataframe['sar'] = ta.SAR(dataframe) + + use_custom_stoploss = True + + def custom_stoploss(self, pair: str, trade: 'Trade', current_time: datetime, + current_rate: float, current_profit: float, **kwargs) -> float: + + dataframe, _ = self.dp.get_analyzed_dataframe(pair, self.timeframe) + last_candle = dataframe.iloc[-1].squeeze() + + # Use parabolic sar as absolute stoploss price + stoploss_price = last_candle['sar'] + + # Convert absolute price to percentage relative to current_rate + if stoploss_price < current_rate: + return (stoploss_price / current_rate) - 1 + + # return maximum stoploss value, keeping current stoploss price unchanged + return 1 +``` + +See [Dataframe access](strategy-advanced.md#dataframe-access) for more information about dataframe use in strategy callbacks. + +### Common helpers for stoploss calculations + +#### Stoploss relative to open price + +Stoploss values returned from `custom_stoploss()` always specify a percentage relative to `current_rate`. In order to set a stoploss relative to the *open* price, we need to use `current_profit` to calculate what percentage relative to the `current_rate` will give you the same result as if the percentage was specified from the open price. + +The helper function [`stoploss_from_open()`](strategy-customization.md#stoploss_from_open) can be used to convert from an open price relative stop, to a current price relative stop which can be returned from `custom_stoploss()`. + +#### Stoploss percentage from absolute price + +Stoploss values returned from `custom_stoploss()` always specify a percentage relative to `current_rate`. In order to set a stoploss at specified absolute price level, we need to use `stop_rate` to calculate what percentage relative to the `current_rate` will give you the same result as if the percentage was specified from the open price. + +The helper function [`stoploss_from_absolute()`](strategy-customization.md#stoploss_from_absolute) can be used to convert from an absolute price, to a current price relative stop which can be returned from `custom_stoploss()`. + +--- + +## Custom order price rules + +By default, freqtrade use the orderbook to automatically set an order price([Relevant documentation](configuration.md#prices-used-for-orders)), you also have the option to create custom order prices based on your strategy. + +You can use this feature by creating a `custom_entry_price()` function in your strategy file to customize entry prices and `custom_exit_price()` for exits. + +Each of these methods are called right before placing an order on the exchange. + +!!! Note + If your custom pricing function return None or an invalid value, price will fall back to `proposed_rate`, which is based on the regular pricing configuration. + +### Custom order entry and exit price example + +``` python +from datetime import datetime, timedelta, timezone +from freqtrade.persistence import Trade + +class AwesomeStrategy(IStrategy): + + # ... populate_* methods + + def custom_entry_price(self, pair: str, current_time: datetime, + proposed_rate, **kwargs) -> float: + + dataframe, last_updated = self.dp.get_analyzed_dataframe(pair=pair, + timeframe=self.timeframe) + new_entryprice = dataframe['bollinger_10_lowerband'].iat[-1] + + return new_entryprice + + def custom_exit_price(self, pair: str, trade: Trade, + current_time: datetime, proposed_rate: float, + current_profit: float, **kwargs) -> float: + + dataframe, last_updated = self.dp.get_analyzed_dataframe(pair=pair, + timeframe=self.timeframe) + new_exitprice = dataframe['bollinger_10_upperband'].iat[-1] + + return new_exitprice + +``` + +!!! Warning + Modifying entry and exit prices will only work for limit orders. Depending on the price chosen, this can result in a lot of unfilled orders. By default the maximum allowed distance between the current price and the custom price is 2%, this value can be changed in config with the `custom_price_max_distance_ratio` parameter. + **Example**: + If the new_entryprice is 97, the proposed_rate is 100 and the `custom_price_max_distance_ratio` is set to 2%, The retained valid custom entry price will be 98, which is 2% below the current (proposed) rate. + +!!! Warning "No backtesting support" + Custom entry-prices are currently not supported during backtesting. + +## Custom order timeout rules + +Simple, time-based order-timeouts can be configured either via strategy or in the configuration in the `unfilledtimeout` section. + +However, freqtrade also offers a custom callback for both order types, which allows you to decide based on custom criteria if an order did time out or not. + +!!! Note + Unfilled order timeouts are not relevant during backtesting or hyperopt, and are only relevant during real (live) trading. Therefore these methods are only called in these circumstances. + +### Custom order timeout example + +Called for every open order until that order is either filled or cancelled. +`check_buy_timeout()` is called for trade entries, while `check_sell_timeout()` is called for trade exit orders. + +A simple example, which applies different unfilled-timeouts depending on the price of the asset can be seen below. +It applies a tight timeout for higher priced assets, while allowing more time to fill on cheap coins. + +The function must return either `True` (cancel order) or `False` (keep order alive). + +``` python +from datetime import datetime, timedelta, timezone +from freqtrade.persistence import Trade + +class AwesomeStrategy(IStrategy): + + # ... populate_* methods + + # Set unfilledtimeout to 25 hours, since the maximum timeout from below is 24 hours. + unfilledtimeout = { + 'buy': 60 * 25, + 'sell': 60 * 25 + } + + def check_buy_timeout(self, pair: str, trade: 'Trade', order: dict, **kwargs) -> bool: + if trade.open_rate > 100 and trade.open_date_utc < datetime.now(timezone.utc) - timedelta(minutes=5): + return True + elif trade.open_rate > 10 and trade.open_date_utc < datetime.now(timezone.utc) - timedelta(minutes=3): + return True + elif trade.open_rate < 1 and trade.open_date_utc < datetime.now(timezone.utc) - timedelta(hours=24): + return True + return False + + + def check_sell_timeout(self, pair: str, trade: 'Trade', order: dict, **kwargs) -> bool: + if trade.open_rate > 100 and trade.open_date_utc < datetime.now(timezone.utc) - timedelta(minutes=5): + return True + elif trade.open_rate > 10 and trade.open_date_utc < datetime.now(timezone.utc) - timedelta(minutes=3): + return True + elif trade.open_rate < 1 and trade.open_date_utc < datetime.now(timezone.utc) - timedelta(hours=24): + return True + return False +``` + +!!! Note + For the above example, `unfilledtimeout` must be set to something bigger than 24h, otherwise that type of timeout will apply first. + +### Custom order timeout example (using additional data) + +``` python +from datetime import datetime +from freqtrade.persistence import Trade + +class AwesomeStrategy(IStrategy): + + # ... populate_* methods + + # Set unfilledtimeout to 25 hours, since the maximum timeout from below is 24 hours. + unfilledtimeout = { + 'buy': 60 * 25, + 'sell': 60 * 25 + } + + def check_buy_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool: + ob = self.dp.orderbook(pair, 1) + current_price = ob['bids'][0][0] + # Cancel buy order if price is more than 2% above the order. + if current_price > order['price'] * 1.02: + return True + return False + + + def check_sell_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool: + ob = self.dp.orderbook(pair, 1) + current_price = ob['asks'][0][0] + # Cancel sell order if price is more than 2% below the order. + if current_price < order['price'] * 0.98: + return True + return False +``` + +--- + +## Bot order confirmation + +Confirm trade entry / exits. +This are the last methods that will be called before an order is placed. + +### Trade entry (buy order) confirmation + +`confirm_trade_entry()` can be used to abort a trade entry at the latest second (maybe because the price is not what we expect). + +``` python +class AwesomeStrategy(IStrategy): + + # ... populate_* methods + + def confirm_trade_entry(self, pair: str, order_type: str, amount: float, rate: float, + time_in_force: str, current_time: datetime, **kwargs) -> bool: + """ + Called right before placing a buy order. + Timing for this function is critical, so avoid doing heavy computations or + network requests in this method. + + For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ + + When not implemented by a strategy, returns True (always confirming). + + :param pair: Pair that's about to be bought. + :param order_type: Order type (as configured in order_types). usually limit or market. + :param amount: Amount in target (quote) currency that's going to be traded. + :param rate: Rate that's going to be used when using limit orders + :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). + :param current_time: datetime object, containing the current datetime + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return bool: When True is returned, then the buy-order is placed on the exchange. + False aborts the process + """ + return True + +``` + +### Trade exit (sell order) confirmation + +`confirm_trade_exit()` can be used to abort a trade exit (sell) at the latest second (maybe because the price is not what we expect). + +``` python +from freqtrade.persistence import Trade + + +class AwesomeStrategy(IStrategy): + + # ... populate_* methods + + def confirm_trade_exit(self, pair: str, trade: Trade, order_type: str, amount: float, + rate: float, time_in_force: str, sell_reason: str, + current_time: datetime, **kwargs) -> bool: + """ + Called right before placing a regular sell order. + Timing for this function is critical, so avoid doing heavy computations or + network requests in this method. + + For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/ + + When not implemented by a strategy, returns True (always confirming). + + :param pair: Pair that's about to be sold. + :param order_type: Order type (as configured in order_types). usually limit or market. + :param amount: Amount in quote currency. + :param rate: Rate that's going to be used when using limit orders + :param time_in_force: Time in force. Defaults to GTC (Good-til-cancelled). + :param sell_reason: Sell reason. + Can be any of ['roi', 'stop_loss', 'stoploss_on_exchange', 'trailing_stop_loss', + 'sell_signal', 'force_sell', 'emergency_sell'] + :param current_time: datetime object, containing the current datetime + :param **kwargs: Ensure to keep this here so updates to this won't break your strategy. + :return bool: When True is returned, then the sell-order is placed on the exchange. + False aborts the process + """ + if sell_reason == 'force_sell' and trade.calc_profit_ratio(rate) < 0: + # Reject force-sells with negative profit + # This is just a sample, please adjust to your needs + # (this does not necessarily make sense, assuming you know when you're force-selling) + return False + return True + +``` diff --git a/docs/strategy-customization.md b/docs/strategy-customization.md index d54bae710..178ed108b 100644 --- a/docs/strategy-customization.md +++ b/docs/strategy-customization.md @@ -317,20 +317,14 @@ class AwesomeStrategy(IStrategy): Setting a stoploss is highly recommended to protect your capital from strong moves against you. -Sample: +Sample of setting a 10% stoploss: ``` python stoploss = -0.10 ``` -This would signify a stoploss of -10%. - For the full documentation on stoploss features, look at the dedicated [stoploss page](stoploss.md). -If your exchange supports it, it's recommended to also set `"stoploss_on_exchange"` in the order_types dictionary, so your stoploss is on the exchange and cannot be missed due to network problems, high load or other reasons. - -For more information on order_types please look [here](configuration.md#understand-order_types). - ### Timeframe (formerly ticker interval) This is the set of candles the bot should download and use for the analysis. @@ -346,7 +340,7 @@ The metadata-dict (available for `populate_buy_trend`, `populate_sell_trend`, `p Currently this is `pair`, which can be accessed using `metadata['pair']` - and will return a pair in the format `XRP/BTC`. 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) +Instead, have a look at the [Storing information](strategy-advanced.md#Storing-information) section. ## Strategy file loading @@ -1016,6 +1010,10 @@ The following lists some common patterns which should be avoided to prevent frus - don't use `dataframe['volume'].mean()`. This uses the full DataFrame for backtesting, including data from the future. Use `dataframe['volume'].rolling().mean()` instead - don't use `.resample('1h')`. This uses the left border of the interval, so moves data from an hour to the start of the hour. Use `.resample('1h', label='right')` instead. +### Colliding signals + +When buy and sell signals collide (both `'buy'` and `'sell'` are 1), freqtrade will do nothing and ignore the entry (buy) signal. This will avoid trades that buy, and sell immediately. Obviously, this can potentially lead to missed entries. + ## Further strategy ideas To get additional Ideas for strategies, head over to the [strategy repository](https://github.com/freqtrade/freqtrade-strategies). Feel free to use them as they are - but results will depend on the current market situation, pairs used etc. - therefore please backtest the strategy for your exchange/desired pairs first, evaluate carefully, use at your own risk. diff --git a/docs/strategy_analysis_example.md b/docs/strategy_analysis_example.md index dd7e07824..90d8d8800 100644 --- a/docs/strategy_analysis_example.md +++ b/docs/strategy_analysis_example.md @@ -50,7 +50,9 @@ candles.head() ```python # Load strategy using values set above from freqtrade.resolvers import StrategyResolver +from freqtrade.data.dataprovider import DataProvider strategy = StrategyResolver.load_strategy(config) +strategy.dp = DataProvider(config, None, None) # Generate buy/sell signals using strategy df = strategy.analyze_ticker(candles, {'pair': pair}) @@ -228,7 +230,7 @@ graph = generate_candlestick_graph(pair=pair, # Show graph inline # graph.show() -# Render graph in a separate window +# Render graph in a seperate window graph.show(renderer="browser") ``` diff --git a/docs/webhook-config.md b/docs/webhook-config.md index ec944cb50..40915c988 100644 --- a/docs/webhook-config.md +++ b/docs/webhook-config.md @@ -50,7 +50,7 @@ Sample configuration (tested using IFTTT). The url in `webhook.url` should point to the correct url for your webhook. If you're using [IFTTT](https://ifttt.com) (as shown in the sample above) please insert your event and key to the url. -You can set the POST body format to Form-Encoded (default) or JSON-Encoded. Use `"format": "form"` or `"format": "json"` respectively. Example configuration for Mattermost Cloud integration: +You can set the POST body format to Form-Encoded (default), JSON-Encoded, or raw data. Use `"format": "form"`, `"format": "json"`, or `"format": "raw"` respectively. Example configuration for Mattermost Cloud integration: ```json "webhook": { @@ -63,7 +63,36 @@ You can set the POST body format to Form-Encoded (default) or JSON-Encoded. Use }, ``` -The result would be POST request with e.g. `{"text":"Status: running"}` body and `Content-Type: application/json` header which results `Status: running` message in the Mattermost channel. +The result would be a POST request with e.g. `{"text":"Status: running"}` body and `Content-Type: application/json` header which results `Status: running` message in the Mattermost channel. + +When using the Form-Encoded or JSON-Encoded configuration you can configure any number of payload values, and both the key and value will be ouput in the POST request. However, when using the raw data format you can only configure one value and it **must** be named `"data"`. In this instance the data key will not be output in the POST request, only the value. For example: + +```json + "webhook": { + "enabled": true, + "url": "https://", + "format": "raw", + "webhookstatus": { + "data": "Status: {status}" + } + }, +``` + +The result would be a POST request with e.g. `Status: running` body and `Content-Type: text/plain` header. + +Optional parameters are available to enable automatic retries for webhook messages. The `webhook.retries` parameter can be set for the maximum number of retries the webhook request should attempt if it is unsuccessful (i.e. HTTP response status is not 200). By default this is set to `0` which is disabled. An additional `webhook.retry_delay` parameter can be set to specify the time in seconds between retry attempts. By default this is set to `0.1` (i.e. 100ms). Note that increasing the number of retries or retry delay may slow down the trader if there are connectivity issues with the webhook. Example configuration for retries: + +```json + "webhook": { + "enabled": true, + "url": "https://", + "retries": 3, + "retry_delay": 0.2, + "webhookstatus": { + "status": "Status: {status}" + } + }, +``` Different payloads can be configured for different events. Not all fields are necessary, but you should configure at least one of the dicts, otherwise the webhook will never be called. @@ -75,7 +104,8 @@ Possible parameters are: * `trade_id` * `exchange` * `pair` -* `limit` +* ~~`limit` # Deprecated - should no longer be used.~~ +* `open_rate` * `amount` * `open_date` * `stake_amount` @@ -117,6 +147,8 @@ Possible parameters are: * `stake_amount` * `stake_currency` * `fiat_currency` +* `order_type` +* `current_rate` * `buy_tag` ### Webhooksell diff --git a/freqtrade/constants.py b/freqtrade/constants.py index e775e39fc..e074718ca 100644 --- a/freqtrade/constants.py +++ b/freqtrade/constants.py @@ -50,6 +50,8 @@ USERPATH_STRATEGIES = 'strategies' USERPATH_NOTEBOOKS = 'notebooks' TELEGRAM_SETTING_OPTIONS = ['on', 'off', 'silent'] +WEBHOOK_FORMAT_OPTIONS = ['form', 'json', 'raw'] + ENV_VAR_PREFIX = 'FREQTRADE__' NON_OPEN_EXCHANGE_STATES = ('cancelled', 'canceled', 'closed', 'expired') @@ -312,10 +314,16 @@ CONF_SCHEMA = { 'type': 'object', 'properties': { 'enabled': {'type': 'boolean'}, + 'url': {'type': 'string'}, + 'format': {'type': 'string', 'enum': WEBHOOK_FORMAT_OPTIONS, 'default': 'form'}, + 'retries': {'type': 'integer', 'minimum': 0}, + 'retry_delay': {'type': 'number', 'minimum': 0}, 'webhookbuy': {'type': 'object'}, 'webhookbuycancel': {'type': 'object'}, + 'webhookbuyfill': {'type': 'object'}, 'webhooksell': {'type': 'object'}, 'webhooksellcancel': {'type': 'object'}, + 'webhooksellfill': {'type': 'object'}, 'webhookstatus': {'type': 'object'}, }, }, diff --git a/freqtrade/data/history/hdf5datahandler.py b/freqtrade/data/history/hdf5datahandler.py index dd60530aa..49fac99ea 100644 --- a/freqtrade/data/history/hdf5datahandler.py +++ b/freqtrade/data/history/hdf5datahandler.py @@ -6,7 +6,6 @@ from typing import List, Optional import numpy as np import pandas as pd -from freqtrade import misc from freqtrade.configuration import TimeRange from freqtrade.constants import (DEFAULT_DATAFRAME_COLUMNS, DEFAULT_TRADES_COLUMNS, ListPairsWithTimeframes, TradeList) @@ -61,10 +60,10 @@ class HDF5DataHandler(IDataHandler): filename = self._pair_data_filename(self._datadir, pair, timeframe) - ds = pd.HDFStore(filename, mode='a', complevel=9, complib='blosc') - ds.put(key, _data.loc[:, self._columns], format='table', data_columns=['date']) - - ds.close() + _data.loc[:, self._columns].to_hdf( + filename, key, mode='a', complevel=9, complib='blosc', + format='table', data_columns=['date'] + ) def _ohlcv_load(self, pair: str, timeframe: str, timerange: Optional[TimeRange] = None) -> pd.DataFrame: @@ -99,19 +98,6 @@ class HDF5DataHandler(IDataHandler): 'low': 'float', 'close': 'float', 'volume': 'float'}) return pairdata - def ohlcv_purge(self, pair: str, timeframe: str) -> bool: - """ - Remove data for this pair - :param pair: Delete data for this pair. - :param timeframe: Timeframe (e.g. "5m") - :return: True when deleted, false if file did not exist. - """ - filename = self._pair_data_filename(self._datadir, pair, timeframe) - if filename.exists(): - filename.unlink() - return True - return False - def ohlcv_append(self, pair: str, timeframe: str, data: pd.DataFrame) -> None: """ Append data to existing data structures @@ -142,11 +128,11 @@ class HDF5DataHandler(IDataHandler): """ key = self._pair_trades_key(pair) - ds = pd.HDFStore(self._pair_trades_filename(self._datadir, pair), - mode='a', complevel=9, complib='blosc') - ds.put(key, pd.DataFrame(data, columns=DEFAULT_TRADES_COLUMNS), - format='table', data_columns=['timestamp']) - ds.close() + pd.DataFrame(data, columns=DEFAULT_TRADES_COLUMNS).to_hdf( + self._pair_trades_filename(self._datadir, pair), key, + mode='a', complevel=9, complib='blosc', + format='table', data_columns=['timestamp'] + ) def trades_append(self, pair: str, data: TradeList): """ @@ -180,17 +166,9 @@ class HDF5DataHandler(IDataHandler): trades[['id', 'type']] = trades[['id', 'type']].replace({np.nan: None}) return trades.values.tolist() - def trades_purge(self, pair: str) -> bool: - """ - Remove data for this pair - :param pair: Delete data for this pair. - :return: True when deleted, false if file did not exist. - """ - filename = self._pair_trades_filename(self._datadir, pair) - if filename.exists(): - filename.unlink() - return True - return False + @classmethod + def _get_file_extension(cls): + return "h5" @classmethod def _pair_ohlcv_key(cls, pair: str, timeframe: str) -> str: @@ -199,15 +177,3 @@ class HDF5DataHandler(IDataHandler): @classmethod def _pair_trades_key(cls, pair: str) -> str: return f"{pair}/trades" - - @classmethod - def _pair_data_filename(cls, datadir: Path, pair: str, timeframe: str) -> Path: - pair_s = misc.pair_to_filename(pair) - filename = datadir.joinpath(f'{pair_s}-{timeframe}.h5') - return filename - - @classmethod - def _pair_trades_filename(cls, datadir: Path, pair: str) -> Path: - pair_s = misc.pair_to_filename(pair) - filename = datadir.joinpath(f'{pair_s}-trades.h5') - return filename diff --git a/freqtrade/data/history/idatahandler.py b/freqtrade/data/history/idatahandler.py index 05052b2d7..578d0b5bf 100644 --- a/freqtrade/data/history/idatahandler.py +++ b/freqtrade/data/history/idatahandler.py @@ -12,6 +12,7 @@ from typing import List, Optional, Type from pandas import DataFrame +from freqtrade import misc from freqtrade.configuration import TimeRange from freqtrade.constants import ListPairsWithTimeframes, TradeList from freqtrade.data.converter import clean_ohlcv_dataframe, trades_remove_duplicates, trim_dataframe @@ -26,6 +27,13 @@ class IDataHandler(ABC): def __init__(self, datadir: Path) -> None: self._datadir = datadir + @classmethod + def _get_file_extension(cls) -> str: + """ + Get file extension for this particular datahandler + """ + raise NotImplementedError() + @abstractclassmethod def ohlcv_get_available_data(cls, datadir: Path) -> ListPairsWithTimeframes: """ @@ -70,7 +78,6 @@ class IDataHandler(ABC): :return: DataFrame with ohlcv data, or empty DataFrame """ - @abstractmethod def ohlcv_purge(self, pair: str, timeframe: str) -> bool: """ Remove data for this pair @@ -78,6 +85,11 @@ class IDataHandler(ABC): :param timeframe: Timeframe (e.g. "5m") :return: True when deleted, false if file did not exist. """ + filename = self._pair_data_filename(self._datadir, pair, timeframe) + if filename.exists(): + filename.unlink() + return True + return False @abstractmethod def ohlcv_append(self, pair: str, timeframe: str, data: DataFrame) -> None: @@ -123,13 +135,17 @@ class IDataHandler(ABC): :return: List of trades """ - @abstractmethod def trades_purge(self, pair: str) -> bool: """ Remove data for this pair :param pair: Delete data for this pair. :return: True when deleted, false if file did not exist. """ + filename = self._pair_trades_filename(self._datadir, pair) + if filename.exists(): + filename.unlink() + return True + return False def trades_load(self, pair: str, timerange: Optional[TimeRange] = None) -> TradeList: """ @@ -141,6 +157,18 @@ class IDataHandler(ABC): """ return trades_remove_duplicates(self._trades_load(pair, timerange=timerange)) + @classmethod + def _pair_data_filename(cls, datadir: Path, pair: str, timeframe: str) -> Path: + pair_s = misc.pair_to_filename(pair) + filename = datadir.joinpath(f'{pair_s}-{timeframe}.{cls._get_file_extension()}') + return filename + + @classmethod + def _pair_trades_filename(cls, datadir: Path, pair: str) -> Path: + pair_s = misc.pair_to_filename(pair) + filename = datadir.joinpath(f'{pair_s}-trades.{cls._get_file_extension()}') + return filename + def ohlcv_load(self, pair, timeframe: str, timerange: Optional[TimeRange] = None, fill_missing: bool = True, diff --git a/freqtrade/data/history/jsondatahandler.py b/freqtrade/data/history/jsondatahandler.py index 24d6e814b..ccefc8356 100644 --- a/freqtrade/data/history/jsondatahandler.py +++ b/freqtrade/data/history/jsondatahandler.py @@ -174,34 +174,10 @@ class JsonDataHandler(IDataHandler): pass return tradesdata - def trades_purge(self, pair: str) -> bool: - """ - Remove data for this pair - :param pair: Delete data for this pair. - :return: True when deleted, false if file did not exist. - """ - filename = self._pair_trades_filename(self._datadir, pair) - if filename.exists(): - filename.unlink() - return True - return False - - @classmethod - def _pair_data_filename(cls, datadir: Path, pair: str, timeframe: str) -> Path: - pair_s = misc.pair_to_filename(pair) - filename = datadir.joinpath(f'{pair_s}-{timeframe}.{cls._get_file_extension()}') - return filename - @classmethod def _get_file_extension(cls): return "json.gz" if cls._use_zip else "json" - @classmethod - def _pair_trades_filename(cls, datadir: Path, pair: str) -> Path: - pair_s = misc.pair_to_filename(pair) - filename = datadir.joinpath(f'{pair_s}-trades.{cls._get_file_extension()}') - return filename - class JsonGzDataHandler(JsonDataHandler): diff --git a/freqtrade/enums/__init__.py b/freqtrade/enums/__init__.py index d803baf31..eab483db3 100644 --- a/freqtrade/enums/__init__.py +++ b/freqtrade/enums/__init__.py @@ -1,5 +1,6 @@ # flake8: noqa: F401 from freqtrade.enums.backteststate import BacktestState +from freqtrade.enums.ordertypevalue import OrderTypeValues from freqtrade.enums.rpcmessagetype import RPCMessageType from freqtrade.enums.runmode import NON_UTIL_MODES, OPTIMIZE_MODES, TRADING_MODES, RunMode from freqtrade.enums.selltype import SellType diff --git a/freqtrade/enums/ordertypevalue.py b/freqtrade/enums/ordertypevalue.py new file mode 100644 index 000000000..9bb716171 --- /dev/null +++ b/freqtrade/enums/ordertypevalue.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class OrderTypeValues(str, Enum): + limit = 'limit' + market = 'market' diff --git a/freqtrade/exchange/exchange.py b/freqtrade/exchange/exchange.py index 19ad4e4b6..22041ddef 100644 --- a/freqtrade/exchange/exchange.py +++ b/freqtrade/exchange/exchange.py @@ -685,16 +685,20 @@ class Exchange: if not self.exchange_has('fetchL2OrderBook'): return True ob = self.fetch_l2_order_book(pair, 1) - if side == 'buy': - price = ob['asks'][0][0] - logger.debug(f"{pair} checking dry buy-order: price={price}, limit={limit}") - if limit >= price: - return True - else: - price = ob['bids'][0][0] - logger.debug(f"{pair} checking dry sell-order: price={price}, limit={limit}") - if limit <= price: - return True + try: + if side == 'buy': + price = ob['asks'][0][0] + logger.debug(f"{pair} checking dry buy-order: price={price}, limit={limit}") + if limit >= price: + return True + else: + price = ob['bids'][0][0] + logger.debug(f"{pair} checking dry sell-order: price={price}, limit={limit}") + if limit <= price: + return True + except IndexError: + # Ignore empty orderbooks when filling - can be filled with the next iteration. + pass return False def check_dry_limit_order_filled(self, order: Dict[str, Any]) -> Dict[str, Any]: @@ -1263,7 +1267,7 @@ class Exchange: results = await asyncio.gather(*input_coro, return_exceptions=True) for res in results: if isinstance(res, Exception): - logger.warning("Async code raised an exception: %s", res.__class__.__name__) + logger.warning(f"Async code raised an exception: {repr(res)}") if raise_: raise continue @@ -1294,7 +1298,7 @@ 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 not cache or self._now_is_time_to_refresh(pair, timeframe)): if not since_ms and self.required_candle_call_count > 1: # Multiple calls for one pair - to get more history @@ -1317,27 +1321,30 @@ class Exchange: ) cached_pairs.append((pair, timeframe)) - results = asyncio.get_event_loop().run_until_complete( - asyncio.gather(*input_coroutines, return_exceptions=True)) - results_df = {} - # handle caching - for res in results: - if isinstance(res, Exception): - logger.warning("Async code raised an exception: %s", res.__class__.__name__) - continue - # Deconstruct tuple (has 3 elements) - pair, timeframe, ticks = res - # keeping last candle time as last refreshed time of the pair - if ticks: - self._pairs_last_refresh_time[(pair, timeframe)] = ticks[-1][0] // 1000 - # keeping parsed dataframe in cache - ohlcv_df = ohlcv_to_dataframe( - ticks, timeframe, pair=pair, fill_missing=True, - drop_incomplete=self._ohlcv_partial_candle) - results_df[(pair, timeframe)] = ohlcv_df - if cache: - self._klines[(pair, timeframe)] = ohlcv_df + # Chunk requests into batches of 100 to avoid overwelming ccxt Throttling + for input_coro in chunks(input_coroutines, 100): + results = asyncio.get_event_loop().run_until_complete( + asyncio.gather(*input_coro, return_exceptions=True)) + + # handle caching + for res in results: + if isinstance(res, Exception): + logger.warning(f"Async code raised an exception: {repr(res)}") + continue + # Deconstruct tuple (has 3 elements) + pair, timeframe, ticks = res + # keeping last candle time as last refreshed time of the pair + if ticks: + self._pairs_last_refresh_time[(pair, timeframe)] = ticks[-1][0] // 1000 + # keeping parsed dataframe in cache + ohlcv_df = ohlcv_to_dataframe( + ticks, timeframe, pair=pair, fill_missing=True, + drop_incomplete=self._ohlcv_partial_candle) + results_df[(pair, timeframe)] = ohlcv_df + if cache: + self._klines[(pair, timeframe)] = ohlcv_df + # Return cached klines for pair, timeframe in cached_pairs: results_df[(pair, timeframe)] = self.klines((pair, timeframe), copy=False) diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index db0453cd7..7d8e0ec2f 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -278,7 +278,8 @@ class FreqtradeBot(LoggingMixin): if order: logger.info(f"Updating sell-fee on trade {trade} for order {order.order_id}.") self.update_trade_state(trade, order.order_id, - stoploss_order=order.ft_order_side == 'stoploss') + stoploss_order=order.ft_order_side == 'stoploss', + send_msg=False) trades: List[Trade] = Trade.get_open_trades_without_assigned_fees() for trade in trades: @@ -286,7 +287,7 @@ class FreqtradeBot(LoggingMixin): order = trade.select_order('buy', False) if order: logger.info(f"Updating buy-fee on trade {trade} for order {order.order_id}.") - self.update_trade_state(trade, order.order_id) + self.update_trade_state(trade, order.order_id, send_msg=False) def handle_insufficient_funds(self, trade: Trade): """ @@ -308,7 +309,7 @@ class FreqtradeBot(LoggingMixin): order = trade.select_order('buy', False) if order: logger.info(f"Updating buy-fee on trade {trade} for order {order.order_id}.") - self.update_trade_state(trade, order.order_id) + self.update_trade_state(trade, order.order_id, send_msg=False) def refind_lost_order(self, trade): """ @@ -466,8 +467,8 @@ class FreqtradeBot(LoggingMixin): logger.info(f"Bids to asks delta for {pair} does not satisfy condition.") return False - def execute_entry(self, pair: str, stake_amount: float, price: Optional[float] = None, - forcebuy: bool = False, buy_tag: Optional[str] = None) -> bool: + def execute_entry(self, pair: str, stake_amount: float, price: Optional[float] = None, *, + ordertype: Optional[str] = None, buy_tag: Optional[str] = None) -> bool: """ Executes a limit buy for the given pair :param pair: pair for which we want to create a LIMIT_BUY @@ -510,10 +511,7 @@ class FreqtradeBot(LoggingMixin): f"{stake_amount} ...") amount = stake_amount / enter_limit_requested - order_type = self.strategy.order_types['buy'] - if forcebuy: - # Forcebuy can define a different ordertype - order_type = self.strategy.order_types.get('forcebuy', order_type) + order_type = ordertype or self.strategy.order_types['buy'] if not strategy_safe_wrapper(self.strategy.confirm_trade_entry, default_retval=True)( pair=pair, order_type=order_type, amount=amount, rate=enter_limit_requested, @@ -581,10 +579,6 @@ class FreqtradeBot(LoggingMixin): ) trade.orders.append(order_obj) - # Update fees if order is closed - if order_status == 'closed': - self.update_trade_state(trade, order_id, order) - Trade.query.session.add(trade) Trade.commit() @@ -593,19 +587,25 @@ class FreqtradeBot(LoggingMixin): self._notify_enter(trade, order_type) + # Update fees if order is closed + if order_status == 'closed': + self.update_trade_state(trade, order_id, order) + return True - def _notify_enter(self, trade: Trade, order_type: str) -> None: + def _notify_enter(self, trade: Trade, order_type: Optional[str] = None, + fill: bool = False) -> None: """ Sends rpc notification when a buy occurred. """ msg = { 'trade_id': trade.id, - 'type': RPCMessageType.BUY, + 'type': RPCMessageType.BUY_FILL if fill else RPCMessageType.BUY, 'buy_tag': trade.buy_tag, 'exchange': self.exchange.name.capitalize(), 'pair': trade.pair, - 'limit': trade.open_rate, + 'limit': trade.open_rate, # Deprecated (?) + 'open_rate': trade.open_rate, 'order_type': order_type, 'stake_amount': trade.stake_amount, 'stake_currency': self.config['stake_currency'], @@ -644,22 +644,6 @@ class FreqtradeBot(LoggingMixin): # Send the message self.rpc.send_msg(msg) - def _notify_enter_fill(self, trade: Trade) -> None: - msg = { - 'trade_id': trade.id, - 'type': RPCMessageType.BUY_FILL, - 'buy_tag': trade.buy_tag, - 'exchange': self.exchange.name.capitalize(), - 'pair': trade.pair, - 'open_rate': trade.open_rate, - 'stake_amount': trade.stake_amount, - 'stake_currency': self.config['stake_currency'], - 'fiat_currency': self.config.get('fiat_display_currency', None), - 'amount': trade.amount, - 'open_date': trade.open_date, - } - self.rpc.send_msg(msg) - # # SELL / exit positions / close trades logic and methods # @@ -868,7 +852,7 @@ class FreqtradeBot(LoggingMixin): logger.info( f'Executing Sell for {trade.pair}. Reason: {should_sell.sell_type}. ' f'Tag: {exit_tag if exit_tag is not None else "None"}') - self.execute_trade_exit(trade, exit_rate, should_sell, exit_tag) + self.execute_trade_exit(trade, exit_rate, should_sell, exit_tag=exit_tag) return True return False @@ -1081,7 +1065,10 @@ class FreqtradeBot(LoggingMixin): trade: Trade, limit: float, sell_reason: SellCheckTuple, - exit_tag: Optional[str] = None) -> bool: + *, + exit_tag: Optional[str] = None, + ordertype: Optional[str] = None, + ) -> bool: """ Executes a trade exit for the given trade and limit :param trade: Trade instance @@ -1119,14 +1106,10 @@ class FreqtradeBot(LoggingMixin): except InvalidOrderException: logger.exception(f"Could not cancel stoploss order {trade.stoploss_order_id}") - order_type = self.strategy.order_types[sell_type] + order_type = ordertype or self.strategy.order_types[sell_type] if sell_reason.sell_type == SellType.EMERGENCY_SELL: # Emergency sells (default to market!) order_type = self.strategy.order_types.get("emergencysell", "market") - if sell_reason.sell_type == SellType.FORCE_SELL: - # Force sells (default to the sell_type defined in the strategy, - # but we allow this value to be changed) - order_type = self.strategy.order_types.get("forcesell", order_type) amount = self._safe_exit_amount(trade.pair, trade.amount) time_in_force = self.strategy.order_time_in_force['sell'] @@ -1158,16 +1141,16 @@ class FreqtradeBot(LoggingMixin): trade.sell_order_status = '' trade.close_rate_requested = limit trade.sell_reason = exit_tag or sell_reason.sell_reason - # In case of market sell orders the order can be closed immediately - if order.get('status', 'unknown') in ('closed', 'expired'): - self.update_trade_state(trade, trade.open_order_id, order) - Trade.commit() # Lock pair for one candle to prevent immediate re-buys self.strategy.lock_pair(trade.pair, datetime.now(timezone.utc), reason='Auto lock') self._notify_exit(trade, order_type) + # In case of market sell orders the order can be closed immediately + if order.get('status', 'unknown') in ('closed', 'expired'): + self.update_trade_state(trade, trade.open_order_id, order) + Trade.commit() return True @@ -1264,13 +1247,14 @@ class FreqtradeBot(LoggingMixin): # def update_trade_state(self, trade: Trade, order_id: str, action_order: Dict[str, Any] = None, - stoploss_order: bool = False) -> bool: + stoploss_order: bool = False, send_msg: bool = True) -> bool: """ Checks trades with open orders and updates the amount if necessary Handles closing both buy and sell orders. :param trade: Trade object of the trade we're analyzing :param order_id: Order-id of the order we're analyzing :param action_order: Already acquired order object + :param send_msg: Send notification - should always be True except in "recovery" methods :return: True if order has been cancelled without being filled partially, False otherwise """ if not order_id: @@ -1310,13 +1294,13 @@ class FreqtradeBot(LoggingMixin): # Updating wallets when order is closed if not trade.is_open: - if not stoploss_order and not trade.open_order_id: + if send_msg and not stoploss_order and not trade.open_order_id: self._notify_exit(trade, '', True) self.handle_protections(trade.pair) self.wallets.update() - elif not trade.open_order_id: + elif send_msg and not trade.open_order_id: # Buy fill - self._notify_enter_fill(trade) + self._notify_enter(trade, fill=True) return False diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index 49957c2bb..58607dcd3 100644 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -67,7 +67,7 @@ class Backtesting: self.all_results: Dict[str, Dict] = {} self.exchange = ExchangeResolver.load_exchange(self.config['exchange']['name'], self.config) - self.dataprovider = DataProvider(self.config, None) + self.dataprovider = DataProvider(self.config, self.exchange) if self.config.get('strategy_list', None): for strat in list(self.config['strategy_list']): @@ -89,7 +89,8 @@ class Backtesting: self.init_backtest_detail() self.pairlists = PairListManager(self.exchange, self.config) if 'VolumePairList' in self.pairlists.name_list: - raise OperationalException("VolumePairList not allowed for backtesting.") + raise OperationalException("VolumePairList not allowed for backtesting. " + "Please use StaticPairlist instead.") if 'PerformanceFilter' in self.pairlists.name_list: raise OperationalException("PerformanceFilter not allowed for backtesting.") diff --git a/freqtrade/optimize/optimize_reports.py b/freqtrade/optimize/optimize_reports.py index c4002fcbe..dcd6b4e1f 100644 --- a/freqtrade/optimize/optimize_reports.py +++ b/freqtrade/optimize/optimize_reports.py @@ -46,20 +46,11 @@ def _get_line_floatfmt(stake_currency: str) -> List[str]: '.2f', 'd', 's', 's'] -def _get_line_header(first_column: str, stake_currency: str) -> List[str]: +def _get_line_header(first_column: str, stake_currency: str, direction: str = 'Buys') -> List[str]: """ Generate header lines (goes in line with _generate_result_line()) """ - return [first_column, 'Buys', 'Avg Profit %', 'Cum Profit %', - f'Tot Profit {stake_currency}', 'Tot Profit %', 'Avg Duration', - 'Win Draw Loss Win%'] - - -def _get_line_header_sell(first_column: str, stake_currency: str) -> List[str]: - """ - Generate header lines (goes in line with _generate_result_line()) - """ - return [first_column, 'Sells', 'Avg Profit %', 'Cum Profit %', + return [first_column, direction, 'Avg Profit %', 'Cum Profit %', f'Tot Profit {stake_currency}', 'Tot Profit %', 'Avg Duration', 'Win Draw Loss Win%'] @@ -156,7 +147,7 @@ def generate_tag_metrics(tag_type: str, if skip_nan and result['profit_abs'].isnull().all(): continue - tabular_data.append(_generate_tag_result_line(result, starting_balance, tag)) + tabular_data.append(_generate_result_line(result, starting_balance, tag)) # Sort by total profit %: tabular_data = sorted(tabular_data, key=lambda k: k['profit_total_abs'], reverse=True) @@ -168,39 +159,6 @@ def generate_tag_metrics(tag_type: str, return [] -def _generate_tag_result_line(result: DataFrame, starting_balance: int, first_column: str) -> Dict: - """ - Generate one result dict, with "first_column" as key. - """ - profit_sum = result['profit_ratio'].sum() - # (end-capital - starting capital) / starting capital - profit_total = result['profit_abs'].sum() / starting_balance - - return { - 'key': first_column, - 'trades': len(result), - 'profit_mean': result['profit_ratio'].mean() if len(result) > 0 else 0.0, - 'profit_mean_pct': result['profit_ratio'].mean() * 100.0 if len(result) > 0 else 0.0, - 'profit_sum': profit_sum, - 'profit_sum_pct': round(profit_sum * 100.0, 2), - 'profit_total_abs': result['profit_abs'].sum(), - 'profit_total': profit_total, - 'profit_total_pct': round(profit_total * 100.0, 2), - 'duration_avg': str(timedelta( - minutes=round(result['trade_duration'].mean())) - ) if not result.empty else '0:00', - # 'duration_max': str(timedelta( - # minutes=round(result['trade_duration'].max())) - # ) if not result.empty else '0:00', - # 'duration_min': str(timedelta( - # minutes=round(result['trade_duration'].min())) - # ) if not result.empty else '0:00', - 'wins': len(result[result['profit_abs'] > 0]), - 'draws': len(result[result['profit_abs'] == 0]), - 'losses': len(result[result['profit_abs'] < 0]), - } - - def generate_sell_reason_stats(max_open_trades: int, results: DataFrame) -> List[Dict]: """ Generate small table outlining Backtest results @@ -631,7 +589,7 @@ def text_table_tags(tag_type: str, tag_results: List[Dict[str, Any]], stake_curr if(tag_type == "buy_tag"): headers = _get_line_header("TAG", stake_currency) else: - headers = _get_line_header_sell("TAG", stake_currency) + headers = _get_line_header("TAG", stake_currency, 'Sells') floatfmt = _get_line_floatfmt(stake_currency) output = [ [ diff --git a/freqtrade/plugins/pairlist/ShuffleFilter.py b/freqtrade/plugins/pairlist/ShuffleFilter.py index 4d3dd29e3..55cf9938f 100644 --- a/freqtrade/plugins/pairlist/ShuffleFilter.py +++ b/freqtrade/plugins/pairlist/ShuffleFilter.py @@ -5,6 +5,7 @@ import logging import random from typing import Any, Dict, List +from freqtrade.enums.runmode import RunMode from freqtrade.plugins.pairlist.IPairList import IPairList @@ -18,7 +19,15 @@ class ShuffleFilter(IPairList): pairlist_pos: int) -> None: super().__init__(exchange, pairlistmanager, config, pairlistconfig, pairlist_pos) - self._seed = pairlistconfig.get('seed') + # Apply seed in backtesting mode to get comparable results, + # but not in live modes to get a non-repeating order of pairs during live modes. + if config.get('runmode') in (RunMode.LIVE, RunMode.DRY_RUN): + self._seed = None + logger.info("Live mode detected, not applying seed.") + else: + self._seed = pairlistconfig.get('seed') + logger.info(f"Backtesting mode detected, applying seed value: {self._seed}") + self._random = random.Random(self._seed) @property diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index c9ff0ddaf..f9389d810 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -4,6 +4,7 @@ from typing import Any, Dict, List, Optional, Union from pydantic import BaseModel from freqtrade.constants import DATETIME_PRINT_FORMAT +from freqtrade.enums import OrderTypeValues class Ping(BaseModel): @@ -125,25 +126,26 @@ class Daily(BaseModel): class UnfilledTimeout(BaseModel): - buy: int - sell: int - unit: str + buy: Optional[int] + sell: Optional[int] + unit: Optional[str] exit_timeout_count: Optional[int] class OrderTypes(BaseModel): - buy: str - sell: str - emergencysell: Optional[str] - forcesell: Optional[str] - forcebuy: Optional[str] - stoploss: str + buy: OrderTypeValues + sell: OrderTypeValues + emergencysell: Optional[OrderTypeValues] + forcesell: Optional[OrderTypeValues] + forcebuy: Optional[OrderTypeValues] + stoploss: OrderTypeValues stoploss_on_exchange: bool stoploss_on_exchange_interval: Optional[int] class ShowConfig(BaseModel): version: str + api_version: float dry_run: bool stake_currency: str stake_amount: Union[float, str] @@ -273,10 +275,12 @@ class Logs(BaseModel): class ForceBuyPayload(BaseModel): pair: str price: Optional[float] + ordertype: Optional[OrderTypeValues] class ForceSellPayload(BaseModel): tradeid: str + ordertype: Optional[OrderTypeValues] class BlacklistPayload(BaseModel): diff --git a/freqtrade/rpc/api_server/api_v1.py b/freqtrade/rpc/api_server/api_v1.py index 06230a7db..65b6941e2 100644 --- a/freqtrade/rpc/api_server/api_v1.py +++ b/freqtrade/rpc/api_server/api_v1.py @@ -26,6 +26,12 @@ from freqtrade.rpc.rpc import RPCException logger = logging.getLogger(__name__) +# API version +# Pre-1.1, no version was provided +# Version increments should happen in "small" steps (1.1, 1.12, ...) unless big changes happen. +# 1.11: forcebuy and forcesell accept ordertype +API_VERSION = 1.11 + # Public API, requires no auth. router_public = APIRouter() # Private API, protected by authentication @@ -117,12 +123,15 @@ def show_config(rpc: Optional[RPC] = Depends(get_rpc_optional), config=Depends(g state = '' if rpc: state = rpc._freqtrade.state - return RPC._rpc_show_config(config, state) + resp = RPC._rpc_show_config(config, state) + resp['api_version'] = API_VERSION + return resp @router.post('/forcebuy', response_model=ForceBuyResponse, tags=['trading']) def forcebuy(payload: ForceBuyPayload, rpc: RPC = Depends(get_rpc)): - trade = rpc._rpc_forcebuy(payload.pair, payload.price) + ordertype = payload.ordertype.value if payload.ordertype else None + trade = rpc._rpc_forcebuy(payload.pair, payload.price, ordertype) if trade: return ForceBuyResponse.parse_obj(trade.to_json()) @@ -132,7 +141,8 @@ def forcebuy(payload: ForceBuyPayload, rpc: RPC = Depends(get_rpc)): @router.post('/forcesell', response_model=ResultMsg, tags=['trading']) def forcesell(payload: ForceSellPayload, rpc: RPC = Depends(get_rpc)): - return rpc._rpc_forcesell(payload.tradeid) + ordertype = payload.ordertype.value if payload.ordertype else None + return rpc._rpc_forcesell(payload.tradeid, ordertype) @router.get('/blacklist', response_model=BlacklistResponse, tags=['info', 'pairlist']) diff --git a/freqtrade/rpc/rpc.py b/freqtrade/rpc/rpc.py index 28585e4e8..c21890b7d 100644 --- a/freqtrade/rpc/rpc.py +++ b/freqtrade/rpc/rpc.py @@ -640,7 +640,7 @@ class RPC: return {'status': 'No more buy will occur from now. Run /reload_config to reset.'} - def _rpc_forcesell(self, trade_id: str) -> Dict[str, str]: + def _rpc_forcesell(self, trade_id: str, ordertype: Optional[str] = None) -> Dict[str, str]: """ Handler for forcesell . Sells the given trade at current price @@ -664,7 +664,11 @@ class RPC: current_rate = self._freqtrade.exchange.get_rate( trade.pair, refresh=False, side="sell") sell_reason = SellCheckTuple(sell_type=SellType.FORCE_SELL) - self._freqtrade.execute_trade_exit(trade, current_rate, sell_reason) + order_type = ordertype or self._freqtrade.strategy.order_types.get( + "forcesell", self._freqtrade.strategy.order_types["sell"]) + + self._freqtrade.execute_trade_exit( + trade, current_rate, sell_reason, ordertype=order_type) # ---- EOF def _exec_forcesell ---- if self._freqtrade.state != State.RUNNING: @@ -692,7 +696,8 @@ class RPC: self._freqtrade.wallets.update() return {'result': f'Created sell order for trade {trade_id}.'} - def _rpc_forcebuy(self, pair: str, price: Optional[float]) -> Optional[Trade]: + def _rpc_forcebuy(self, pair: str, price: Optional[float], + order_type: Optional[str] = None) -> Optional[Trade]: """ Handler for forcebuy Buys a pair trade at the given or current price @@ -720,7 +725,10 @@ class RPC: stakeamount = self._freqtrade.wallets.get_trade_stake_amount(pair) # execute buy - if self._freqtrade.execute_entry(pair, stakeamount, price, forcebuy=True): + if not order_type: + order_type = self._freqtrade.strategy.order_types.get( + 'forcebuy', self._freqtrade.strategy.order_types['buy']) + if self._freqtrade.execute_entry(pair, stakeamount, price, ordertype=order_type): Trade.commit() trade = Trade.get_trades([Trade.is_open.is_(True), Trade.pair == pair]).first() return trade diff --git a/freqtrade/rpc/telegram.py b/freqtrade/rpc/telegram.py index e6af85267..3099de5ba 100644 --- a/freqtrade/rpc/telegram.py +++ b/freqtrade/rpc/telegram.py @@ -112,6 +112,7 @@ class Telegram(RPCHandler): r'/stats$', r'/count$', r'/locks$', r'/balance$', r'/stopbuy$', r'/reload_config$', r'/show_config$', r'/logs$', r'/whitelist$', r'/blacklist$', r'/edge$', + r'/weekly$', r'/weekly \d+$', r'/monthly$', r'/monthly \d+$', r'/forcebuy$', r'/help$', r'/version$'] # Create keys for generation valid_keys_print = [k.replace('$', '') for k in valid_keys] @@ -274,11 +275,11 @@ class Telegram(RPCHandler): 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") + f"*Amount:* `{msg['amount']:.8f}`\n" + f"*Open Rate:* `{msg['open_rate']:.8f}`\n") if msg['type'] == RPCMessageType.SELL: - message += (f"*Open Rate:* `{msg['open_rate']:.8f}`\n" - f"*Current Rate:* `{msg['current_rate']:.8f}`\n" + message += (f"*Current Rate:* `{msg['current_rate']:.8f}`\n" f"*Close Rate:* `{msg['limit']:.8f}`") elif msg['type'] == RPCMessageType.SELL_FILL: diff --git a/freqtrade/rpc/webhook.py b/freqtrade/rpc/webhook.py index b4c55649e..58b75769e 100644 --- a/freqtrade/rpc/webhook.py +++ b/freqtrade/rpc/webhook.py @@ -2,6 +2,7 @@ This module manages webhook communication """ import logging +import time from typing import Any, Dict from requests import RequestException, post @@ -28,12 +29,9 @@ class Webhook(RPCHandler): super().__init__(rpc, config) self._url = self._config['webhook']['url'] - self._format = self._config['webhook'].get('format', 'form') - - if self._format != 'form' and self._format != 'json': - raise NotImplementedError('Unknown webhook format `{}`, possible values are ' - '`form` (default) and `json`'.format(self._format)) + self._retries = self._config['webhook'].get('retries', 0) + self._retry_delay = self._config['webhook'].get('retry_delay', 0.1) def cleanup(self) -> None: """ @@ -77,13 +75,30 @@ class Webhook(RPCHandler): def _send_msg(self, payload: dict) -> None: """do the actual call to the webhook""" - try: - if self._format == 'form': - post(self._url, data=payload) - elif self._format == 'json': - post(self._url, json=payload) - else: - raise NotImplementedError('Unknown format: {}'.format(self._format)) + success = False + attempts = 0 + while not success and attempts <= self._retries: + if attempts: + if self._retry_delay: + time.sleep(self._retry_delay) + logger.info("Retrying webhook...") - except RequestException as exc: - logger.warning("Could not call webhook url. Exception: %s", exc) + attempts += 1 + + try: + if self._format == 'form': + response = post(self._url, data=payload) + elif self._format == 'json': + response = post(self._url, json=payload) + elif self._format == 'raw': + response = post(self._url, data=payload['data'], + headers={'Content-Type': 'text/plain'}) + else: + raise NotImplementedError('Unknown format: {}'.format(self._format)) + + # Throw a RequestException if the post was not successful + response.raise_for_status() + success = True + + except RequestException as exc: + logger.warning("Could not call webhook url. Exception: %s", exc) diff --git a/freqtrade/strategy/informative_decorator.py b/freqtrade/strategy/informative_decorator.py index 4c5f21108..722e7a128 100644 --- a/freqtrade/strategy/informative_decorator.py +++ b/freqtrade/strategy/informative_decorator.py @@ -80,12 +80,11 @@ def _create_and_merge_informative_pair(strategy, dataframe: DataFrame, metadata: # Not specifying an asset will define informative dataframe for current pair. asset = metadata['pair'] - if '/' in asset: - base, quote = asset.split('/') - else: - # When futures are supported this may need reevaluation. - # base, quote = asset, '' - raise OperationalException('Not implemented.') + market = strategy.dp.market(asset) + if market is None: + raise OperationalException(f'Market {asset} is not available.') + base = market['base'] + quote = market['quote'] # Default format. This optimizes for the common case: informative pairs using same stake # currency. When quote currency matches stake currency, column name will omit base currency. diff --git a/freqtrade/templates/base_strategy.py.j2 b/freqtrade/templates/base_strategy.py.j2 index 06d7cbc5c..035468d58 100644 --- a/freqtrade/templates/base_strategy.py.j2 +++ b/freqtrade/templates/base_strategy.py.j2 @@ -12,6 +12,7 @@ from freqtrade.strategy import (BooleanParameter, CategoricalParameter, DecimalP # -------------------------------- # Add your lib to import here import talib.abstract as ta +import pandas_ta as pta import freqtrade.vendor.qtpylib.indicators as qtpylib @@ -36,6 +37,9 @@ class {{ strategy }}(IStrategy): # Check the documentation or the Sample strategy to get the latest version. INTERFACE_VERSION = 2 + # Optimal timeframe for the strategy. + timeframe = '5m' + # Minimal ROI designed for the strategy. # This attribute will be overridden if the config file contains "minimal_roi". minimal_roi = { @@ -54,9 +58,6 @@ class {{ strategy }}(IStrategy): # trailing_stop_positive = 0.01 # trailing_stop_positive_offset = 0.0 # Disabled / not configured - # Optimal timeframe for the strategy. - timeframe = '5m' - # Run "populate_indicators()" only for new candle. process_only_new_candles = False @@ -68,6 +69,10 @@ class {{ strategy }}(IStrategy): # Number of candles the strategy requires before producing valid signals startup_candle_count: int = 30 + # Strategy parameters + buy_rsi = IntParameter(10, 40, default=30, space="buy") + sell_rsi = IntParameter(60, 90, default=70, space="sell") + # Optional order type mapping. order_types = { 'buy': 'limit', @@ -82,6 +87,7 @@ class {{ strategy }}(IStrategy): 'sell': 'gtc' } {{ plot_config | indent(4) }} + def informative_pairs(self): """ Define additional, informative pair/interval combinations to be cached from the exchange. diff --git a/freqtrade/templates/strategy_analysis_example.ipynb b/freqtrade/templates/strategy_analysis_example.ipynb index 99720ae6e..3b937d1c5 100644 --- a/freqtrade/templates/strategy_analysis_example.ipynb +++ b/freqtrade/templates/strategy_analysis_example.ipynb @@ -79,7 +79,9 @@ "source": [ "# Load strategy using values set above\n", "from freqtrade.resolvers import StrategyResolver\n", + "from freqtrade.data.dataprovider import DataProvider\n", "strategy = StrategyResolver.load_strategy(config)\n", + "strategy.dp = DataProvider(config, None, None)\n", "\n", "# Generate buy/sell signals using strategy\n", "df = strategy.analyze_ticker(candles, {'pair': pair})\n", diff --git a/freqtrade/templates/subtemplates/buy_trend_full.j2 b/freqtrade/templates/subtemplates/buy_trend_full.j2 index 1a0d326b3..aac8325a7 100644 --- a/freqtrade/templates/subtemplates/buy_trend_full.j2 +++ b/freqtrade/templates/subtemplates/buy_trend_full.j2 @@ -1,3 +1,3 @@ -(qtpylib.crossed_above(dataframe['rsi'], 30)) & # Signal: RSI crosses above 30 +(qtpylib.crossed_above(dataframe['rsi'], self.buy_rsi.value)) & # Signal: RSI crosses above buy_rsi (dataframe['tema'] <= dataframe['bb_middleband']) & # Guard: tema below BB middle (dataframe['tema'] > dataframe['tema'].shift(1)) & # Guard: tema is raising diff --git a/freqtrade/templates/subtemplates/buy_trend_minimal.j2 b/freqtrade/templates/subtemplates/buy_trend_minimal.j2 index 6a4079cf3..e89d3779e 100644 --- a/freqtrade/templates/subtemplates/buy_trend_minimal.j2 +++ b/freqtrade/templates/subtemplates/buy_trend_minimal.j2 @@ -1 +1 @@ -(qtpylib.crossed_above(dataframe['rsi'], 30)) & # Signal: RSI crosses above 30 +(qtpylib.crossed_above(dataframe['rsi'], self.buy_rsi.value)) & # Signal: RSI crosses above buy_rsi diff --git a/freqtrade/templates/subtemplates/plot_config_full.j2 b/freqtrade/templates/subtemplates/plot_config_full.j2 index ab02c7892..e3f9e7ca0 100644 --- a/freqtrade/templates/subtemplates/plot_config_full.j2 +++ b/freqtrade/templates/subtemplates/plot_config_full.j2 @@ -1,18 +1,20 @@ -plot_config = { - # Main plot indicators (Moving averages, ...) - 'main_plot': { - 'tema': {}, - 'sar': {'color': 'white'}, - }, - 'subplots': { - # Subplots - each dict defines one additional plot - "MACD": { - 'macd': {'color': 'blue'}, - 'macdsignal': {'color': 'orange'}, +@property +def plot_config(self): + return { + # Main plot indicators (Moving averages, ...) + 'main_plot': { + 'tema': {}, + 'sar': {'color': 'white'}, }, - "RSI": { - 'rsi': {'color': 'red'}, + 'subplots': { + # Subplots - each dict defines one additional plot + "MACD": { + 'macd': {'color': 'blue'}, + 'macdsignal': {'color': 'orange'}, + }, + "RSI": { + 'rsi': {'color': 'red'}, + } } } -} diff --git a/freqtrade/templates/subtemplates/sell_trend_full.j2 b/freqtrade/templates/subtemplates/sell_trend_full.j2 index 36c08c947..3068d8d57 100644 --- a/freqtrade/templates/subtemplates/sell_trend_full.j2 +++ b/freqtrade/templates/subtemplates/sell_trend_full.j2 @@ -1,3 +1,3 @@ -(qtpylib.crossed_above(dataframe['rsi'], 70)) & # Signal: RSI crosses above 70 +(qtpylib.crossed_above(dataframe['rsi'], self.sell_rsi.value)) & # Signal: RSI crosses above sell_rsi (dataframe['tema'] > dataframe['bb_middleband']) & # Guard: tema above BB middle (dataframe['tema'] < dataframe['tema'].shift(1)) & # Guard: tema is falling diff --git a/freqtrade/templates/subtemplates/sell_trend_minimal.j2 b/freqtrade/templates/subtemplates/sell_trend_minimal.j2 index 42a7b81a2..5dabc5910 100644 --- a/freqtrade/templates/subtemplates/sell_trend_minimal.j2 +++ b/freqtrade/templates/subtemplates/sell_trend_minimal.j2 @@ -1 +1 @@ -(qtpylib.crossed_above(dataframe['rsi'], 70)) & # Signal: RSI crosses above 70 +(qtpylib.crossed_above(dataframe['rsi'], self.sell_rsi.value)) & # Signal: RSI crosses above sell_rsi diff --git a/mkdocs.yml b/mkdocs.yml index 0daf462c2..fb1b80ebf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -11,8 +11,9 @@ nav: - Freqtrade Basics: bot-basics.md - Configuration: configuration.md - Strategy Customization: strategy-customization.md - - Plugins: plugins.md + - Strategy Callbacks: strategy-callbacks.md - Stoploss: stoploss.md + - Plugins: plugins.md - Start the bot: bot-usage.md - Control the bot: - Telegram: telegram-usage.md @@ -80,8 +81,10 @@ markdown_extensions: - pymdownx.snippets: base_path: docs check_paths: true - - pymdownx.tabbed - pymdownx.superfences + - pymdownx.tabbed: + alternate_style: true - pymdownx.tasklist: custom_checkbox: true + - pymdownx.tilde - mdx_truly_sane_lists diff --git a/requirements-dev.txt b/requirements-dev.txt index ab06468b9..055a2a35d 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -14,16 +14,16 @@ pytest-mock==3.6.1 pytest-random-order==1.0.4 isort==5.10.1 # For datetime mocking -time-machine==2.4.0 +time-machine==2.4.1 # Convert jupyter notebooks to markdown documents nbconvert==6.3.0 # mypy types -types-cachetools==4.2.4 +types-cachetools==4.2.6 types-filelock==3.2.1 -types-requests==2.26.0 +types-requests==2.26.1 types-tabulate==0.8.3 # Extensions to datetime library -types-python-dateutil==2.8.2 \ No newline at end of file +types-python-dateutil==2.8.3 \ No newline at end of file diff --git a/requirements-hyperopt.txt b/requirements-hyperopt.txt index 7efbb47cd..05ea21703 100644 --- a/requirements-hyperopt.txt +++ b/requirements-hyperopt.txt @@ -2,10 +2,10 @@ -r requirements.txt # Required for hyperopt -scipy==1.7.2 +scipy==1.7.3 scikit-learn==1.0.1 scikit-optimize==0.9.0 -filelock==3.3.2 +filelock==3.4.0 joblib==1.1.0 psutil==5.8.0 progressbar2==3.55.0 diff --git a/requirements-plot.txt b/requirements-plot.txt index 8e17232b0..488ef73d6 100644 --- a/requirements-plot.txt +++ b/requirements-plot.txt @@ -1,5 +1,5 @@ # Include all requirements to run the bot. -r requirements.txt -plotly==5.3.1 +plotly==5.4.0 diff --git a/requirements.txt b/requirements.txt index d715b8f52..ccc3f5397 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,10 +2,10 @@ numpy==1.21.4 pandas==1.3.4 pandas-ta==0.3.14b -ccxt==1.61.24 +ccxt==1.62.42 # Pin cryptography for now due to rust build errors with piwheels -cryptography==35.0.0 -aiohttp==3.7.4.post0 +cryptography==36.0.0 +aiohttp==3.8.1 SQLAlchemy==1.4.27 python-telegram-bot==13.8.1 arrow==1.2.1 @@ -34,13 +34,13 @@ sdnotify==0.3.2 fastapi==0.70.0 uvicorn==0.15.0 pyjwt==2.3.0 -aiofiles==0.7.0 +aiofiles==0.8.0 psutil==5.8.0 # Support for colorized terminal output colorama==0.4.4 # Building config files interactively questionary==1.10.0 -prompt-toolkit==3.0.22 +prompt-toolkit==3.0.23 # Extensions to datetime library -python-dateutil==2.8.2 \ No newline at end of file +python-dateutil==2.8.2 diff --git a/tests/exchange/test_exchange.py b/tests/exchange/test_exchange.py index 12b11ff3d..b33e0cbb7 100644 --- a/tests/exchange/test_exchange.py +++ b/tests/exchange/test_exchange.py @@ -1026,6 +1026,12 @@ def test_create_dry_run_order_limit_fill(default_conf, mocker, side, startprice, assert order_closed['status'] == 'closed' assert order['fee'] + # Empty orderbook test + mocker.patch('freqtrade.exchange.Exchange.fetch_l2_order_book', + return_value={'asks': [], 'bids': []}) + exchange._dry_run_open_orders[order['id']]['status'] = 'open' + order_closed = exchange.fetch_dry_run_order(order['id']) + @pytest.mark.parametrize("side,rate,amount,endprice", [ # spread is 25.263-25.266 @@ -1667,12 +1673,21 @@ def test_refresh_latest_ohlcv(mocker, default_conf, caplog) -> None: assert len(res) == len(pairs) assert exchange._api_async.fetch_ohlcv.call_count == 0 + exchange.required_candle_call_count = 1 assert log_has(f"Using cached candle (OHLCV) data for pair {pairs[0][0]}, " f"timeframe {pairs[0][1]} ...", caplog) res = exchange.refresh_latest_ohlcv([('IOTA/ETH', '5m'), ('XRP/ETH', '5m'), ('XRP/ETH', '1d')], cache=False) assert len(res) == 3 + assert exchange._api_async.fetch_ohlcv.call_count == 3 + + # Test the same again, should NOT return from cache! + exchange._api_async.fetch_ohlcv.reset_mock() + res = exchange.refresh_latest_ohlcv([('IOTA/ETH', '5m'), ('XRP/ETH', '5m'), ('XRP/ETH', '1d')], + cache=False) + assert len(res) == 3 + assert exchange._api_async.fetch_ohlcv.call_count == 3 @pytest.mark.asyncio @@ -1768,7 +1783,7 @@ def test_refresh_latest_ohlcv_inv_result(default_conf, mocker, caplog): assert len(res) == 1 # Test that each is in list at least once as order is not guaranteed assert log_has("Error loading ETH/BTC. Result was [[]].", caplog) - assert log_has("Async code raised an exception: TypeError", caplog) + assert log_has("Async code raised an exception: TypeError()", caplog) def test_get_next_limit_in_list(): diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py index ab7aa74a1..f5e182c1d 100644 --- a/tests/optimize/test_backtesting.py +++ b/tests/optimize/test_backtesting.py @@ -438,7 +438,8 @@ def test_backtesting_no_pair_left(default_conf, mocker, caplog, testdatadir) -> Backtesting(default_conf) default_conf['pairlists'] = [{"method": "VolumePairList", "number_assets": 5}] - with pytest.raises(OperationalException, match='VolumePairList not allowed for backtesting.'): + with pytest.raises(OperationalException, + match=r'VolumePairList not allowed for backtesting\..*StaticPairlist.*'): Backtesting(default_conf) default_conf.update({ @@ -470,7 +471,8 @@ def test_backtesting_pairlist_list(default_conf, mocker, caplog, testdatadir, ti default_conf['timerange'] = '20180101-20180102' default_conf['pairlists'] = [{"method": "VolumePairList", "number_assets": 5}] - with pytest.raises(OperationalException, match='VolumePairList not allowed for backtesting.'): + with pytest.raises(OperationalException, + match=r'VolumePairList not allowed for backtesting\..*StaticPairlist.*'): Backtesting(default_conf) default_conf['pairlists'] = [{"method": "StaticPairList"}, {"method": "PerformanceFilter"}] diff --git a/tests/plugins/test_pairlist.py b/tests/plugins/test_pairlist.py index 6333266aa..ba8e6c3c3 100644 --- a/tests/plugins/test_pairlist.py +++ b/tests/plugins/test_pairlist.py @@ -7,6 +7,7 @@ import pytest import time_machine from freqtrade.constants import AVAILABLE_PAIRLISTS +from freqtrade.enums.runmode import RunMode from freqtrade.exceptions import OperationalException from freqtrade.persistence import Trade from freqtrade.plugins.pairlist.pairlist_helpers import expand_pairlist @@ -657,6 +658,22 @@ def test_PerformanceFilter_error(mocker, whitelist_conf, caplog) -> None: assert log_has("PerformanceFilter is not available in this mode.", caplog) +def test_ShuffleFilter_init(mocker, whitelist_conf, caplog) -> None: + whitelist_conf['pairlists'] = [ + {"method": "StaticPairList"}, + {"method": "ShuffleFilter", "seed": 42} + ] + + exchange = get_patched_exchange(mocker, whitelist_conf) + PairListManager(exchange, whitelist_conf) + assert log_has("Backtesting mode detected, applying seed value: 42", caplog) + caplog.clear() + whitelist_conf['runmode'] = RunMode.DRY_RUN + PairListManager(exchange, whitelist_conf) + assert not log_has("Backtesting mode detected, applying seed value: 42", caplog) + assert log_has("Live mode detected, not applying seed.", caplog) + + @pytest.mark.usefixtures("init_persistence") def test_PerformanceFilter_lookback(mocker, whitelist_conf, fee, caplog) -> None: whitelist_conf['exchange']['pair_whitelist'].append('XRP/BTC') diff --git a/tests/rpc/test_rpc.py b/tests/rpc/test_rpc.py index 2852ada81..b6fe1c691 100644 --- a/tests/rpc/test_rpc.py +++ b/tests/rpc/test_rpc.py @@ -1093,7 +1093,7 @@ def test_rpcforcebuy(mocker, default_conf, ticker, fee, limit_buy_order_open) -> with pytest.raises(RPCException, match=r'position for ETH/BTC already open - id: 1'): rpc._rpc_forcebuy(pair, 0.0001) pair = 'XRP/BTC' - trade = rpc._rpc_forcebuy(pair, 0.0001) + trade = rpc._rpc_forcebuy(pair, 0.0001, order_type='limit') assert isinstance(trade, Trade) assert trade.pair == pair assert trade.open_rate == 0.0001 diff --git a/tests/rpc/test_rpc_apiserver.py b/tests/rpc/test_rpc_apiserver.py index 074e312d9..76372df55 100644 --- a/tests/rpc/test_rpc_apiserver.py +++ b/tests/rpc/test_rpc_apiserver.py @@ -538,6 +538,8 @@ def test_api_show_config(botclient): assert 'ask_strategy' in rc.json() assert 'unfilledtimeout' in rc.json() assert 'version' in rc.json() + assert 'api_version' in rc.json() + assert 1.1 <= rc.json()['api_version'] <= 1.2 def test_api_daily(botclient, mocker, ticker, fee, markets): diff --git a/tests/rpc/test_rpc_telegram.py b/tests/rpc/test_rpc_telegram.py index c1677320e..e7db3ab19 100644 --- a/tests/rpc/test_rpc_telegram.py +++ b/tests/rpc/test_rpc_telegram.py @@ -24,6 +24,7 @@ from freqtrade.freqtradebot import FreqtradeBot from freqtrade.loggers import setup_logging from freqtrade.persistence import PairLocks, Trade from freqtrade.rpc import RPC +from freqtrade.rpc.rpc import RPCException from freqtrade.rpc.telegram import Telegram, authorized_only from tests.conftest import (create_mock_trades, get_patched_freqtradebot, log_has, log_has_re, patch_exchange, patch_get_signal, patch_whitelist) @@ -936,7 +937,7 @@ def test_telegram_forcesell_handle(default_conf, update, ticker, fee, telegram._forcesell(update=update, context=context) assert msg_mock.call_count == 4 - last_msg = msg_mock.call_args_list[-1][0][0] + last_msg = msg_mock.call_args_list[-2][0][0] assert { 'type': RPCMessageType.SELL, 'trade_id': 1, @@ -1000,7 +1001,7 @@ def test_telegram_forcesell_down_handle(default_conf, update, ticker, fee, assert msg_mock.call_count == 4 - last_msg = msg_mock.call_args_list[-1][0][0] + last_msg = msg_mock.call_args_list[-2][0][0] assert { 'type': RPCMessageType.SELL, 'trade_id': 1, @@ -1054,7 +1055,7 @@ def test_forcesell_all_handle(default_conf, update, ticker, fee, mocker) -> None # Called for each trade 2 times assert msg_mock.call_count == 8 - msg = msg_mock.call_args_list[1][0][0] + msg = msg_mock.call_args_list[0][0][0] assert { 'type': RPCMessageType.SELL, 'trade_id': 1, @@ -1186,8 +1187,8 @@ def test_forcebuy_no_pair(default_conf, update, mocker) -> None: assert fbuy_mock.call_count == 1 -def test_performance_handle(default_conf, update, ticker, fee, - limit_buy_order, limit_sell_order, mocker) -> None: +def test_telegram_performance_handle(default_conf, update, ticker, fee, + limit_buy_order, limit_sell_order, mocker) -> None: mocker.patch.multiple( 'freqtrade.exchange.Exchange', @@ -1216,8 +1217,8 @@ def test_performance_handle(default_conf, update, ticker, fee, assert 'ETH/BTC\t0.00006217 BTC (6.20%) (1)' in msg_mock.call_args_list[0][0][0] -def test_buy_tag_performance_handle(default_conf, update, ticker, fee, - limit_buy_order, limit_sell_order, mocker) -> None: +def test_telegram_buy_tag_performance_handle(default_conf, update, ticker, fee, + limit_buy_order, limit_sell_order, mocker) -> None: mocker.patch.multiple( 'freqtrade.exchange.Exchange', fetch_ticker=ticker, @@ -1240,15 +1241,27 @@ def test_buy_tag_performance_handle(default_conf, update, ticker, fee, trade.close_date = datetime.utcnow() trade.is_open = False - - telegram._buy_tag_performance(update=update, context=MagicMock()) + context = MagicMock() + telegram._buy_tag_performance(update=update, context=context) assert msg_mock.call_count == 1 assert 'Buy Tag Performance' in msg_mock.call_args_list[0][0][0] assert 'TESTBUY\t0.00006217 BTC (6.20%) (1)' in msg_mock.call_args_list[0][0][0] + context.args = [trade.pair] + telegram._buy_tag_performance(update=update, context=context) + assert msg_mock.call_count == 2 -def test_sell_reason_performance_handle(default_conf, update, ticker, fee, - limit_buy_order, limit_sell_order, mocker) -> None: + msg_mock.reset_mock() + mocker.patch('freqtrade.rpc.rpc.RPC._rpc_buy_tag_performance', + side_effect=RPCException('Error')) + telegram._buy_tag_performance(update=update, context=MagicMock()) + + assert msg_mock.call_count == 1 + assert "Error" in msg_mock.call_args_list[0][0][0] + + +def test_telegram_sell_reason_performance_handle(default_conf, update, ticker, fee, + limit_buy_order, limit_sell_order, mocker) -> None: mocker.patch.multiple( 'freqtrade.exchange.Exchange', fetch_ticker=ticker, @@ -1271,15 +1284,27 @@ def test_sell_reason_performance_handle(default_conf, update, ticker, fee, trade.close_date = datetime.utcnow() trade.is_open = False - - telegram._sell_reason_performance(update=update, context=MagicMock()) + context = MagicMock() + telegram._sell_reason_performance(update=update, context=context) assert msg_mock.call_count == 1 assert 'Sell Reason Performance' in msg_mock.call_args_list[0][0][0] assert 'TESTSELL\t0.00006217 BTC (6.20%) (1)' in msg_mock.call_args_list[0][0][0] + context.args = [trade.pair] + + telegram._sell_reason_performance(update=update, context=context) + assert msg_mock.call_count == 2 + + msg_mock.reset_mock() + mocker.patch('freqtrade.rpc.rpc.RPC._rpc_sell_reason_performance', + side_effect=RPCException('Error')) + telegram._sell_reason_performance(update=update, context=MagicMock()) + + assert msg_mock.call_count == 1 + assert "Error" in msg_mock.call_args_list[0][0][0] -def test_mix_tag_performance_handle(default_conf, update, ticker, fee, - limit_buy_order, limit_sell_order, mocker) -> None: +def test_telegram_mix_tag_performance_handle(default_conf, update, ticker, fee, + limit_buy_order, limit_sell_order, mocker) -> None: mocker.patch.multiple( 'freqtrade.exchange.Exchange', fetch_ticker=ticker, @@ -1305,12 +1330,25 @@ def test_mix_tag_performance_handle(default_conf, update, ticker, fee, trade.close_date = datetime.utcnow() trade.is_open = False - telegram._mix_tag_performance(update=update, context=MagicMock()) + context = MagicMock() + telegram._mix_tag_performance(update=update, context=context) assert msg_mock.call_count == 1 assert 'Mix Tag Performance' in msg_mock.call_args_list[0][0][0] assert ('TESTBUY TESTSELL\t0.00006217 BTC (6.20%) (1)' in msg_mock.call_args_list[0][0][0]) + context.args = [trade.pair] + telegram._mix_tag_performance(update=update, context=context) + assert msg_mock.call_count == 2 + + msg_mock.reset_mock() + mocker.patch('freqtrade.rpc.rpc.RPC._rpc_mix_tag_performance', + side_effect=RPCException('Error')) + telegram._mix_tag_performance(update=update, context=MagicMock()) + + assert msg_mock.call_count == 1 + assert "Error" in msg_mock.call_args_list[0][0][0] + def test_count_handle(default_conf, update, ticker, fee, mocker) -> None: mocker.patch.multiple( @@ -1851,6 +1889,7 @@ def test_send_msg_sell_fill_notification(default_conf, mocker) -> None: '*Sell Reason:* `stop_loss`\n' '*Duration:* `1 day, 2:30:00 (1590.0 min)`\n' '*Amount:* `1333.33333333`\n' + '*Open Rate:* `0.00007500`\n' '*Close Rate:* `0.00003201`' ) diff --git a/tests/rpc/test_rpc_webhook.py b/tests/rpc/test_rpc_webhook.py index 04e63a3be..17d1baca9 100644 --- a/tests/rpc/test_rpc_webhook.py +++ b/tests/rpc/test_rpc_webhook.py @@ -292,3 +292,15 @@ def test__send_msg_with_json_format(default_conf, mocker, caplog): webhook._send_msg(msg) assert post.call_args[1] == {'json': msg} + + +def test__send_msg_with_raw_format(default_conf, mocker, caplog): + default_conf["webhook"] = get_webhook_dict() + default_conf["webhook"]["format"] = "raw" + webhook = Webhook(RPC(get_patched_freqtradebot(mocker, default_conf)), default_conf) + msg = {'data': 'Hello'} + post = MagicMock() + mocker.patch("freqtrade.rpc.webhook.post", post) + webhook._send_msg(msg) + + assert post.call_args[1] == {'data': msg['data'], 'headers': {'Content-Type': 'text/plain'}} diff --git a/tests/strategy/strats/informative_decorator_strategy.py b/tests/strategy/strats/informative_decorator_strategy.py index a32ad79e8..17d4df018 100644 --- a/tests/strategy/strats/informative_decorator_strategy.py +++ b/tests/strategy/strats/informative_decorator_strategy.py @@ -20,7 +20,7 @@ class InformativeDecoratorTest(IStrategy): startup_candle_count: int = 20 def informative_pairs(self): - return [('BTC/USDT', '5m')] + return [('NEO/USDT', '5m')] def populate_buy_trend(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe['buy'] = 0 @@ -38,8 +38,8 @@ class InformativeDecoratorTest(IStrategy): return dataframe # Simple informative test. - @informative('1h', 'BTC/{stake}') - def populate_indicators_btc_1h(self, dataframe: DataFrame, metadata: dict) -> DataFrame: + @informative('1h', 'NEO/{stake}') + def populate_indicators_neo_1h(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe['rsi'] = 14 return dataframe @@ -50,7 +50,7 @@ class InformativeDecoratorTest(IStrategy): return dataframe # Formatting test. - @informative('30m', 'BTC/{stake}', '{column}_{BASE}_{QUOTE}_{base}_{quote}_{asset}_{timeframe}') + @informative('30m', 'NEO/{stake}', '{column}_{BASE}_{QUOTE}_{base}_{quote}_{asset}_{timeframe}') def populate_indicators_btc_1h_2(self, dataframe: DataFrame, metadata: dict) -> DataFrame: dataframe['rsi'] = 14 return dataframe @@ -68,7 +68,7 @@ class InformativeDecoratorTest(IStrategy): dataframe['rsi_less'] = dataframe['rsi'] < dataframe['rsi_1h'] # Mixing manual informative pairs with decorators. - informative = self.dp.get_pair_dataframe('BTC/USDT', '5m') + informative = self.dp.get_pair_dataframe('NEO/USDT', '5m') informative['rsi'] = 14 dataframe = merge_informative_pair(dataframe, informative, self.timeframe, '5m', ffill=True) diff --git a/tests/strategy/test_strategy_helpers.py b/tests/strategy/test_strategy_helpers.py index cb7cf97a1..9e546869a 100644 --- a/tests/strategy/test_strategy_helpers.py +++ b/tests/strategy/test_strategy_helpers.py @@ -7,6 +7,7 @@ import pytest from freqtrade.data.dataprovider import DataProvider from freqtrade.strategy import (merge_informative_pair, stoploss_from_absolute, stoploss_from_open, timeframe_to_minutes) +from tests.conftest import get_patched_exchange def generate_test_data(timeframe: str, size: int, start: str = '2020-07-05'): @@ -155,9 +156,9 @@ def test_informative_decorator(mocker, default_conf): ('LTC/USDT', '5m'): test_data_5m, ('LTC/USDT', '30m'): test_data_30m, ('LTC/USDT', '1h'): test_data_1h, - ('BTC/USDT', '30m'): test_data_30m, - ('BTC/USDT', '5m'): test_data_5m, - ('BTC/USDT', '1h'): test_data_1h, + ('NEO/USDT', '30m'): test_data_30m, + ('NEO/USDT', '5m'): test_data_5m, + ('NEO/USDT', '1h'): test_data_1h, ('ETH/USDT', '1h'): test_data_1h, ('ETH/USDT', '30m'): test_data_30m, ('ETH/BTC', '1h'): test_data_1h, @@ -165,15 +166,16 @@ def test_informative_decorator(mocker, default_conf): from .strats.informative_decorator_strategy import InformativeDecoratorTest default_conf['stake_currency'] = 'USDT' strategy = InformativeDecoratorTest(config=default_conf) - strategy.dp = DataProvider({}, None, None) + exchange = get_patched_exchange(mocker, default_conf) + strategy.dp = DataProvider({}, exchange, None) mocker.patch.object(strategy.dp, 'current_whitelist', return_value=[ - 'XRP/USDT', 'LTC/USDT', 'BTC/USDT' + 'XRP/USDT', 'LTC/USDT', 'NEO/USDT' ]) assert len(strategy._ft_informative) == 6 # Equal to number of decorators used informative_pairs = [('XRP/USDT', '1h'), ('LTC/USDT', '1h'), ('XRP/USDT', '30m'), - ('LTC/USDT', '30m'), ('BTC/USDT', '1h'), ('BTC/USDT', '30m'), - ('BTC/USDT', '5m'), ('ETH/BTC', '1h'), ('ETH/USDT', '30m')] + ('LTC/USDT', '30m'), ('NEO/USDT', '1h'), ('NEO/USDT', '30m'), + ('NEO/USDT', '5m'), ('ETH/BTC', '1h'), ('ETH/USDT', '30m')] for inf_pair in informative_pairs: assert inf_pair in strategy.gather_informative_pairs() @@ -186,8 +188,8 @@ def test_informative_decorator(mocker, default_conf): {p: data[(p, strategy.timeframe)] for p in ('XRP/USDT', 'LTC/USDT')}) expected_columns = [ 'rsi_1h', 'rsi_30m', # Stacked informative decorators - 'btc_usdt_rsi_1h', # BTC 1h informative - 'rsi_BTC_USDT_btc_usdt_BTC/USDT_30m', # Column formatting + 'neo_usdt_rsi_1h', # NEO 1h informative + 'rsi_NEO_USDT_neo_usdt_NEO/USDT_30m', # Column formatting 'rsi_from_callable', # Custom column formatter 'eth_btc_rsi_1h', # Quote currency not matching stake currency 'rsi', 'rsi_less', # Non-informative columns diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index e5dae5461..dd1fcd6e2 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -2979,7 +2979,7 @@ def test_execute_trade_exit_market_order(default_conf_usdt, ticker_usdt, fee, assert trade.close_profit == 0.09451372 assert rpc_mock.call_count == 3 - last_msg = rpc_mock.call_args_list[-1][0][0] + last_msg = rpc_mock.call_args_list[-2][0][0] assert { 'type': RPCMessageType.SELL, 'trade_id': 1, diff --git a/tests/test_misc.py b/tests/test_misc.py index 221c7b712..de3f368e9 100644 --- a/tests/test_misc.py +++ b/tests/test_misc.py @@ -67,6 +67,9 @@ def test_file_load_json(mocker, testdatadir) -> None: @pytest.mark.parametrize("pair,expected_result", [ ("ETH/BTC", 'ETH_BTC'), + ("ETH/USDT", 'ETH_USDT'), + ("ETH/USDT:USDT", 'ETH_USDT_USDT'), # swap with USDT as settlement currency + ("ETH/USDT:USDT-210625", 'ETH_USDT_USDT_210625'), # expiring futures ("Fabric Token/ETH", 'Fabric_Token_ETH'), ("ETHH20", 'ETHH20'), (".XBTBON2H", '_XBTBON2H'),