Merge pull request #2872 from freqtrade/interface_ordertimeoutcallback
Buy order timeout callback
This commit is contained in:
commit
485e324d36
91
docs/strategy-advanced.md
Normal file
91
docs/strategy-advanced.md
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
# Advanced Strategies
|
||||||
|
|
||||||
|
This page explains some advanced concepts available for strategies.
|
||||||
|
If you're just getting started, please be familiar with the methods described in the [Strategy Customization](strategy-customization.md) documentation first.
|
||||||
|
|
||||||
|
## Custom order timeout rules
|
||||||
|
|
||||||
|
Simple, timebased order-timeouts can be configured either via strategy or in the configuration in the `unfilledtimeout` section.
|
||||||
|
|
||||||
|
However, freqtrade also offers a custom callback for both ordertypes, which allows you to decide based on custom criteria if a order did time out or not.
|
||||||
|
|
||||||
|
!!! 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, timestamp
|
||||||
|
from freqtrade.persistence import Trade
|
||||||
|
|
||||||
|
class Awesomestrategy(IStrategy):
|
||||||
|
|
||||||
|
# ... populate_* methods
|
||||||
|
|
||||||
|
# Set unfilledtimeout to 25 hours, since our 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 < datetime.utcnow() - timedelta(minutes=5):
|
||||||
|
return True
|
||||||
|
elif trade.open_rate > 10 and trade.open_date < datetime.utcnow() - timedelta(minutes=3):
|
||||||
|
return True
|
||||||
|
elif trade.open_rate < 1 and trade.open_date < datetime.utcnow() - 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 < datetime.utcnow() - timedelta(minutes=5):
|
||||||
|
return True
|
||||||
|
elif trade.open_rate > 10 and trade.open_date < datetime.utcnow() - timedelta(minutes=3):
|
||||||
|
return True
|
||||||
|
elif trade.open_rate < 1 and trade.open_date < datetime.utcnow() - 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, timestamp
|
||||||
|
from freqtrade.persistence import Trade
|
||||||
|
|
||||||
|
class Awesomestrategy(IStrategy):
|
||||||
|
|
||||||
|
# ... populate_* methods
|
||||||
|
|
||||||
|
# Set unfilledtimeout to 25 hours, since our 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
|
||||||
|
```
|
@ -1,7 +1,6 @@
|
|||||||
# Strategy Customization
|
# Strategy Customization
|
||||||
|
|
||||||
This page explains where to customize your strategies, and add new
|
This page explains where to customize your strategies, and add new indicators.
|
||||||
indicators.
|
|
||||||
|
|
||||||
## Install a custom strategy file
|
## Install a custom strategy file
|
||||||
|
|
||||||
|
@ -77,7 +77,7 @@ Results will be located in `user_data/strategies/<strategyclassname>.py`.
|
|||||||
|
|
||||||
``` output
|
``` output
|
||||||
usage: freqtrade new-strategy [-h] [--userdir PATH] [-s NAME]
|
usage: freqtrade new-strategy [-h] [--userdir PATH] [-s NAME]
|
||||||
[--template {full,minimal}]
|
[--template {full,minimal,advanced}]
|
||||||
|
|
||||||
optional arguments:
|
optional arguments:
|
||||||
-h, --help show this help message and exit
|
-h, --help show this help message and exit
|
||||||
@ -86,10 +86,10 @@ optional arguments:
|
|||||||
-s NAME, --strategy NAME
|
-s NAME, --strategy NAME
|
||||||
Specify strategy class name which will be used by the
|
Specify strategy class name which will be used by the
|
||||||
bot.
|
bot.
|
||||||
--template {full,minimal}
|
--template {full,minimal,advanced}
|
||||||
Use a template which is either `minimal` or `full`
|
Use a template which is either `minimal`, `full`
|
||||||
(containing multiple sample indicators). Default:
|
(containing multiple sample indicators) or `advanced`.
|
||||||
`full`.
|
Default: `full`.
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -105,6 +105,12 @@ With custom user directory
|
|||||||
freqtrade new-strategy --userdir ~/.freqtrade/ --strategy AwesomeStrategy
|
freqtrade new-strategy --userdir ~/.freqtrade/ --strategy AwesomeStrategy
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Using the advanced template (populates all optional functions and methods)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
freqtrade new-strategy --strategy AwesomeStrategy --template advanced
|
||||||
|
```
|
||||||
|
|
||||||
## Create new hyperopt
|
## Create new hyperopt
|
||||||
|
|
||||||
Creates a new hyperopt from a template similar to SampleHyperopt.
|
Creates a new hyperopt from a template similar to SampleHyperopt.
|
||||||
@ -114,7 +120,7 @@ Results will be located in `user_data/hyperopts/<classname>.py`.
|
|||||||
|
|
||||||
``` output
|
``` output
|
||||||
usage: freqtrade new-hyperopt [-h] [--userdir PATH] [--hyperopt NAME]
|
usage: freqtrade new-hyperopt [-h] [--userdir PATH] [--hyperopt NAME]
|
||||||
[--template {full,minimal}]
|
[--template {full,minimal,advanced}]
|
||||||
|
|
||||||
optional arguments:
|
optional arguments:
|
||||||
-h, --help show this help message and exit
|
-h, --help show this help message and exit
|
||||||
@ -122,10 +128,10 @@ optional arguments:
|
|||||||
Path to userdata directory.
|
Path to userdata directory.
|
||||||
--hyperopt NAME Specify hyperopt class name which will be used by the
|
--hyperopt NAME Specify hyperopt class name which will be used by the
|
||||||
bot.
|
bot.
|
||||||
--template {full,minimal}
|
--template {full,minimal,advanced}
|
||||||
Use a template which is either `minimal` or `full`
|
Use a template which is either `minimal`, `full`
|
||||||
(containing multiple sample indicators). Default:
|
(containing multiple sample indicators) or `advanced`.
|
||||||
`full`.
|
Default: `full`.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Sample usage of new-hyperopt
|
### Sample usage of new-hyperopt
|
||||||
|
@ -387,9 +387,9 @@ AVAILABLE_CLI_OPTIONS = {
|
|||||||
# Templating options
|
# Templating options
|
||||||
"template": Arg(
|
"template": Arg(
|
||||||
'--template',
|
'--template',
|
||||||
help='Use a template which is either `minimal` or '
|
help='Use a template which is either `minimal`, '
|
||||||
'`full` (containing multiple sample indicators). Default: `%(default)s`.',
|
'`full` (containing multiple sample indicators) or `advanced`. Default: `%(default)s`.',
|
||||||
choices=['full', 'minimal'],
|
choices=['full', 'minimal', 'advanced'],
|
||||||
default='full',
|
default='full',
|
||||||
),
|
),
|
||||||
# Plot dataframe
|
# Plot dataframe
|
||||||
|
@ -8,7 +8,7 @@ from freqtrade.configuration.directory_operations import (copy_sample_files,
|
|||||||
create_userdata_dir)
|
create_userdata_dir)
|
||||||
from freqtrade.constants import USERPATH_HYPEROPTS, USERPATH_STRATEGIES
|
from freqtrade.constants import USERPATH_HYPEROPTS, USERPATH_STRATEGIES
|
||||||
from freqtrade.exceptions import OperationalException
|
from freqtrade.exceptions import OperationalException
|
||||||
from freqtrade.misc import render_template
|
from freqtrade.misc import render_template, render_template_with_fallback
|
||||||
from freqtrade.state import RunMode
|
from freqtrade.state import RunMode
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -32,10 +32,27 @@ def deploy_new_strategy(strategy_name: str, strategy_path: Path, subtemplate: st
|
|||||||
"""
|
"""
|
||||||
Deploy new strategy from template to strategy_path
|
Deploy new strategy from template to strategy_path
|
||||||
"""
|
"""
|
||||||
indicators = render_template(templatefile=f"subtemplates/indicators_{subtemplate}.j2",)
|
fallback = 'full'
|
||||||
buy_trend = render_template(templatefile=f"subtemplates/buy_trend_{subtemplate}.j2",)
|
indicators = render_template_with_fallback(
|
||||||
sell_trend = render_template(templatefile=f"subtemplates/sell_trend_{subtemplate}.j2",)
|
templatefile=f"subtemplates/indicators_{subtemplate}.j2",
|
||||||
plot_config = render_template(templatefile=f"subtemplates/plot_config_{subtemplate}.j2",)
|
templatefallbackfile=f"subtemplates/indicators_{fallback}.j2",
|
||||||
|
)
|
||||||
|
buy_trend = render_template_with_fallback(
|
||||||
|
templatefile=f"subtemplates/buy_trend_{subtemplate}.j2",
|
||||||
|
templatefallbackfile=f"subtemplates/buy_trend_{fallback}.j2",
|
||||||
|
)
|
||||||
|
sell_trend = render_template_with_fallback(
|
||||||
|
templatefile=f"subtemplates/sell_trend_{subtemplate}.j2",
|
||||||
|
templatefallbackfile=f"subtemplates/sell_trend_{fallback}.j2",
|
||||||
|
)
|
||||||
|
plot_config = render_template_with_fallback(
|
||||||
|
templatefile=f"subtemplates/plot_config_{subtemplate}.j2",
|
||||||
|
templatefallbackfile=f"subtemplates/plot_config_{fallback}.j2",
|
||||||
|
)
|
||||||
|
additional_methods = render_template_with_fallback(
|
||||||
|
templatefile=f"subtemplates/strategy_methods_{subtemplate}.j2",
|
||||||
|
templatefallbackfile=f"subtemplates/strategy_methods_empty.j2",
|
||||||
|
)
|
||||||
|
|
||||||
strategy_text = render_template(templatefile='base_strategy.py.j2',
|
strategy_text = render_template(templatefile='base_strategy.py.j2',
|
||||||
arguments={"strategy": strategy_name,
|
arguments={"strategy": strategy_name,
|
||||||
@ -43,6 +60,7 @@ def deploy_new_strategy(strategy_name: str, strategy_path: Path, subtemplate: st
|
|||||||
"buy_trend": buy_trend,
|
"buy_trend": buy_trend,
|
||||||
"sell_trend": sell_trend,
|
"sell_trend": sell_trend,
|
||||||
"plot_config": plot_config,
|
"plot_config": plot_config,
|
||||||
|
"additional_methods": additional_methods,
|
||||||
})
|
})
|
||||||
|
|
||||||
logger.info(f"Writing strategy to `{strategy_path}`.")
|
logger.info(f"Writing strategy to `{strategy_path}`.")
|
||||||
@ -73,14 +91,23 @@ def deploy_new_hyperopt(hyperopt_name: str, hyperopt_path: Path, subtemplate: st
|
|||||||
"""
|
"""
|
||||||
Deploys a new hyperopt template to hyperopt_path
|
Deploys a new hyperopt template to hyperopt_path
|
||||||
"""
|
"""
|
||||||
buy_guards = render_template(
|
fallback = 'full'
|
||||||
templatefile=f"subtemplates/hyperopt_buy_guards_{subtemplate}.j2",)
|
buy_guards = render_template_with_fallback(
|
||||||
sell_guards = render_template(
|
templatefile=f"subtemplates/hyperopt_buy_guards_{subtemplate}.j2",
|
||||||
templatefile=f"subtemplates/hyperopt_sell_guards_{subtemplate}.j2",)
|
templatefallbackfile=f"subtemplates/hyperopt_buy_guards_{fallback}.j2",
|
||||||
buy_space = render_template(
|
)
|
||||||
templatefile=f"subtemplates/hyperopt_buy_space_{subtemplate}.j2",)
|
sell_guards = render_template_with_fallback(
|
||||||
sell_space = render_template(
|
templatefile=f"subtemplates/hyperopt_sell_guards_{subtemplate}.j2",
|
||||||
templatefile=f"subtemplates/hyperopt_sell_space_{subtemplate}.j2",)
|
templatefallbackfile=f"subtemplates/hyperopt_sell_guards_{fallback}.j2",
|
||||||
|
)
|
||||||
|
buy_space = render_template_with_fallback(
|
||||||
|
templatefile=f"subtemplates/hyperopt_buy_space_{subtemplate}.j2",
|
||||||
|
templatefallbackfile=f"subtemplates/hyperopt_buy_space_{fallback}.j2",
|
||||||
|
)
|
||||||
|
sell_space = render_template_with_fallback(
|
||||||
|
templatefile=f"subtemplates/hyperopt_sell_space_{subtemplate}.j2",
|
||||||
|
templatefallbackfile=f"subtemplates/hyperopt_sell_space_{fallback}.j2",
|
||||||
|
)
|
||||||
|
|
||||||
strategy_text = render_template(templatefile='base_hyperopt.py.j2',
|
strategy_text = render_template(templatefile='base_hyperopt.py.j2',
|
||||||
arguments={"hyperopt": hyperopt_name,
|
arguments={"hyperopt": hyperopt_name,
|
||||||
|
@ -35,3 +35,10 @@ class TemporaryError(FreqtradeException):
|
|||||||
This could happen when an exchange is congested, unavailable, or the user
|
This could happen when an exchange is congested, unavailable, or the user
|
||||||
has networking problems. Usually resolves itself after a time.
|
has networking problems. Usually resolves itself after a time.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class StrategyError(FreqtradeException):
|
||||||
|
"""
|
||||||
|
Errors with custom user-code deteced.
|
||||||
|
Usually caused by errors in the strategy.
|
||||||
|
"""
|
||||||
|
@ -27,6 +27,7 @@ from freqtrade.resolvers import ExchangeResolver, StrategyResolver
|
|||||||
from freqtrade.rpc import RPCManager, RPCMessageType
|
from freqtrade.rpc import RPCManager, RPCMessageType
|
||||||
from freqtrade.state import State
|
from freqtrade.state import State
|
||||||
from freqtrade.strategy.interface import IStrategy, SellType
|
from freqtrade.strategy.interface import IStrategy, SellType
|
||||||
|
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
|
||||||
from freqtrade.wallets import Wallets
|
from freqtrade.wallets import Wallets
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -869,7 +870,12 @@ class FreqtradeBot:
|
|||||||
|
|
||||||
if (order['side'] == 'buy' and (
|
if (order['side'] == 'buy' and (
|
||||||
trade_state_update
|
trade_state_update
|
||||||
or self._check_timed_out('buy', order))):
|
or self._check_timed_out('buy', order)
|
||||||
|
or strategy_safe_wrapper(self.strategy.check_buy_timeout,
|
||||||
|
default_retval=False)(pair=trade.pair,
|
||||||
|
trade=trade,
|
||||||
|
order=order))):
|
||||||
|
|
||||||
self.handle_timedout_limit_buy(trade, order)
|
self.handle_timedout_limit_buy(trade, order)
|
||||||
self.wallets.update()
|
self.wallets.update()
|
||||||
order_type = self.strategy.order_types['buy']
|
order_type = self.strategy.order_types['buy']
|
||||||
@ -877,7 +883,11 @@ class FreqtradeBot:
|
|||||||
|
|
||||||
elif (order['side'] == 'sell' and (
|
elif (order['side'] == 'sell' and (
|
||||||
trade_state_update
|
trade_state_update
|
||||||
or self._check_timed_out('sell', order))):
|
or self._check_timed_out('sell', order)
|
||||||
|
or strategy_safe_wrapper(self.strategy.check_sell_timeout,
|
||||||
|
default_retval=False)(pair=trade.pair,
|
||||||
|
trade=trade,
|
||||||
|
order=order))):
|
||||||
reason = self.handle_timedout_limit_sell(trade, order)
|
reason = self.handle_timedout_limit_sell(trade, order)
|
||||||
self.wallets.update()
|
self.wallets.update()
|
||||||
order_type = self.strategy.order_types['sell']
|
order_type = self.strategy.order_types['sell']
|
||||||
|
@ -163,3 +163,15 @@ def render_template(templatefile: str, arguments: dict = {}) -> str:
|
|||||||
)
|
)
|
||||||
template = env.get_template(templatefile)
|
template = env.get_template(templatefile)
|
||||||
return template.render(**arguments)
|
return template.render(**arguments)
|
||||||
|
|
||||||
|
|
||||||
|
def render_template_with_fallback(templatefile: str, templatefallbackfile: str,
|
||||||
|
arguments: dict = {}) -> str:
|
||||||
|
"""
|
||||||
|
Use templatefile if possible, otherwise fall back to templatefallbackfile
|
||||||
|
"""
|
||||||
|
from jinja2.exceptions import TemplateNotFound
|
||||||
|
try:
|
||||||
|
return render_template(templatefile, arguments)
|
||||||
|
except TemplateNotFound:
|
||||||
|
return render_template(templatefallbackfile, arguments)
|
||||||
|
@ -3,21 +3,21 @@ IStrategy interface
|
|||||||
This module defines the interface to apply for strategies
|
This module defines the interface to apply for strategies
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
import warnings
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Dict, List, NamedTuple, Optional, Tuple
|
from typing import Dict, List, NamedTuple, Optional, Tuple
|
||||||
import warnings
|
|
||||||
|
|
||||||
import arrow
|
import arrow
|
||||||
from pandas import DataFrame
|
from pandas import DataFrame
|
||||||
|
|
||||||
from freqtrade.data.dataprovider import DataProvider
|
from freqtrade.data.dataprovider import DataProvider
|
||||||
|
from freqtrade.exceptions import StrategyError
|
||||||
from freqtrade.exchange import timeframe_to_minutes
|
from freqtrade.exchange import timeframe_to_minutes
|
||||||
from freqtrade.persistence import Trade
|
from freqtrade.persistence import Trade
|
||||||
|
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
|
||||||
from freqtrade.wallets import Wallets
|
from freqtrade.wallets import Wallets
|
||||||
from freqtrade.exceptions import DependencyException
|
|
||||||
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -149,6 +149,42 @@ class IStrategy(ABC):
|
|||||||
:return: DataFrame with sell column
|
:return: DataFrame with sell column
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
def check_buy_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool:
|
||||||
|
"""
|
||||||
|
Check buy timeout function callback.
|
||||||
|
This method can be used to override the buy-timeout.
|
||||||
|
It is called whenever a limit buy order has been created,
|
||||||
|
and is not yet fully filled.
|
||||||
|
Configuration options in `unfilledtimeout` will be verified before this,
|
||||||
|
so ensure to set these timeouts high enough.
|
||||||
|
|
||||||
|
When not implemented by a strategy, this simply returns False.
|
||||||
|
:param pair: Pair the trade is for
|
||||||
|
:param trade: trade object.
|
||||||
|
:param order: Order dictionary as returned from CCXT.
|
||||||
|
: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 cancelled.
|
||||||
|
"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_sell_timeout(self, pair: str, trade: Trade, order: dict, **kwargs) -> bool:
|
||||||
|
"""
|
||||||
|
Check sell timeout function callback.
|
||||||
|
This method can be used to override the sell-timeout.
|
||||||
|
It is called whenever a limit sell order has been created,
|
||||||
|
and is not yet fully filled.
|
||||||
|
Configuration options in `unfilledtimeout` will be verified before this,
|
||||||
|
so ensure to set these timeouts high enough.
|
||||||
|
|
||||||
|
When not implemented by a strategy, this simply returns False.
|
||||||
|
:param pair: Pair the trade is for
|
||||||
|
:param trade: trade object.
|
||||||
|
:param order: Order dictionary as returned from CCXT.
|
||||||
|
: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 cancelled.
|
||||||
|
"""
|
||||||
|
return False
|
||||||
|
|
||||||
def informative_pairs(self) -> List[Tuple[str, str]]:
|
def informative_pairs(self) -> List[Tuple[str, str]]:
|
||||||
"""
|
"""
|
||||||
Define additional, informative pair/interval combinations to be cached from the exchange.
|
Define additional, informative pair/interval combinations to be cached from the exchange.
|
||||||
@ -258,8 +294,7 @@ class IStrategy(ABC):
|
|||||||
elif df_date != dataframe["date"].iloc[-1]:
|
elif df_date != dataframe["date"].iloc[-1]:
|
||||||
message = "last date"
|
message = "last date"
|
||||||
if message:
|
if message:
|
||||||
raise DependencyException("Dataframe returned from strategy has mismatching "
|
raise StrategyError(f"Dataframe returned from strategy has mismatching {message}.")
|
||||||
f"{message}.")
|
|
||||||
|
|
||||||
def get_signal(self, pair: str, interval: str, dataframe: DataFrame) -> Tuple[bool, bool]:
|
def get_signal(self, pair: str, interval: str, dataframe: DataFrame) -> Tuple[bool, bool]:
|
||||||
"""
|
"""
|
||||||
@ -276,22 +311,13 @@ class IStrategy(ABC):
|
|||||||
latest_date = dataframe['date'].max()
|
latest_date = dataframe['date'].max()
|
||||||
try:
|
try:
|
||||||
df_len, df_close, df_date = self.preserve_df(dataframe)
|
df_len, df_close, df_date = self.preserve_df(dataframe)
|
||||||
dataframe = self._analyze_ticker_internal(dataframe, {'pair': pair})
|
dataframe = strategy_safe_wrapper(
|
||||||
|
self._analyze_ticker_internal, message=""
|
||||||
|
)(dataframe, {'pair': pair})
|
||||||
self.assert_df(dataframe, df_len, df_close, df_date)
|
self.assert_df(dataframe, df_len, df_close, df_date)
|
||||||
except ValueError as error:
|
except StrategyError as error:
|
||||||
logger.warning('Unable to analyze candle (OHLCV) data for pair %s: %s',
|
logger.warning(f"Unable to analyze candle (OHLCV) data for pair {pair}: {error}")
|
||||||
pair, str(error))
|
|
||||||
return False, False
|
|
||||||
except DependencyException as error:
|
|
||||||
logger.warning("Unable to analyze candle (OHLCV) data for pair %s: %s",
|
|
||||||
pair, str(error))
|
|
||||||
return False, False
|
|
||||||
except Exception as error:
|
|
||||||
logger.exception(
|
|
||||||
'Unexpected error when analyzing candle (OHLCV) data for pair %s: %s',
|
|
||||||
pair,
|
|
||||||
str(error)
|
|
||||||
)
|
|
||||||
return False, False
|
return False, False
|
||||||
|
|
||||||
if dataframe.empty:
|
if dataframe.empty:
|
||||||
|
35
freqtrade/strategy/strategy_wrapper.py
Normal file
35
freqtrade/strategy/strategy_wrapper.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from freqtrade.exceptions import StrategyError
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def strategy_safe_wrapper(f, message: str = "", default_retval=None):
|
||||||
|
"""
|
||||||
|
Wrapper around user-provided methods and functions.
|
||||||
|
Caches all exceptions and returns either the default_retval (if it's not None) or raises
|
||||||
|
a StrategyError exception, which then needs to be handled by the calling method.
|
||||||
|
"""
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
try:
|
||||||
|
return f(*args, **kwargs)
|
||||||
|
except ValueError as error:
|
||||||
|
logger.warning(
|
||||||
|
f"{message}"
|
||||||
|
f"Strategy caused the following exception: {error}"
|
||||||
|
f"{f}"
|
||||||
|
)
|
||||||
|
if default_retval is None:
|
||||||
|
raise StrategyError(str(error)) from error
|
||||||
|
return default_retval
|
||||||
|
except Exception as error:
|
||||||
|
logger.exception(
|
||||||
|
f"{message}"
|
||||||
|
f"Unexpected error {error} calling {f}"
|
||||||
|
)
|
||||||
|
if default_retval is None:
|
||||||
|
raise StrategyError(str(error)) from error
|
||||||
|
return default_retval
|
||||||
|
|
||||||
|
return wrapper
|
@ -137,3 +137,4 @@ class {{ strategy }}(IStrategy):
|
|||||||
),
|
),
|
||||||
'sell'] = 1
|
'sell'] = 1
|
||||||
return dataframe
|
return dataframe
|
||||||
|
{{ additional_methods | indent(4) }}
|
||||||
|
@ -0,0 +1,40 @@
|
|||||||
|
|
||||||
|
def check_buy_timeout(self, pair: str, trade: 'Trade', order: dict, **kwargs) -> bool:
|
||||||
|
"""
|
||||||
|
Check buy timeout function callback.
|
||||||
|
This method can be used to override the buy-timeout.
|
||||||
|
It is called whenever a limit buy order has been created,
|
||||||
|
and is not yet fully filled.
|
||||||
|
Configuration options in `unfilledtimeout` will be verified before this,
|
||||||
|
so ensure to set these timeouts high enough.
|
||||||
|
|
||||||
|
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/
|
||||||
|
|
||||||
|
When not implemented by a strategy, this simply returns False.
|
||||||
|
:param pair: Pair the trade is for
|
||||||
|
:param trade: trade object.
|
||||||
|
:param order: Order dictionary as returned from CCXT.
|
||||||
|
: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 cancelled.
|
||||||
|
"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
def check_sell_timeout(self, pair: str, trade: 'Trade', order: dict, **kwargs) -> bool:
|
||||||
|
"""
|
||||||
|
Check sell timeout function callback.
|
||||||
|
This method can be used to override the sell-timeout.
|
||||||
|
It is called whenever a limit sell order has been created,
|
||||||
|
and is not yet fully filled.
|
||||||
|
Configuration options in `unfilledtimeout` will be verified before this,
|
||||||
|
so ensure to set these timeouts high enough.
|
||||||
|
|
||||||
|
For full documentation please go to https://www.freqtrade.io/en/latest/strategy-advanced/
|
||||||
|
|
||||||
|
When not implemented by a strategy, this simply returns False.
|
||||||
|
:param pair: Pair the trade is for
|
||||||
|
:param trade: trade object.
|
||||||
|
:param order: Order dictionary as returned from CCXT.
|
||||||
|
: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 cancelled.
|
||||||
|
"""
|
||||||
|
return False
|
@ -24,6 +24,7 @@ nav:
|
|||||||
- Plotting: plotting.md
|
- Plotting: plotting.md
|
||||||
- SQL Cheatsheet: sql_cheatsheet.md
|
- SQL Cheatsheet: sql_cheatsheet.md
|
||||||
- Advanced Post-installation Tasks: advanced-setup.md
|
- Advanced Post-installation Tasks: advanced-setup.md
|
||||||
|
- Advanced Strategy: strategy-advanced.md
|
||||||
- Advanced Hyperopt: advanced-hyperopt.md
|
- Advanced Hyperopt: advanced-hyperopt.md
|
||||||
- Sandbox Testing: sandbox-testing.md
|
- Sandbox Testing: sandbox-testing.md
|
||||||
- Deprecated Features: deprecated.md
|
- Deprecated Features: deprecated.md
|
||||||
|
@ -9,10 +9,11 @@ from pandas import DataFrame
|
|||||||
|
|
||||||
from freqtrade.configuration import TimeRange
|
from freqtrade.configuration import TimeRange
|
||||||
from freqtrade.data.history import load_data
|
from freqtrade.data.history import load_data
|
||||||
from freqtrade.exceptions import DependencyException
|
from freqtrade.exceptions import StrategyError
|
||||||
from freqtrade.persistence import Trade
|
from freqtrade.persistence import Trade
|
||||||
from freqtrade.resolvers import StrategyResolver
|
from freqtrade.resolvers import StrategyResolver
|
||||||
from tests.conftest import get_patched_exchange, log_has
|
from freqtrade.strategy.strategy_wrapper import strategy_safe_wrapper
|
||||||
|
from tests.conftest import get_patched_exchange, log_has, log_has_re
|
||||||
|
|
||||||
from .strats.default_strategy import DefaultStrategy
|
from .strats.default_strategy import DefaultStrategy
|
||||||
|
|
||||||
@ -71,7 +72,7 @@ def test_get_signal_exception_valueerror(default_conf, mocker, caplog, ohlcv_his
|
|||||||
)
|
)
|
||||||
assert (False, False) == _STRATEGY.get_signal('foo', default_conf['ticker_interval'],
|
assert (False, False) == _STRATEGY.get_signal('foo', default_conf['ticker_interval'],
|
||||||
ohlcv_history)
|
ohlcv_history)
|
||||||
assert log_has('Unable to analyze candle (OHLCV) data for pair foo: xyz', caplog)
|
assert log_has_re(r'Strategy caused the following exception: xyz.*', caplog)
|
||||||
|
|
||||||
|
|
||||||
def test_get_signal_empty_dataframe(default_conf, mocker, caplog, ohlcv_history):
|
def test_get_signal_empty_dataframe(default_conf, mocker, caplog, ohlcv_history):
|
||||||
@ -121,7 +122,7 @@ def test_assert_df_raise(default_conf, mocker, caplog, ohlcv_history):
|
|||||||
caplog.set_level(logging.INFO)
|
caplog.set_level(logging.INFO)
|
||||||
mocker.patch.object(
|
mocker.patch.object(
|
||||||
_STRATEGY, 'assert_df',
|
_STRATEGY, 'assert_df',
|
||||||
side_effect=DependencyException('Dataframe returned...')
|
side_effect=StrategyError('Dataframe returned...')
|
||||||
)
|
)
|
||||||
assert (False, False) == _STRATEGY.get_signal('xyz', default_conf['ticker_interval'],
|
assert (False, False) == _STRATEGY.get_signal('xyz', default_conf['ticker_interval'],
|
||||||
ohlcv_history)
|
ohlcv_history)
|
||||||
@ -134,15 +135,15 @@ def test_assert_df(default_conf, mocker, ohlcv_history):
|
|||||||
_STRATEGY.assert_df(ohlcv_history, len(ohlcv_history),
|
_STRATEGY.assert_df(ohlcv_history, len(ohlcv_history),
|
||||||
ohlcv_history.loc[1, 'close'], ohlcv_history.loc[1, 'date'])
|
ohlcv_history.loc[1, 'close'], ohlcv_history.loc[1, 'date'])
|
||||||
|
|
||||||
with pytest.raises(DependencyException, match=r"Dataframe returned from strategy.*length\."):
|
with pytest.raises(StrategyError, match=r"Dataframe returned from strategy.*length\."):
|
||||||
_STRATEGY.assert_df(ohlcv_history, len(ohlcv_history) + 1,
|
_STRATEGY.assert_df(ohlcv_history, len(ohlcv_history) + 1,
|
||||||
ohlcv_history.loc[1, 'close'], ohlcv_history.loc[1, 'date'])
|
ohlcv_history.loc[1, 'close'], ohlcv_history.loc[1, 'date'])
|
||||||
|
|
||||||
with pytest.raises(DependencyException,
|
with pytest.raises(StrategyError,
|
||||||
match=r"Dataframe returned from strategy.*last close price\."):
|
match=r"Dataframe returned from strategy.*last close price\."):
|
||||||
_STRATEGY.assert_df(ohlcv_history, len(ohlcv_history),
|
_STRATEGY.assert_df(ohlcv_history, len(ohlcv_history),
|
||||||
ohlcv_history.loc[1, 'close'] + 0.01, ohlcv_history.loc[1, 'date'])
|
ohlcv_history.loc[1, 'close'] + 0.01, ohlcv_history.loc[1, 'date'])
|
||||||
with pytest.raises(DependencyException,
|
with pytest.raises(StrategyError,
|
||||||
match=r"Dataframe returned from strategy.*last date\."):
|
match=r"Dataframe returned from strategy.*last date\."):
|
||||||
_STRATEGY.assert_df(ohlcv_history, len(ohlcv_history),
|
_STRATEGY.assert_df(ohlcv_history, len(ohlcv_history),
|
||||||
ohlcv_history.loc[1, 'close'], ohlcv_history.loc[0, 'date'])
|
ohlcv_history.loc[1, 'close'], ohlcv_history.loc[0, 'date'])
|
||||||
@ -389,3 +390,38 @@ def test_is_pair_locked(default_conf):
|
|||||||
pair = 'ETH/BTC'
|
pair = 'ETH/BTC'
|
||||||
strategy.unlock_pair(pair)
|
strategy.unlock_pair(pair)
|
||||||
assert not strategy.is_pair_locked(pair)
|
assert not strategy.is_pair_locked(pair)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('error', [
|
||||||
|
ValueError, KeyError, Exception,
|
||||||
|
])
|
||||||
|
def test_strategy_safe_wrapper_error(caplog, error):
|
||||||
|
def failing_method():
|
||||||
|
raise error('This is an error.')
|
||||||
|
|
||||||
|
def working_method(argumentpassedin):
|
||||||
|
return argumentpassedin
|
||||||
|
|
||||||
|
with pytest.raises(StrategyError, match=r'This is an error.'):
|
||||||
|
strategy_safe_wrapper(failing_method, message='DeadBeef')()
|
||||||
|
|
||||||
|
assert log_has_re(r'DeadBeef.*', caplog)
|
||||||
|
ret = strategy_safe_wrapper(failing_method, message='DeadBeef', default_retval=True)()
|
||||||
|
|
||||||
|
assert isinstance(ret, bool)
|
||||||
|
assert ret
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('value', [
|
||||||
|
1, 22, 55, True, False, {'a': 1, 'b': '112'},
|
||||||
|
[1, 2, 3, 4], (4, 2, 3, 6)
|
||||||
|
])
|
||||||
|
def test_strategy_safe_wrapper(value):
|
||||||
|
|
||||||
|
def working_method(argumentpassedin):
|
||||||
|
return argumentpassedin
|
||||||
|
|
||||||
|
ret = strategy_safe_wrapper(working_method, message='DeadBeef')(value)
|
||||||
|
|
||||||
|
assert type(ret) == type(value)
|
||||||
|
assert ret == value
|
||||||
|
@ -1939,6 +1939,53 @@ def test_close_trade(default_conf, ticker, limit_buy_order, limit_sell_order,
|
|||||||
freqtrade.handle_trade(trade)
|
freqtrade.handle_trade(trade)
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_handle_timedout_buy_usercustom(default_conf, ticker, limit_buy_order_old, open_trade,
|
||||||
|
fee, mocker) -> None:
|
||||||
|
default_conf["unfilledtimeout"] = {"buy": 1400, "sell": 30}
|
||||||
|
|
||||||
|
rpc_mock = patch_RPCManager(mocker)
|
||||||
|
cancel_order_mock = MagicMock(return_value=limit_buy_order_old)
|
||||||
|
patch_exchange(mocker)
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.exchange.Exchange',
|
||||||
|
fetch_ticker=ticker,
|
||||||
|
get_order=MagicMock(return_value=limit_buy_order_old),
|
||||||
|
cancel_order=cancel_order_mock,
|
||||||
|
get_fee=fee
|
||||||
|
)
|
||||||
|
freqtrade = FreqtradeBot(default_conf)
|
||||||
|
|
||||||
|
Trade.session.add(open_trade)
|
||||||
|
|
||||||
|
# Return false - trade remains open
|
||||||
|
freqtrade.strategy.check_buy_timeout = MagicMock(return_value=False)
|
||||||
|
freqtrade.check_handle_timedout()
|
||||||
|
assert cancel_order_mock.call_count == 0
|
||||||
|
trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all()
|
||||||
|
nb_trades = len(trades)
|
||||||
|
assert nb_trades == 1
|
||||||
|
assert freqtrade.strategy.check_buy_timeout.call_count == 1
|
||||||
|
|
||||||
|
# Raise Keyerror ... (no impact on trade)
|
||||||
|
freqtrade.strategy.check_buy_timeout = MagicMock(side_effect=KeyError)
|
||||||
|
freqtrade.check_handle_timedout()
|
||||||
|
assert cancel_order_mock.call_count == 0
|
||||||
|
trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all()
|
||||||
|
nb_trades = len(trades)
|
||||||
|
assert nb_trades == 1
|
||||||
|
assert freqtrade.strategy.check_buy_timeout.call_count == 1
|
||||||
|
|
||||||
|
freqtrade.strategy.check_buy_timeout = MagicMock(return_value=True)
|
||||||
|
# Trade should be closed since the function returns true
|
||||||
|
freqtrade.check_handle_timedout()
|
||||||
|
assert cancel_order_mock.call_count == 1
|
||||||
|
assert rpc_mock.call_count == 1
|
||||||
|
trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all()
|
||||||
|
nb_trades = len(trades)
|
||||||
|
assert nb_trades == 0
|
||||||
|
assert freqtrade.strategy.check_buy_timeout.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
def test_check_handle_timedout_buy(default_conf, ticker, limit_buy_order_old, open_trade,
|
def test_check_handle_timedout_buy(default_conf, ticker, limit_buy_order_old, open_trade,
|
||||||
fee, mocker) -> None:
|
fee, mocker) -> None:
|
||||||
rpc_mock = patch_RPCManager(mocker)
|
rpc_mock = patch_RPCManager(mocker)
|
||||||
@ -1955,6 +2002,7 @@ def test_check_handle_timedout_buy(default_conf, ticker, limit_buy_order_old, op
|
|||||||
|
|
||||||
Trade.session.add(open_trade)
|
Trade.session.add(open_trade)
|
||||||
|
|
||||||
|
freqtrade.strategy.check_buy_timeout = MagicMock(return_value=False)
|
||||||
# check it does cancel buy orders over the time limit
|
# check it does cancel buy orders over the time limit
|
||||||
freqtrade.check_handle_timedout()
|
freqtrade.check_handle_timedout()
|
||||||
assert cancel_order_mock.call_count == 1
|
assert cancel_order_mock.call_count == 1
|
||||||
@ -1962,6 +2010,8 @@ def test_check_handle_timedout_buy(default_conf, ticker, limit_buy_order_old, op
|
|||||||
trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all()
|
trades = Trade.query.filter(Trade.open_order_id.is_(open_trade.open_order_id)).all()
|
||||||
nb_trades = len(trades)
|
nb_trades = len(trades)
|
||||||
assert nb_trades == 0
|
assert nb_trades == 0
|
||||||
|
# Custom user buy-timeout is never called
|
||||||
|
assert freqtrade.strategy.check_buy_timeout.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
def test_check_handle_cancelled_buy(default_conf, ticker, limit_buy_order_old, open_trade,
|
def test_check_handle_cancelled_buy(default_conf, ticker, limit_buy_order_old, open_trade,
|
||||||
@ -2018,6 +2068,51 @@ def test_check_handle_timedout_buy_exception(default_conf, ticker, limit_buy_ord
|
|||||||
assert nb_trades == 1
|
assert nb_trades == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_check_handle_timedout_sell_usercustom(default_conf, ticker, limit_sell_order_old, mocker,
|
||||||
|
open_trade) -> None:
|
||||||
|
default_conf["unfilledtimeout"] = {"buy": 1440, "sell": 1440}
|
||||||
|
rpc_mock = patch_RPCManager(mocker)
|
||||||
|
cancel_order_mock = MagicMock()
|
||||||
|
patch_exchange(mocker)
|
||||||
|
mocker.patch.multiple(
|
||||||
|
'freqtrade.exchange.Exchange',
|
||||||
|
fetch_ticker=ticker,
|
||||||
|
get_order=MagicMock(return_value=limit_sell_order_old),
|
||||||
|
cancel_order=cancel_order_mock
|
||||||
|
)
|
||||||
|
freqtrade = FreqtradeBot(default_conf)
|
||||||
|
|
||||||
|
open_trade.open_date = arrow.utcnow().shift(hours=-5).datetime
|
||||||
|
open_trade.close_date = arrow.utcnow().shift(minutes=-601).datetime
|
||||||
|
open_trade.is_open = False
|
||||||
|
|
||||||
|
Trade.session.add(open_trade)
|
||||||
|
|
||||||
|
freqtrade.strategy.check_sell_timeout = MagicMock(return_value=False)
|
||||||
|
# Return false - No impact
|
||||||
|
freqtrade.check_handle_timedout()
|
||||||
|
assert cancel_order_mock.call_count == 0
|
||||||
|
assert rpc_mock.call_count == 0
|
||||||
|
assert open_trade.is_open is False
|
||||||
|
assert freqtrade.strategy.check_sell_timeout.call_count == 1
|
||||||
|
|
||||||
|
freqtrade.strategy.check_sell_timeout = MagicMock(side_effect=KeyError)
|
||||||
|
# Return Error - No impact
|
||||||
|
freqtrade.check_handle_timedout()
|
||||||
|
assert cancel_order_mock.call_count == 0
|
||||||
|
assert rpc_mock.call_count == 0
|
||||||
|
assert open_trade.is_open is False
|
||||||
|
assert freqtrade.strategy.check_sell_timeout.call_count == 1
|
||||||
|
|
||||||
|
# Return True - sells!
|
||||||
|
freqtrade.strategy.check_sell_timeout = MagicMock(return_value=True)
|
||||||
|
freqtrade.check_handle_timedout()
|
||||||
|
assert cancel_order_mock.call_count == 1
|
||||||
|
assert rpc_mock.call_count == 1
|
||||||
|
assert open_trade.is_open is True
|
||||||
|
assert freqtrade.strategy.check_sell_timeout.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
def test_check_handle_timedout_sell(default_conf, ticker, limit_sell_order_old, mocker,
|
def test_check_handle_timedout_sell(default_conf, ticker, limit_sell_order_old, mocker,
|
||||||
open_trade) -> None:
|
open_trade) -> None:
|
||||||
rpc_mock = patch_RPCManager(mocker)
|
rpc_mock = patch_RPCManager(mocker)
|
||||||
@ -2037,11 +2132,14 @@ def test_check_handle_timedout_sell(default_conf, ticker, limit_sell_order_old,
|
|||||||
|
|
||||||
Trade.session.add(open_trade)
|
Trade.session.add(open_trade)
|
||||||
|
|
||||||
|
freqtrade.strategy.check_sell_timeout = MagicMock(return_value=False)
|
||||||
# check it does cancel sell orders over the time limit
|
# check it does cancel sell orders over the time limit
|
||||||
freqtrade.check_handle_timedout()
|
freqtrade.check_handle_timedout()
|
||||||
assert cancel_order_mock.call_count == 1
|
assert cancel_order_mock.call_count == 1
|
||||||
assert rpc_mock.call_count == 1
|
assert rpc_mock.call_count == 1
|
||||||
assert open_trade.is_open is True
|
assert open_trade.is_open is True
|
||||||
|
# Custom user sell-timeout is never called
|
||||||
|
assert freqtrade.strategy.check_sell_timeout.call_count == 0
|
||||||
|
|
||||||
|
|
||||||
def test_check_handle_cancelled_sell(default_conf, ticker, limit_sell_order_old, open_trade,
|
def test_check_handle_cancelled_sell(default_conf, ticker, limit_sell_order_old, open_trade,
|
||||||
|
@ -9,7 +9,9 @@ import pytest
|
|||||||
from freqtrade.data.converter import ohlcv_to_dataframe
|
from freqtrade.data.converter import ohlcv_to_dataframe
|
||||||
from freqtrade.misc import (datesarray_to_datetimearray, file_dump_json,
|
from freqtrade.misc import (datesarray_to_datetimearray, file_dump_json,
|
||||||
file_load_json, format_ms_time, pair_to_filename,
|
file_load_json, format_ms_time, pair_to_filename,
|
||||||
plural, safe_value_fallback, shorten_date)
|
plural, render_template,
|
||||||
|
render_template_with_fallback, safe_value_fallback,
|
||||||
|
shorten_date)
|
||||||
|
|
||||||
|
|
||||||
def test_shorten_date() -> None:
|
def test_shorten_date() -> None:
|
||||||
@ -144,3 +146,17 @@ def test_plural() -> None:
|
|||||||
assert plural(1.5, "ox", "oxen") == "oxen"
|
assert plural(1.5, "ox", "oxen") == "oxen"
|
||||||
assert plural(-0.5, "ox", "oxen") == "oxen"
|
assert plural(-0.5, "ox", "oxen") == "oxen"
|
||||||
assert plural(-1.5, "ox", "oxen") == "oxen"
|
assert plural(-1.5, "ox", "oxen") == "oxen"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_template_fallback(mocker):
|
||||||
|
from jinja2.exceptions import TemplateNotFound
|
||||||
|
with pytest.raises(TemplateNotFound):
|
||||||
|
val = render_template(
|
||||||
|
templatefile='subtemplates/indicators_does-not-exist.j2',)
|
||||||
|
|
||||||
|
val = render_template_with_fallback(
|
||||||
|
templatefile='subtemplates/indicators_does-not-exist.j2',
|
||||||
|
templatefallbackfile='subtemplates/indicators_minimal.j2',
|
||||||
|
)
|
||||||
|
assert isinstance(val, str)
|
||||||
|
assert 'if self.dp' in val
|
||||||
|
Loading…
Reference in New Issue
Block a user