diff --git a/config_full.json.example b/config_full.json.example
index 5ee2a1faf..e69e52469 100644
--- a/config_full.json.example
+++ b/config_full.json.example
@@ -75,6 +75,33 @@
"refresh_period": 1440
}
],
+ "protections": [
+ {
+ "method": "StoplossGuard",
+ "lookback_period_candles": 60,
+ "trade_limit": 4,
+ "stop_duration_candles": 60,
+ "only_per_pair": false
+ },
+ {
+ "method": "CooldownPeriod",
+ "stop_duration_candles": 20
+ },
+ {
+ "method": "MaxDrawdown",
+ "lookback_period_candles": 200,
+ "trade_limit": 20,
+ "stop_duration_candles": 10,
+ "max_allowed_drawdown": 0.2
+ },
+ {
+ "method": "LowProfitPairs",
+ "lookback_period_candles": 360,
+ "trade_limit": 1,
+ "stop_duration_candles": 2,
+ "required_profit": 0.02
+ }
+ ],
"exchange": {
"name": "bittrex",
"sandbox": false,
diff --git a/docs/bot-usage.md b/docs/bot-usage.md
index 4d07435c7..5820b3cc7 100644
--- a/docs/bot-usage.md
+++ b/docs/bot-usage.md
@@ -213,9 +213,11 @@ Backtesting also uses the config specified via `-c/--config`.
usage: freqtrade backtesting [-h] [-v] [--logfile FILE] [-V] [-c PATH]
[-d PATH] [--userdir PATH] [-s NAME]
[--strategy-path PATH] [-i TIMEFRAME]
- [--timerange TIMERANGE] [--max-open-trades INT]
+ [--timerange TIMERANGE]
+ [--data-format-ohlcv {json,jsongz,hdf5}]
+ [--max-open-trades INT]
[--stake-amount STAKE_AMOUNT] [--fee FLOAT]
- [--eps] [--dmmp]
+ [--eps] [--dmmp] [--enable-protections]
[--strategy-list STRATEGY_LIST [STRATEGY_LIST ...]]
[--export EXPORT] [--export-filename PATH]
@@ -226,6 +228,9 @@ optional arguments:
`1d`).
--timerange TIMERANGE
Specify what timerange of data to use.
+ --data-format-ohlcv {json,jsongz,hdf5}
+ Storage format for downloaded candle (OHLCV) data.
+ (default: `None`).
--max-open-trades INT
Override the value of the `max_open_trades`
configuration setting.
@@ -241,6 +246,10 @@ optional arguments:
Disable applying `max_open_trades` during backtest
(same as setting `max_open_trades` to a very high
number).
+ --enable-protections, --enableprotections
+ Enable protections for backtesting.Will slow
+ backtesting down by a considerable amount, but will
+ include configured protections
--strategy-list STRATEGY_LIST [STRATEGY_LIST ...]
Provide a space-separated list of strategies to
backtest. Please note that ticker-interval needs to be
@@ -296,13 +305,14 @@ to find optimal parameter values for your strategy.
usage: freqtrade hyperopt [-h] [-v] [--logfile FILE] [-V] [-c PATH] [-d PATH]
[--userdir PATH] [-s NAME] [--strategy-path PATH]
[-i TIMEFRAME] [--timerange TIMERANGE]
+ [--data-format-ohlcv {json,jsongz,hdf5}]
[--max-open-trades INT]
[--stake-amount STAKE_AMOUNT] [--fee FLOAT]
[--hyperopt NAME] [--hyperopt-path PATH] [--eps]
- [-e INT]
+ [--dmmp] [--enable-protections] [-e INT]
[--spaces {all,buy,sell,roi,stoploss,trailing,default} [{all,buy,sell,roi,stoploss,trailing,default} ...]]
- [--dmmp] [--print-all] [--no-color] [--print-json]
- [-j JOBS] [--random-state INT] [--min-trades INT]
+ [--print-all] [--no-color] [--print-json] [-j JOBS]
+ [--random-state INT] [--min-trades INT]
[--hyperopt-loss NAME]
optional arguments:
@@ -312,6 +322,9 @@ optional arguments:
`1d`).
--timerange TIMERANGE
Specify what timerange of data to use.
+ --data-format-ohlcv {json,jsongz,hdf5}
+ Storage format for downloaded candle (OHLCV) data.
+ (default: `None`).
--max-open-trades INT
Override the value of the `max_open_trades`
configuration setting.
@@ -327,14 +340,18 @@ optional arguments:
--eps, --enable-position-stacking
Allow buying the same pair multiple times (position
stacking).
- -e INT, --epochs INT Specify number of epochs (default: 100).
- --spaces {all,buy,sell,roi,stoploss,trailing,default} [{all,buy,sell,roi,stoploss,trailing,default} ...]
- Specify which parameters to hyperopt. Space-separated
- list.
--dmmp, --disable-max-market-positions
Disable applying `max_open_trades` during backtest
(same as setting `max_open_trades` to a very high
number).
+ --enable-protections, --enableprotections
+ Enable protections for backtesting.Will slow
+ backtesting down by a considerable amount, but will
+ include configured protections
+ -e INT, --epochs INT Specify number of epochs (default: 100).
+ --spaces {all,buy,sell,roi,stoploss,trailing,default} [{all,buy,sell,roi,stoploss,trailing,default} ...]
+ Specify which parameters to hyperopt. Space-separated
+ list.
--print-all Print all results, not only the best ones.
--no-color Disable colorization of hyperopt results. May be
useful if you are redirecting output to a file.
@@ -353,10 +370,10 @@ optional arguments:
class (IHyperOptLoss). Different functions can
generate completely different results, since the
target for optimization is different. Built-in
- Hyperopt-loss-functions are: ShortTradeDurHyperOptLoss,
- OnlyProfitHyperOptLoss, SharpeHyperOptLoss,
- SharpeHyperOptLossDaily, SortinoHyperOptLoss,
- SortinoHyperOptLossDaily.
+ Hyperopt-loss-functions are:
+ ShortTradeDurHyperOptLoss, OnlyProfitHyperOptLoss,
+ SharpeHyperOptLoss, SharpeHyperOptLossDaily,
+ SortinoHyperOptLoss, SortinoHyperOptLossDaily
Common arguments:
-v, --verbose Verbose mode (-vv for more, -vvv to get all messages).
diff --git a/docs/configuration.md b/docs/configuration.md
index 2e8f6555f..b70a85c04 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -91,6 +91,7 @@ Mandatory parameters are marked as **Required**, which means that they are requi
| `edge.*` | Please refer to [edge configuration document](edge.md) for detailed explanation.
| `experimental.block_bad_exchanges` | Block exchanges known to not work with freqtrade. Leave on default unless you want to test if that exchange works now.
*Defaults to `true`.*
**Datatype:** Boolean
| `pairlists` | Define one or more pairlists to be used. [More information below](#pairlists-and-pairlist-handlers).
*Defaults to `StaticPairList`.*
**Datatype:** List of Dicts
+| `protections` | Define one or more protections to be used. [More information below](#protections).
**Datatype:** List of Dicts
| `telegram.enabled` | Enable the usage of Telegram.
**Datatype:** Boolean
| `telegram.token` | Your Telegram bot token. Only required if `telegram.enabled` is `true`.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String
| `telegram.chat_id` | Your personal Telegram account id. Only required if `telegram.enabled` is `true`.
**Keep it in secret, do not disclose publicly.**
**Datatype:** String
@@ -575,6 +576,7 @@ Assuming both buy and sell are using market orders, a configuration similar to t
Obviously, if only one side is using limit orders, different pricing combinations can be used.
--8<-- "includes/pairlists.md"
+--8<-- "includes/protections.md"
## Switch to Dry-run mode
diff --git a/docs/developer.md b/docs/developer.md
index c253f4460..dcbaa3ca9 100644
--- a/docs/developer.md
+++ b/docs/developer.md
@@ -94,7 +94,9 @@ Below is an outline of exception inheritance hierarchy:
+---+ StrategyError
```
-## Modules
+---
+
+## Plugins
### Pairlists
@@ -119,6 +121,9 @@ The base-class provides an instance of the exchange (`self._exchange`) the pairl
self._pairlist_pos = pairlist_pos
```
+!!! Tip
+ Don't forget to register your pairlist in `constants.py` under the variable `AVAILABLE_PAIRLISTS` - otherwise it will not be selectable.
+
Now, let's step through the methods which require actions:
#### Pairlist configuration
@@ -170,6 +175,66 @@ In `VolumePairList`, this implements different methods of sorting, does early va
return pairs
```
+### Protections
+
+Best read the [Protection documentation](configuration.md#protections) to understand protections.
+This Guide is directed towards Developers who want to develop a new protection.
+
+No protection should use datetime directly, but use the provided `date_now` variable for date calculations. This preserves the ability to backtest protections.
+
+!!! Tip "Writing a new Protection"
+ Best copy one of the existing Protections to have a good example.
+ Don't forget to register your protection in `constants.py` under the variable `AVAILABLE_PROTECTIONS` - otherwise it will not be selectable.
+
+#### Implementation of a new protection
+
+All Protection implementations must have `IProtection` as parent class.
+For that reason, they must implement the following methods:
+
+* `short_desc()`
+* `global_stop()`
+* `stop_per_pair()`.
+
+`global_stop()` and `stop_per_pair()` must return a ProtectionReturn tuple, which consists of:
+
+* lock pair - boolean
+* lock until - datetime - until when should the pair be locked (will be rounded up to the next new candle)
+* reason - string, used for logging and storage in the database
+
+The `until` portion should be calculated using the provided `calculate_lock_end()` method.
+
+All Protections should use `"stop_duration"` / `"stop_duration_candles"` to define how long a a pair (or all pairs) should be locked.
+The content of this is made available as `self._stop_duration` to the each Protection.
+
+If your protection requires a look-back period, please use `"lookback_period"` / `"lockback_period_candles"` to keep all protections aligned.
+
+#### Global vs. local stops
+
+Protections can have 2 different ways to stop trading for a limited :
+
+* Per pair (local)
+* For all Pairs (globally)
+
+##### Protections - per pair
+
+Protections that implement the per pair approach must set `has_local_stop=True`.
+The method `stop_per_pair()` will be called whenever a trade closed (sell order completed).
+
+##### Protections - global protection
+
+These Protections should do their evaluation across all pairs, and consequently will also lock all pairs from trading (called a global PairLock).
+Global protection must set `has_global_stop=True` to be evaluated for global stops.
+The method `global_stop()` will be called whenever a trade closed (sell order completed).
+
+##### Protections - calculating lock end time
+
+Protections should calculate the lock end time based on the last trade it considers.
+This avoids re-locking should the lookback-period be longer than the actual lock period.
+
+The `IProtection` parent class provides a helper method for this in `calculate_lock_end()`.
+
+---
+
## Implement a new Exchange (WIP)
!!! Note
diff --git a/docs/includes/protections.md b/docs/includes/protections.md
new file mode 100644
index 000000000..87db17fd8
--- /dev/null
+++ b/docs/includes/protections.md
@@ -0,0 +1,169 @@
+## Protections
+
+!!! Warning "Beta feature"
+ This feature is still in it's testing phase. Should you notice something you think is wrong please let us know via Discord, Slack or via Github Issue.
+
+Protections will protect your strategy from unexpected events and market conditions by temporarily stop trading for either one pair, or for all pairs.
+All protection end times are rounded up to the next candle to avoid sudden, unexpected intra-candle buys.
+
+!!! Note
+ Not all Protections will work for all strategies, and parameters will need to be tuned for your strategy to improve performance.
+
+!!! Tip
+ Each Protection can be configured multiple times with different parameters, to allow different levels of protection (short-term / long-term).
+
+!!! Note "Backtesting"
+ Protections are supported by backtesting and hyperopt, but must be explicitly enabled by using the `--enable-protections` flag.
+
+### Available Protections
+
+* [`StoplossGuard`](#stoploss-guard) Stop trading if a certain amount of stoploss occurred within a certain time window.
+* [`MaxDrawdown`](#maxdrawdown) Stop trading if max-drawdown is reached.
+* [`LowProfitPairs`](#low-profit-pairs) Lock pairs with low profits
+* [`CooldownPeriod`](#cooldown-period) Don't enter a trade right after selling a trade.
+
+### Common settings to all Protections
+
+| Parameter| Description |
+|------------|-------------|
+| `method` | Protection name to use.
**Datatype:** String, selected from [available Protections](#available-protections)
+| `stop_duration_candles` | For how many candles should the lock be set?
**Datatype:** Positive integer (in candles)
+| `stop_duration` | how many minutes should protections be locked.
Cannot be used together with `stop_duration_candles`.
**Datatype:** Float (in minutes)
+| `lookback_period_candles` | Only trades that completed within the last `lookback_period_candles` candles will be considered. This setting may be ignored by some Protections.
**Datatype:** Positive integer (in candles).
+| `lookback_period` | Only trades that completed after `current_time - lookback_period` will be considered.
Cannot be used together with `lookback_period_candles`.
This setting may be ignored by some Protections.
**Datatype:** Float (in minutes)
+| `trade_limit` | Number of trades required at minimum (not used by all Protections).
**Datatype:** Positive integer
+
+!!! Note "Durations"
+ Durations (`stop_duration*` and `lookback_period*` can be defined in either minutes or candles).
+ For more flexibility when testing different timeframes, all below examples will use the "candle" definition.
+
+#### Stoploss Guard
+
+`StoplossGuard` selects all trades within `lookback_period`, and determines if the amount of trades that resulted in stoploss are above `trade_limit` - in which case trading will stop for `stop_duration`.
+This applies across all pairs, unless `only_per_pair` is set to true, which will then only look at one pair at a time.
+
+The below example stops trading for all pairs for 4 candles after the last trade if the bot hit stoploss 4 times within the last 24 candles.
+
+```json
+"protections": [
+ {
+ "method": "StoplossGuard",
+ "lookback_period_candles": 24,
+ "trade_limit": 4,
+ "stop_duration_candles": 4,
+ "only_per_pair": false
+ }
+],
+```
+
+!!! Note
+ `StoplossGuard` considers all trades with the results `"stop_loss"` and `"trailing_stop_loss"` if the resulting profit was negative.
+ `trade_limit` and `lookback_period` will need to be tuned for your strategy.
+
+#### MaxDrawdown
+
+`MaxDrawdown` uses all trades within `lookback_period` (in minutes) to determine the maximum drawdown. If the drawdown is below `max_allowed_drawdown`, trading will stop for `stop_duration` (in minutes) after the last trade - assuming that the bot needs some time to let markets recover.
+
+The below sample stops trading for 12 candles if max-drawdown is > 20% considering all trades within the last 48 candles.
+
+```json
+"protections": [
+ {
+ "method": "MaxDrawdown",
+ "lookback_period_candles": 48,
+ "trade_limit": 20,
+ "stop_duration_candles": 12,
+ "max_allowed_drawdown": 0.2
+ },
+],
+
+```
+
+#### Low Profit Pairs
+
+`LowProfitPairs` uses all trades for a pair within `lookback_period` (in minutes) to determine the overall profit ratio.
+If that ratio is below `required_profit`, that pair will be locked for `stop_duration` (in minutes).
+
+The below example will stop trading a pair for 60 minutes if the pair does not have a required profit of 2% (and a minimum of 2 trades) within the last 6 candles.
+
+```json
+"protections": [
+ {
+ "method": "LowProfitPairs",
+ "lookback_period_candles": 6,
+ "trade_limit": 2,
+ "stop_duration": 60,
+ "required_profit": 0.02
+ }
+],
+```
+
+#### Cooldown Period
+
+`CooldownPeriod` locks a pair for `stop_duration` (in minutes) after selling, avoiding a re-entry for this pair for `stop_duration` minutes.
+
+The below example will stop trading a pair for 2 candles after closing a trade, allowing this pair to "cool down".
+
+```json
+"protections": [
+ {
+ "method": "CooldownPeriod",
+ "stop_duration_candles": 2
+ }
+],
+```
+
+!!! Note
+ This Protection applies only at pair-level, and will never lock all pairs globally.
+ This Protection does not consider `lookback_period` as it only looks at the latest trade.
+
+### Full example of Protections
+
+All protections can be combined at will, also with different parameters, creating a increasing wall for under-performing pairs.
+All protections are evaluated in the sequence they are defined.
+
+The below example assumes a timeframe of 1 hour:
+
+* Locks each pair after selling for an additional 5 candles (`CooldownPeriod`), giving other pairs a chance to get filled.
+* Stops trading for 4 hours (`4 * 1h candles`) if the last 2 days (`48 * 1h candles`) had 20 trades, which caused a max-drawdown of more than 20%. (`MaxDrawdown`).
+* Stops trading if more than 4 stoploss occur for all pairs within a 1 day (`24 * 1h candles`) limit (`StoplossGuard`).
+* Locks all pairs that had 4 Trades within the last 6 hours (`6 * 1h candles`) with a combined profit ratio of below 0.02 (<2%) (`LowProfitPairs`).
+* Locks all pairs for 2 candles that had a profit of below 0.01 (<1%) within the last 24h (`24 * 1h candles`), a minimum of 4 trades.
+
+```json
+"timeframe": "1h",
+"protections": [
+ {
+ "method": "CooldownPeriod",
+ "stop_duration_candles": 5
+ },
+ {
+ "method": "MaxDrawdown",
+ "lookback_period_candles": 48,
+ "trade_limit": 20,
+ "stop_duration_candles": 4,
+ "max_allowed_drawdown": 0.2
+ },
+ {
+ "method": "StoplossGuard",
+ "lookback_period_candles": 24,
+ "trade_limit": 4,
+ "stop_duration_candles": 2,
+ "only_per_pair": false
+ },
+ {
+ "method": "LowProfitPairs",
+ "lookback_period_candles": 6,
+ "trade_limit": 2,
+ "stop_duration_candles": 60,
+ "required_profit": 0.02
+ },
+ {
+ "method": "LowProfitPairs",
+ "lookback_period_candles": 24,
+ "trade_limit": 4,
+ "stop_duration_candles": 2,
+ "required_profit": 0.01
+ }
+ ],
+```
diff --git a/docs/plugins.md b/docs/plugins.md
new file mode 100644
index 000000000..1f785bbaa
--- /dev/null
+++ b/docs/plugins.md
@@ -0,0 +1,3 @@
+# Plugins
+--8<-- "includes/pairlists.md"
+--8<-- "includes/protections.md"
diff --git a/freqtrade/commands/arguments.py b/freqtrade/commands/arguments.py
index aa58ff585..a7ae969f4 100644
--- a/freqtrade/commands/arguments.py
+++ b/freqtrade/commands/arguments.py
@@ -20,11 +20,13 @@ ARGS_COMMON_OPTIMIZE = ["timeframe", "timerange", "dataformat_ohlcv",
"max_open_trades", "stake_amount", "fee"]
ARGS_BACKTEST = ARGS_COMMON_OPTIMIZE + ["position_stacking", "use_max_market_positions",
+ "enable_protections",
"strategy_list", "export", "exportfilename"]
ARGS_HYPEROPT = ARGS_COMMON_OPTIMIZE + ["hyperopt", "hyperopt_path",
- "position_stacking", "epochs", "spaces",
- "use_max_market_positions", "print_all",
+ "position_stacking", "use_max_market_positions",
+ "enable_protections",
+ "epochs", "spaces", "print_all",
"print_colorized", "print_json", "hyperopt_jobs",
"hyperopt_random_state", "hyperopt_min_trades",
"hyperopt_loss"]
diff --git a/freqtrade/commands/cli_options.py b/freqtrade/commands/cli_options.py
index 619a300ae..668b4abf5 100644
--- a/freqtrade/commands/cli_options.py
+++ b/freqtrade/commands/cli_options.py
@@ -144,6 +144,14 @@ AVAILABLE_CLI_OPTIONS = {
action='store_false',
default=True,
),
+ "enable_protections": Arg(
+ '--enable-protections', '--enableprotections',
+ help='Enable protections for backtesting.'
+ 'Will slow backtesting down by a considerable amount, but will include '
+ 'configured protections',
+ action='store_true',
+ default=False,
+ ),
"strategy_list": Arg(
'--strategy-list',
help='Provide a space-separated list of strategies to backtest. '
diff --git a/freqtrade/configuration/config_validation.py b/freqtrade/configuration/config_validation.py
index ab21bc686..b8829b80f 100644
--- a/freqtrade/configuration/config_validation.py
+++ b/freqtrade/configuration/config_validation.py
@@ -74,6 +74,7 @@ def validate_config_consistency(conf: Dict[str, Any]) -> None:
_validate_trailing_stoploss(conf)
_validate_edge(conf)
_validate_whitelist(conf)
+ _validate_protections(conf)
_validate_unlimited_amount(conf)
# validate configuration before returning
@@ -155,3 +156,22 @@ def _validate_whitelist(conf: Dict[str, Any]) -> None:
if (pl.get('method') == 'StaticPairList'
and not conf.get('exchange', {}).get('pair_whitelist')):
raise OperationalException("StaticPairList requires pair_whitelist to be set.")
+
+
+def _validate_protections(conf: Dict[str, Any]) -> None:
+ """
+ Validate protection configuration validity
+ """
+
+ for prot in conf.get('protections', []):
+ if ('stop_duration' in prot and 'stop_duration_candles' in prot):
+ raise OperationalException(
+ "Protections must specify either `stop_duration` or `stop_duration_candles`.\n"
+ f"Please fix the protection {prot.get('method')}"
+ )
+
+ if ('lookback_period' in prot and 'lookback_period_candles' in prot):
+ raise OperationalException(
+ "Protections must specify either `lookback_period` or `lookback_period_candles`.\n"
+ f"Please fix the protection {prot.get('method')}"
+ )
diff --git a/freqtrade/configuration/configuration.py b/freqtrade/configuration/configuration.py
index 1ca3187fb..7bf3e6bf2 100644
--- a/freqtrade/configuration/configuration.py
+++ b/freqtrade/configuration/configuration.py
@@ -211,6 +211,9 @@ class Configuration:
self._args_to_config(config, argname='position_stacking',
logstring='Parameter --enable-position-stacking detected ...')
+ self._args_to_config(
+ config, argname='enable_protections',
+ logstring='Parameter --enable-protections detected, enabling Protections. ...')
# Setting max_open_trades to infinite if -1
if config.get('max_open_trades') == -1:
config['max_open_trades'] = float('inf')
diff --git a/freqtrade/constants.py b/freqtrade/constants.py
index 601e525c1..e7d7e80f6 100644
--- a/freqtrade/constants.py
+++ b/freqtrade/constants.py
@@ -27,6 +27,7 @@ AVAILABLE_PAIRLISTS = ['StaticPairList', 'VolumePairList',
'AgeFilter', 'PerformanceFilter', 'PrecisionFilter',
'PriceFilter', 'RangeStabilityFilter', 'ShuffleFilter',
'SpreadFilter']
+AVAILABLE_PROTECTIONS = ['CooldownPeriod', 'LowProfitPairs', 'MaxDrawdown', 'StoplossGuard']
AVAILABLE_DATAHANDLERS = ['json', 'jsongz', 'hdf5']
DRY_RUN_WALLET = 1000
DATETIME_PRINT_FORMAT = '%Y-%m-%d %H:%M:%S'
@@ -192,7 +193,21 @@ CONF_SCHEMA = {
'type': 'object',
'properties': {
'method': {'type': 'string', 'enum': AVAILABLE_PAIRLISTS},
- 'config': {'type': 'object'}
+ },
+ 'required': ['method'],
+ }
+ },
+ 'protections': {
+ 'type': 'array',
+ 'items': {
+ 'type': 'object',
+ 'properties': {
+ 'method': {'type': 'string', 'enum': AVAILABLE_PROTECTIONS},
+ 'stop_duration': {'type': 'number', 'minimum': 0.0},
+ 'stop_duration_candles': {'type': 'number', 'minimum': 0},
+ 'trade_limit': {'type': 'number', 'minimum': 1},
+ 'lookback_period': {'type': 'number', 'minimum': 1},
+ 'lookback_period_candles': {'type': 'number', 'minimum': 1},
},
'required': ['method'],
}
diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py
index c86fb616b..08b806076 100644
--- a/freqtrade/freqtradebot.py
+++ b/freqtrade/freqtradebot.py
@@ -19,10 +19,12 @@ from freqtrade.data.dataprovider import DataProvider
from freqtrade.edge import Edge
from freqtrade.exceptions import (DependencyException, ExchangeError, InsufficientFundsError,
InvalidOrderException, PricingError)
-from freqtrade.exchange import timeframe_to_minutes
+from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
from freqtrade.misc import safe_value_fallback, safe_value_fallback2
+from freqtrade.mixins import LoggingMixin
from freqtrade.pairlist.pairlistmanager import PairListManager
from freqtrade.persistence import Order, PairLocks, Trade, cleanup_db, init_db
+from freqtrade.plugins.protectionmanager import ProtectionManager
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
from freqtrade.rpc import RPCManager, RPCMessageType
from freqtrade.state import State
@@ -34,7 +36,7 @@ from freqtrade.wallets import Wallets
logger = logging.getLogger(__name__)
-class FreqtradeBot:
+class FreqtradeBot(LoggingMixin):
"""
Freqtrade is the main class of the bot.
This is from here the bot start its logic.
@@ -78,6 +80,8 @@ class FreqtradeBot:
self.dataprovider = DataProvider(self.config, self.exchange, self.pairlists)
+ self.protections = ProtectionManager(self.config)
+
# Attach Dataprovider to Strategy baseclass
IStrategy.dp = self.dataprovider
# Attach Wallets to Strategy baseclass
@@ -101,6 +105,7 @@ class FreqtradeBot:
self.rpc: RPCManager = RPCManager(self)
# Protect sell-logic from forcesell and viceversa
self._sell_lock = Lock()
+ LoggingMixin.__init__(self, logger, timeframe_to_seconds(self.strategy.timeframe))
def notify_status(self, msg: str) -> None:
"""
@@ -132,7 +137,7 @@ class FreqtradeBot:
Called on startup and after reloading the bot - triggers notifications and
performs startup tasks
"""
- self.rpc.startup_messages(self.config, self.pairlists)
+ self.rpc.startup_messages(self.config, self.pairlists, self.protections)
if not self.edge:
# Adjust stoploss if it was changed
Trade.stoploss_reinitialization(self.strategy.stoploss)
@@ -358,6 +363,15 @@ class FreqtradeBot:
logger.info("No currency pair in active pair whitelist, "
"but checking to sell open trades.")
return trades_created
+ if PairLocks.is_global_lock():
+ lock = PairLocks.get_pair_longest_lock('*')
+ if lock:
+ self.log_once(f"Global pairlock active until "
+ f"{lock.lock_end_time.strftime(constants.DATETIME_PRINT_FORMAT)}. "
+ "Not creating new trades.", logger.info)
+ else:
+ self.log_once("Global pairlock active. Not creating new trades.", logger.info)
+ return trades_created
# Create entity and execute trade for each pair from whitelist
for pair in whitelist:
try:
@@ -366,8 +380,7 @@ class FreqtradeBot:
logger.warning('Unable to create trade for %s: %s', pair, exception)
if not trades_created:
- logger.debug("Found no buy signals for whitelisted currencies. "
- "Trying again...")
+ logger.debug("Found no buy signals for whitelisted currencies. Trying again...")
return trades_created
@@ -540,9 +553,15 @@ class FreqtradeBot:
logger.debug(f"create_trade for pair {pair}")
analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(pair, self.strategy.timeframe)
- if self.strategy.is_pair_locked(
- pair, analyzed_df.iloc[-1]['date'] if len(analyzed_df) > 0 else None):
- logger.info(f"Pair {pair} is currently locked.")
+ nowtime = analyzed_df.iloc[-1]['date'] if len(analyzed_df) > 0 else None
+ if self.strategy.is_pair_locked(pair, nowtime):
+ lock = PairLocks.get_pair_longest_lock(pair, nowtime)
+ if lock:
+ self.log_once(f"Pair {pair} is still locked until "
+ f"{lock.lock_end_time.strftime(constants.DATETIME_PRINT_FORMAT)}.",
+ logger.info)
+ else:
+ self.log_once(f"Pair {pair} is still locked.", logger.info)
return False
# get_free_open_trades is checked before create_trade is called
@@ -1407,6 +1426,8 @@ class FreqtradeBot:
# Updating wallets when order is closed
if not trade.is_open:
+ self.protections.stop_per_pair(trade.pair)
+ self.protections.global_stop()
self.wallets.update()
return False
diff --git a/freqtrade/mixins/__init__.py b/freqtrade/mixins/__init__.py
new file mode 100644
index 000000000..f4a640fa3
--- /dev/null
+++ b/freqtrade/mixins/__init__.py
@@ -0,0 +1,2 @@
+# flake8: noqa: F401
+from freqtrade.mixins.logging_mixin import LoggingMixin
diff --git a/freqtrade/mixins/logging_mixin.py b/freqtrade/mixins/logging_mixin.py
new file mode 100644
index 000000000..06935d5f6
--- /dev/null
+++ b/freqtrade/mixins/logging_mixin.py
@@ -0,0 +1,38 @@
+from typing import Callable
+
+from cachetools import TTLCache, cached
+
+
+class LoggingMixin():
+ """
+ Logging Mixin
+ Shows similar messages only once every `refresh_period`.
+ """
+ # Disable output completely
+ show_output = True
+
+ def __init__(self, logger, refresh_period: int = 3600):
+ """
+ :param refresh_period: in seconds - Show identical messages in this intervals
+ """
+ self.logger = logger
+ self.refresh_period = refresh_period
+ self._log_cache: TTLCache = TTLCache(maxsize=1024, ttl=self.refresh_period)
+
+ def log_once(self, message: str, logmethod: Callable) -> None:
+ """
+ Logs message - not more often than "refresh_period" to avoid log spamming
+ Logs the log-message as debug as well to simplify debugging.
+ :param message: String containing the message to be sent to the function.
+ :param logmethod: Function that'll be called. Most likely `logger.info`.
+ :return: None.
+ """
+ @cached(cache=self._log_cache)
+ def _log_once(message: str):
+ logmethod(message)
+
+ # Log as debug first
+ self.logger.debug(message)
+ # Call hidden function.
+ if self.show_output:
+ _log_once(message)
diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py
index 883f7338c..de9c52dad 100644
--- a/freqtrade/optimize/backtesting.py
+++ b/freqtrade/optimize/backtesting.py
@@ -18,10 +18,12 @@ from freqtrade.data.converter import trim_dataframe
from freqtrade.data.dataprovider import DataProvider
from freqtrade.exceptions import OperationalException
from freqtrade.exchange import timeframe_to_minutes, timeframe_to_seconds
+from freqtrade.mixins import LoggingMixin
from freqtrade.optimize.optimize_reports import (generate_backtest_stats, show_backtest_results,
store_backtest_stats)
from freqtrade.pairlist.pairlistmanager import PairListManager
-from freqtrade.persistence import Trade
+from freqtrade.persistence import PairLocks, Trade
+from freqtrade.plugins.protectionmanager import ProtectionManager
from freqtrade.resolvers import ExchangeResolver, StrategyResolver
from freqtrade.strategy.interface import IStrategy, SellCheckTuple, SellType
@@ -67,6 +69,8 @@ class Backtesting:
"""
def __init__(self, config: Dict[str, Any]) -> None:
+
+ LoggingMixin.show_output = False
self.config = config
# Reset keys for backtesting
@@ -115,11 +119,24 @@ class Backtesting:
else:
self.fee = self.exchange.get_fee(symbol=self.pairlists.whitelist[0])
+ Trade.use_db = False
+ Trade.reset_trades()
+ PairLocks.timeframe = self.config['timeframe']
+ PairLocks.use_db = False
+ PairLocks.reset_locks()
+ if self.config.get('enable_protections', False):
+ self.protections = ProtectionManager(self.config)
+
# Get maximum required startup period
self.required_startup = max([strat.startup_candle_count for strat in self.strategylist])
# Load one (first) strategy
self._set_strategy(self.strategylist[0])
+ def __del__(self):
+ LoggingMixin.show_output = True
+ PairLocks.use_db = True
+ Trade.use_db = True
+
def _set_strategy(self, strategy):
"""
Load strategy into backtesting
@@ -156,6 +173,17 @@ class Backtesting:
return data, timerange
+ def prepare_backtest(self, enable_protections):
+ """
+ Backtesting setup method - called once for every call to "backtest()".
+ """
+ PairLocks.use_db = False
+ Trade.use_db = False
+ if enable_protections:
+ # Reset persisted data - used for protections only
+ PairLocks.reset_locks()
+ Trade.reset_trades()
+
def _get_ohlcv_as_lists(self, processed: Dict[str, DataFrame]) -> Dict[str, Tuple]:
"""
Helper function to convert a processed dataframes into lists for performance reasons.
@@ -235,6 +263,10 @@ class Backtesting:
trade_dur = int((sell_row[DATE_IDX] - trade.open_date).total_seconds() // 60)
closerate = self._get_close_rate(sell_row, trade, sell, trade_dur)
+ trade.close_date = sell_row[DATE_IDX]
+ trade.sell_reason = sell.sell_type
+ trade.close(closerate, show_msg=False)
+
return BacktestResult(pair=trade.pair,
profit_percent=trade.calc_profit_ratio(rate=closerate),
profit_abs=trade.calc_profit(rate=closerate),
@@ -261,6 +293,7 @@ class Backtesting:
if len(open_trades[pair]) > 0:
for trade in open_trades[pair]:
sell_row = data[pair][-1]
+
trade_entry = BacktestResult(pair=trade.pair,
profit_percent=trade.calc_profit_ratio(
rate=sell_row[OPEN_IDX]),
@@ -283,7 +316,8 @@ class Backtesting:
def backtest(self, processed: Dict, stake_amount: float,
start_date: datetime, end_date: datetime,
- max_open_trades: int = 0, position_stacking: bool = False) -> DataFrame:
+ max_open_trades: int = 0, position_stacking: bool = False,
+ enable_protections: bool = False) -> DataFrame:
"""
Implement backtesting functionality
@@ -297,6 +331,7 @@ class Backtesting:
:param end_date: backtesting timerange end datetime
:param max_open_trades: maximum number of concurrent trades, <= 0 means unlimited
:param position_stacking: do we allow position stacking?
+ :param enable_protections: Should protections be enabled?
:return: DataFrame with trades (results of backtesting)
"""
logger.debug(f"Run backtest, stake_amount: {stake_amount}, "
@@ -304,6 +339,7 @@ class Backtesting:
f"max_open_trades: {max_open_trades}, position_stacking: {position_stacking}"
)
trades = []
+ self.prepare_backtest(enable_protections)
# Use dict of lists with data for performance
# (looping lists is a lot faster than pandas DataFrames)
@@ -342,7 +378,8 @@ class Backtesting:
if ((position_stacking or len(open_trades[pair]) == 0)
and (max_open_trades <= 0 or open_trade_count_start < max_open_trades)
and tmp != end_date
- and row[BUY_IDX] == 1 and row[SELL_IDX] != 1):
+ and row[BUY_IDX] == 1 and row[SELL_IDX] != 1
+ and not PairLocks.is_pair_locked(pair, row[DATE_IDX])):
# Enter trade
trade = Trade(
pair=pair,
@@ -361,6 +398,7 @@ class Backtesting:
open_trade_count += 1
# logger.debug(f"{pair} - Backtesting emulates creation of new trade: {trade}.")
open_trades[pair].append(trade)
+ Trade.trades.append(trade)
for trade in open_trades[pair]:
# since indexes has been incremented before, we need to go one step back to
@@ -372,6 +410,9 @@ class Backtesting:
open_trade_count -= 1
open_trades[pair].remove(trade)
trades.append(trade_entry)
+ if enable_protections:
+ self.protections.stop_per_pair(pair, row[DATE_IDX])
+ self.protections.global_stop(tmp)
# Move time one configured time_interval ahead.
tmp += timedelta(minutes=self.timeframe_min)
@@ -427,10 +468,12 @@ class Backtesting:
end_date=max_date.datetime,
max_open_trades=max_open_trades,
position_stacking=position_stacking,
+ enable_protections=self.config.get('enable_protections', False),
)
all_results[self.strategy.get_strategy_name()] = {
'results': results,
'config': self.strategy.config,
+ 'locks': PairLocks.locks,
}
stats = generate_backtest_stats(data, all_results, min_date=min_date, max_date=max_date)
diff --git a/freqtrade/optimize/hyperopt.py b/freqtrade/optimize/hyperopt.py
index 7870ba1cf..2a2f5b472 100644
--- a/freqtrade/optimize/hyperopt.py
+++ b/freqtrade/optimize/hyperopt.py
@@ -542,6 +542,8 @@ class Hyperopt:
end_date=max_date.datetime,
max_open_trades=self.max_open_trades,
position_stacking=self.position_stacking,
+ enable_protections=self.config.get('enable_protections', False),
+
)
return self._get_results_dict(backtesting_results, min_date, max_date,
params_dict, params_details)
diff --git a/freqtrade/optimize/optimize_reports.py b/freqtrade/optimize/optimize_reports.py
index b3799856e..d029ecd13 100644
--- a/freqtrade/optimize/optimize_reports.py
+++ b/freqtrade/optimize/optimize_reports.py
@@ -266,6 +266,7 @@ def generate_backtest_stats(btdata: Dict[str, DataFrame],
backtest_days = (max_date - min_date).days
strat_stats = {
'trades': results.to_dict(orient='records'),
+ 'locks': [lock.to_json() for lock in content['locks']],
'best_pair': best_pair,
'worst_pair': worst_pair,
'results_per_pair': pair_results,
diff --git a/freqtrade/pairlist/AgeFilter.py b/freqtrade/pairlist/AgeFilter.py
index e2a13c20a..ae2132637 100644
--- a/freqtrade/pairlist/AgeFilter.py
+++ b/freqtrade/pairlist/AgeFilter.py
@@ -76,9 +76,8 @@ class AgeFilter(IPairList):
self._symbolsChecked[ticker['symbol']] = int(arrow.utcnow().float_timestamp) * 1000
return True
else:
- self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, "
- f"because age {len(daily_candles)} is less than "
- f"{self._min_days_listed} "
- f"{plural(self._min_days_listed, 'day')}")
+ self.log_once(f"Removed {ticker['symbol']} from whitelist, because age "
+ f"{len(daily_candles)} is less than {self._min_days_listed} "
+ f"{plural(self._min_days_listed, 'day')}", logger.info)
return False
return False
diff --git a/freqtrade/pairlist/IPairList.py b/freqtrade/pairlist/IPairList.py
index c869e499b..5f29241ce 100644
--- a/freqtrade/pairlist/IPairList.py
+++ b/freqtrade/pairlist/IPairList.py
@@ -6,16 +6,15 @@ from abc import ABC, abstractmethod, abstractproperty
from copy import deepcopy
from typing import Any, Dict, List
-from cachetools import TTLCache, cached
-
from freqtrade.exceptions import OperationalException
from freqtrade.exchange import market_is_active
+from freqtrade.mixins import LoggingMixin
logger = logging.getLogger(__name__)
-class IPairList(ABC):
+class IPairList(LoggingMixin, ABC):
def __init__(self, exchange, pairlistmanager,
config: Dict[str, Any], pairlistconfig: Dict[str, Any],
@@ -36,7 +35,7 @@ class IPairList(ABC):
self._pairlist_pos = pairlist_pos
self.refresh_period = self._pairlistconfig.get('refresh_period', 1800)
self._last_refresh = 0
- self._log_cache: TTLCache = TTLCache(maxsize=1024, ttl=self.refresh_period)
+ LoggingMixin.__init__(self, logger, self.refresh_period)
@property
def name(self) -> str:
@@ -46,24 +45,6 @@ class IPairList(ABC):
"""
return self.__class__.__name__
- def log_on_refresh(self, logmethod, message: str) -> None:
- """
- Logs message - not more often than "refresh_period" to avoid log spamming
- Logs the log-message as debug as well to simplify debugging.
- :param logmethod: Function that'll be called. Most likely `logger.info`.
- :param message: String containing the message to be sent to the function.
- :return: None.
- """
-
- @cached(cache=self._log_cache)
- def _log_on_refresh(message: str):
- logmethod(message)
-
- # Log as debug first
- logger.debug(message)
- # Call hidden function.
- _log_on_refresh(message)
-
@abstractproperty
def needstickers(self) -> bool:
"""
diff --git a/freqtrade/pairlist/PrecisionFilter.py b/freqtrade/pairlist/PrecisionFilter.py
index 29e32fd44..db05d5883 100644
--- a/freqtrade/pairlist/PrecisionFilter.py
+++ b/freqtrade/pairlist/PrecisionFilter.py
@@ -59,9 +59,8 @@ class PrecisionFilter(IPairList):
logger.debug(f"{ticker['symbol']} - {sp} : {stop_gap_price}")
if sp <= stop_gap_price:
- self.log_on_refresh(logger.info,
- f"Removed {ticker['symbol']} from whitelist, "
- f"because stop price {sp} would be <= stop limit {stop_gap_price}")
+ self.log_once(f"Removed {ticker['symbol']} from whitelist, because "
+ f"stop price {sp} would be <= stop limit {stop_gap_price}", logger.info)
return False
return True
diff --git a/freqtrade/pairlist/PriceFilter.py b/freqtrade/pairlist/PriceFilter.py
index bef1c0a15..3686cd138 100644
--- a/freqtrade/pairlist/PriceFilter.py
+++ b/freqtrade/pairlist/PriceFilter.py
@@ -64,9 +64,9 @@ class PriceFilter(IPairList):
:return: True if the pair can stay, false if it should be removed
"""
if ticker['last'] is None or ticker['last'] == 0:
- self.log_on_refresh(logger.info,
- f"Removed {ticker['symbol']} from whitelist, because "
- "ticker['last'] is empty (Usually no trade in the last 24h).")
+ self.log_once(f"Removed {ticker['symbol']} from whitelist, because "
+ "ticker['last'] is empty (Usually no trade in the last 24h).",
+ logger.info)
return False
# Perform low_price_ratio check.
@@ -74,22 +74,22 @@ class PriceFilter(IPairList):
compare = self._exchange.price_get_one_pip(ticker['symbol'], ticker['last'])
changeperc = compare / ticker['last']
if changeperc > self._low_price_ratio:
- self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, "
- f"because 1 unit is {changeperc * 100:.3f}%")
+ self.log_once(f"Removed {ticker['symbol']} from whitelist, "
+ f"because 1 unit is {changeperc * 100:.3f}%", logger.info)
return False
# Perform min_price check.
if self._min_price != 0:
if ticker['last'] < self._min_price:
- self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, "
- f"because last price < {self._min_price:.8f}")
+ self.log_once(f"Removed {ticker['symbol']} from whitelist, "
+ f"because last price < {self._min_price:.8f}", logger.info)
return False
# Perform max_price check.
if self._max_price != 0:
if ticker['last'] > self._max_price:
- self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, "
- f"because last price > {self._max_price:.8f}")
+ self.log_once(f"Removed {ticker['symbol']} from whitelist, "
+ f"because last price > {self._max_price:.8f}", logger.info)
return False
return True
diff --git a/freqtrade/pairlist/SpreadFilter.py b/freqtrade/pairlist/SpreadFilter.py
index a636b90bd..6c4e9f12f 100644
--- a/freqtrade/pairlist/SpreadFilter.py
+++ b/freqtrade/pairlist/SpreadFilter.py
@@ -45,9 +45,9 @@ class SpreadFilter(IPairList):
if 'bid' in ticker and 'ask' in ticker:
spread = 1 - ticker['bid'] / ticker['ask']
if spread > self._max_spread_ratio:
- self.log_on_refresh(logger.info, f"Removed {ticker['symbol']} from whitelist, "
- f"because spread {spread * 100:.3f}% >"
- f"{self._max_spread_ratio * 100}%")
+ self.log_once(f"Removed {ticker['symbol']} from whitelist, because spread "
+ f"{spread * 100:.3f}% > {self._max_spread_ratio * 100}%",
+ logger.info)
return False
else:
return True
diff --git a/freqtrade/pairlist/VolumePairList.py b/freqtrade/pairlist/VolumePairList.py
index 7d3c2c653..7056bc59d 100644
--- a/freqtrade/pairlist/VolumePairList.py
+++ b/freqtrade/pairlist/VolumePairList.py
@@ -111,6 +111,6 @@ class VolumePairList(IPairList):
# Limit pairlist to the requested number of pairs
pairs = pairs[:self._number_pairs]
- self.log_on_refresh(logger.info, f"Searching {self._number_pairs} pairs: {pairs}")
+ self.log_once(f"Searching {self._number_pairs} pairs: {pairs}", logger.info)
return pairs
diff --git a/freqtrade/pairlist/pairlistmanager.py b/freqtrade/pairlist/pairlistmanager.py
index 89bab99be..810a22300 100644
--- a/freqtrade/pairlist/pairlistmanager.py
+++ b/freqtrade/pairlist/pairlistmanager.py
@@ -26,9 +26,6 @@ class PairListManager():
self._pairlist_handlers: List[IPairList] = []
self._tickers_needed = False
for pairlist_handler_config in self._config.get('pairlists', None):
- if 'method' not in pairlist_handler_config:
- logger.warning(f"No method found in {pairlist_handler_config}, ignoring.")
- continue
pairlist_handler = PairListResolver.load_pairlist(
pairlist_handler_config['method'],
exchange=exchange,
diff --git a/freqtrade/pairlist/rangestabilityfilter.py b/freqtrade/pairlist/rangestabilityfilter.py
index b460ff477..756368355 100644
--- a/freqtrade/pairlist/rangestabilityfilter.py
+++ b/freqtrade/pairlist/rangestabilityfilter.py
@@ -78,11 +78,10 @@ class RangeStabilityFilter(IPairList):
if pct_change >= self._min_rate_of_change:
result = True
else:
- self.log_on_refresh(logger.info,
- f"Removed {pair} from whitelist, "
- f"because rate of change over {plural(self._days, 'day')} is "
- f"{pct_change:.3f}, which is below the "
- f"threshold of {self._min_rate_of_change}.")
+ self.log_once(f"Removed {pair} from whitelist, because rate of change "
+ f"over {plural(self._days, 'day')} is {pct_change:.3f}, "
+ f"which is below the threshold of {self._min_rate_of_change}.",
+ logger.info)
result = False
self._pair_cache[pair] = result
diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py
index 06dd785e8..7fa894e9c 100644
--- a/freqtrade/persistence/models.py
+++ b/freqtrade/persistence/models.py
@@ -202,6 +202,10 @@ class Trade(_DECL_BASE):
"""
__tablename__ = 'trades'
+ use_db: bool = True
+ # Trades container for backtesting
+ trades: List['Trade'] = []
+
id = Column(Integer, primary_key=True)
orders = relationship("Order", order_by="Order.id", cascade="all, delete-orphan")
@@ -323,6 +327,14 @@ class Trade(_DECL_BASE):
'open_order_id': self.open_order_id,
}
+ @staticmethod
+ def reset_trades() -> None:
+ """
+ Resets all trades. Only active for backtesting mode.
+ """
+ if not Trade.use_db:
+ Trade.trades = []
+
def adjust_min_max_rates(self, current_price: float) -> None:
"""
Adjust the max_rate and min_rate.
@@ -407,7 +419,7 @@ class Trade(_DECL_BASE):
raise ValueError(f'Unknown order type: {order_type}')
cleanup_db()
- def close(self, rate: float) -> None:
+ def close(self, rate: float, *, show_msg: bool = True) -> None:
"""
Sets close_rate to the given rate, calculates total profit
and marks trade as closed
@@ -419,10 +431,11 @@ class Trade(_DECL_BASE):
self.is_open = False
self.sell_order_status = 'closed'
self.open_order_id = None
- logger.info(
- 'Marking %s as closed as the trade is fulfilled and found no open orders for it.',
- self
- )
+ if show_msg:
+ logger.info(
+ 'Marking %s as closed as the trade is fulfilled and found no open orders for it.',
+ self
+ )
def update_fee(self, fee_cost: float, fee_currency: Optional[str], fee_rate: Optional[float],
side: str) -> None:
@@ -562,6 +575,43 @@ class Trade(_DECL_BASE):
else:
return Trade.query
+ @staticmethod
+ def get_trades_proxy(*, pair: str = None, is_open: bool = None,
+ open_date: datetime = None, close_date: datetime = None,
+ ) -> List['Trade']:
+ """
+ Helper function to query Trades.
+ Returns a List of trades, filtered on the parameters given.
+ In live mode, converts the filter to a database query and returns all rows
+ In Backtest mode, uses filters on Trade.trades to get the result.
+
+ :return: unsorted List[Trade]
+ """
+ if Trade.use_db:
+ trade_filter = []
+ if pair:
+ trade_filter.append(Trade.pair == pair)
+ if open_date:
+ trade_filter.append(Trade.open_date > open_date)
+ if close_date:
+ trade_filter.append(Trade.close_date > close_date)
+ if is_open is not None:
+ trade_filter.append(Trade.is_open.is_(is_open))
+ return Trade.get_trades(trade_filter).all()
+ else:
+ # Offline mode - without database
+ sel_trades = [trade for trade in Trade.trades]
+ if pair:
+ sel_trades = [trade for trade in sel_trades if trade.pair == pair]
+ if open_date:
+ sel_trades = [trade for trade in sel_trades if trade.open_date > open_date]
+ if close_date:
+ sel_trades = [trade for trade in sel_trades if trade.close_date
+ and trade.close_date > close_date]
+ if is_open is not None:
+ sel_trades = [trade for trade in sel_trades if trade.is_open == is_open]
+ return sel_trades
+
@staticmethod
def get_open_trades() -> List[Any]:
"""
@@ -688,7 +738,7 @@ class PairLock(_DECL_BASE):
@staticmethod
def query_pair_locks(pair: Optional[str], now: datetime) -> Query:
"""
- Get all locks for this pair
+ Get all currently active locks for this pair
:param pair: Pair to check for. Returns all current locks if pair is empty
:param now: Datetime object (generated via datetime.now(timezone.utc)).
"""
diff --git a/freqtrade/persistence/pairlock_middleware.py b/freqtrade/persistence/pairlock_middleware.py
index 44fc228f6..8644146d8 100644
--- a/freqtrade/persistence/pairlock_middleware.py
+++ b/freqtrade/persistence/pairlock_middleware.py
@@ -22,10 +22,27 @@ class PairLocks():
timeframe: str = ''
@staticmethod
- def lock_pair(pair: str, until: datetime, reason: str = None) -> None:
+ def reset_locks() -> None:
+ """
+ Resets all locks. Only active for backtesting mode.
+ """
+ if not PairLocks.use_db:
+ PairLocks.locks = []
+
+ @staticmethod
+ def lock_pair(pair: str, until: datetime, reason: str = None, *, now: datetime = None) -> None:
+ """
+ Create PairLock from now to "until".
+ Uses database by default, unless PairLocks.use_db is set to False,
+ in which case a list is maintained.
+ :param pair: pair to lock. use '*' to lock all pairs
+ :param until: End time of the lock. Will be rounded up to the next candle.
+ :param reason: Reason string that will be shown as reason for the lock
+ :param now: Current timestamp. Used to determine lock start time.
+ """
lock = PairLock(
pair=pair,
- lock_time=datetime.now(timezone.utc),
+ lock_time=now or datetime.now(timezone.utc),
lock_end_time=timeframe_to_next_date(PairLocks.timeframe, until),
reason=reason,
active=True
@@ -57,6 +74,15 @@ class PairLocks():
)]
return locks
+ @staticmethod
+ def get_pair_longest_lock(pair: str, now: Optional[datetime] = None) -> Optional[PairLock]:
+ """
+ Get the lock that expires the latest for the pair given.
+ """
+ locks = PairLocks.get_pair_locks(pair, now)
+ locks = sorted(locks, key=lambda l: l.lock_end_time, reverse=True)
+ return locks[0] if locks else None
+
@staticmethod
def unlock_pair(pair: str, now: Optional[datetime] = None) -> None:
"""
diff --git a/tests/pairlist/__init__.py b/freqtrade/plugins/__init__.py
similarity index 100%
rename from tests/pairlist/__init__.py
rename to freqtrade/plugins/__init__.py
diff --git a/freqtrade/plugins/protectionmanager.py b/freqtrade/plugins/protectionmanager.py
new file mode 100644
index 000000000..a8edd4e4b
--- /dev/null
+++ b/freqtrade/plugins/protectionmanager.py
@@ -0,0 +1,72 @@
+"""
+Protection manager class
+"""
+import logging
+from datetime import datetime, timezone
+from typing import Dict, List, Optional
+
+from freqtrade.persistence import PairLocks
+from freqtrade.plugins.protections import IProtection
+from freqtrade.resolvers import ProtectionResolver
+
+
+logger = logging.getLogger(__name__)
+
+
+class ProtectionManager():
+
+ def __init__(self, config: dict) -> None:
+ self._config = config
+
+ self._protection_handlers: List[IProtection] = []
+ for protection_handler_config in self._config.get('protections', []):
+ protection_handler = ProtectionResolver.load_protection(
+ protection_handler_config['method'],
+ config=config,
+ protection_config=protection_handler_config,
+ )
+ self._protection_handlers.append(protection_handler)
+
+ if not self._protection_handlers:
+ logger.info("No protection Handlers defined.")
+
+ @property
+ def name_list(self) -> List[str]:
+ """
+ Get list of loaded Protection Handler names
+ """
+ return [p.name for p in self._protection_handlers]
+
+ def short_desc(self) -> List[Dict]:
+ """
+ List of short_desc for each Pairlist Handler
+ """
+ return [{p.name: p.short_desc()} for p in self._protection_handlers]
+
+ def global_stop(self, now: Optional[datetime] = None) -> bool:
+ if not now:
+ now = datetime.now(timezone.utc)
+ result = False
+ for protection_handler in self._protection_handlers:
+ if protection_handler.has_global_stop:
+ result, until, reason = protection_handler.global_stop(now)
+
+ # Early stopping - first positive result blocks further trades
+ if result and until:
+ if not PairLocks.is_global_lock(until):
+ PairLocks.lock_pair('*', until, reason, now=now)
+ result = True
+ return result
+
+ def stop_per_pair(self, pair, now: Optional[datetime] = None) -> bool:
+ if not now:
+ now = datetime.now(timezone.utc)
+ result = False
+ for protection_handler in self._protection_handlers:
+ if protection_handler.has_local_stop:
+ result, until, reason = protection_handler.stop_per_pair(pair, now)
+ if result and until:
+ if not PairLocks.is_pair_locked(pair, until):
+ PairLocks.lock_pair(pair, until, reason, now=now)
+ result = True
+ return result
diff --git a/freqtrade/plugins/protections/__init__.py b/freqtrade/plugins/protections/__init__.py
new file mode 100644
index 000000000..936355052
--- /dev/null
+++ b/freqtrade/plugins/protections/__init__.py
@@ -0,0 +1,2 @@
+# flake8: noqa: F401
+from freqtrade.plugins.protections.iprotection import IProtection, ProtectionReturn
diff --git a/freqtrade/plugins/protections/cooldown_period.py b/freqtrade/plugins/protections/cooldown_period.py
new file mode 100644
index 000000000..2d7d7b4c7
--- /dev/null
+++ b/freqtrade/plugins/protections/cooldown_period.py
@@ -0,0 +1,72 @@
+
+import logging
+from datetime import datetime, timedelta
+from typing import Any, Dict
+
+from freqtrade.persistence import Trade
+from freqtrade.plugins.protections import IProtection, ProtectionReturn
+
+
+logger = logging.getLogger(__name__)
+
+
+class CooldownPeriod(IProtection):
+
+ has_global_stop: bool = False
+ has_local_stop: bool = True
+
+ def __init__(self, config: Dict[str, Any], protection_config: Dict[str, Any]) -> None:
+ super().__init__(config, protection_config)
+
+ def _reason(self) -> str:
+ """
+ LockReason to use
+ """
+ return (f'Cooldown period for {self.stop_duration_str}.')
+
+ def short_desc(self) -> str:
+ """
+ Short method description - used for startup-messages
+ """
+ return (f"{self.name} - Cooldown period of {self.stop_duration_str}.")
+
+ def _cooldown_period(self, pair: str, date_now: datetime, ) -> ProtectionReturn:
+ """
+ Get last trade for this pair
+ """
+ look_back_until = date_now - timedelta(minutes=self._stop_duration)
+ # filters = [
+ # Trade.is_open.is_(False),
+ # Trade.close_date > look_back_until,
+ # Trade.pair == pair,
+ # ]
+ # trade = Trade.get_trades(filters).first()
+ trades = Trade.get_trades_proxy(pair=pair, is_open=False, close_date=look_back_until)
+ if trades:
+ # Get latest trade
+ trade = sorted(trades, key=lambda t: t.close_date)[-1]
+ self.log_once(f"Cooldown for {pair} for {self.stop_duration_str}.", logger.info)
+ until = self.calculate_lock_end([trade], self._stop_duration)
+
+ return True, until, self._reason()
+
+ return False, None, None
+
+ def global_stop(self, date_now: datetime) -> ProtectionReturn:
+ """
+ Stops trading (position entering) for all pairs
+ This must evaluate to true for the whole period of the "cooldown period".
+ :return: Tuple of [bool, until, reason].
+ If true, all pairs will be locked with until
+ """
+ # Not implemented for cooldown period.
+ return False, None, None
+
+ def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn:
+ """
+ Stops trading (position entering) for this pair
+ This must evaluate to true for the whole period of the "cooldown period".
+ :return: Tuple of [bool, until, reason].
+ If true, this pair will be locked with until
+ """
+ return self._cooldown_period(pair, date_now)
diff --git a/freqtrade/plugins/protections/iprotection.py b/freqtrade/plugins/protections/iprotection.py
new file mode 100644
index 000000000..684bf6cd3
--- /dev/null
+++ b/freqtrade/plugins/protections/iprotection.py
@@ -0,0 +1,107 @@
+
+import logging
+from abc import ABC, abstractmethod
+from datetime import datetime, timedelta, timezone
+from typing import Any, Dict, List, Optional, Tuple
+
+from freqtrade.exchange import timeframe_to_minutes
+from freqtrade.misc import plural
+from freqtrade.mixins import LoggingMixin
+from freqtrade.persistence import Trade
+
+
+logger = logging.getLogger(__name__)
+
+ProtectionReturn = Tuple[bool, Optional[datetime], Optional[str]]
+
+
+class IProtection(LoggingMixin, ABC):
+
+ # Can globally stop the bot
+ has_global_stop: bool = False
+ # Can stop trading for one pair
+ has_local_stop: bool = False
+
+ def __init__(self, config: Dict[str, Any], protection_config: Dict[str, Any]) -> None:
+ self._config = config
+ self._protection_config = protection_config
+ tf_in_min = timeframe_to_minutes(config['timeframe'])
+ if 'stop_duration_candles' in protection_config:
+ self._stop_duration_candles = protection_config.get('stop_duration_candles', 1)
+ self._stop_duration = (tf_in_min * self._stop_duration_candles)
+ else:
+ self._stop_duration_candles = None
+ self._stop_duration = protection_config.get('stop_duration', 60)
+ if 'lookback_period_candles' in protection_config:
+ self._lookback_period_candles = protection_config.get('lookback_period_candles', 1)
+ self._lookback_period = tf_in_min * self._lookback_period_candles
+ else:
+ self._lookback_period_candles = None
+ self._lookback_period = protection_config.get('lookback_period', 60)
+
+ LoggingMixin.__init__(self, logger)
+
+ @property
+ def name(self) -> str:
+ return self.__class__.__name__
+
+ @property
+ def stop_duration_str(self) -> str:
+ """
+ Output configured stop duration in either candles or minutes
+ """
+ if self._stop_duration_candles:
+ return (f"{self._stop_duration_candles} "
+ f"{plural(self._stop_duration_candles, 'candle', 'candles')}")
+ else:
+ return (f"{self._stop_duration} "
+ f"{plural(self._stop_duration, 'minute', 'minutes')}")
+
+ @property
+ def lookback_period_str(self) -> str:
+ """
+ Output configured lookback period in either candles or minutes
+ """
+ if self._lookback_period_candles:
+ return (f"{self._lookback_period_candles} "
+ f"{plural(self._lookback_period_candles, 'candle', 'candles')}")
+ else:
+ return (f"{self._lookback_period} "
+ f"{plural(self._lookback_period, 'minute', 'minutes')}")
+
+ @abstractmethod
+ def short_desc(self) -> str:
+ """
+ Short method description - used for startup-messages
+ -> Please overwrite in subclasses
+ """
+
+ @abstractmethod
+ def global_stop(self, date_now: datetime) -> ProtectionReturn:
+ """
+ Stops trading (position entering) for all pairs
+ This must evaluate to true for the whole period of the "cooldown period".
+ """
+
+ @abstractmethod
+ def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn:
+ """
+ Stops trading (position entering) for this pair
+ This must evaluate to true for the whole period of the "cooldown period".
+ :return: Tuple of [bool, until, reason].
+ If true, this pair will be locked with until
+ """
+
+ @staticmethod
+ def calculate_lock_end(trades: List[Trade], stop_minutes: int) -> datetime:
+ """
+ Get lock end time
+ """
+ max_date: datetime = max([trade.close_date for trade in trades])
+ # comming from Database, tzinfo is not set.
+ if max_date.tzinfo is None:
+ max_date = max_date.replace(tzinfo=timezone.utc)
+
+ until = max_date + timedelta(minutes=stop_minutes)
+
+ return until
diff --git a/freqtrade/plugins/protections/low_profit_pairs.py b/freqtrade/plugins/protections/low_profit_pairs.py
new file mode 100644
index 000000000..9d5ed35b4
--- /dev/null
+++ b/freqtrade/plugins/protections/low_profit_pairs.py
@@ -0,0 +1,83 @@
+
+import logging
+from datetime import datetime, timedelta
+from typing import Any, Dict
+
+from freqtrade.persistence import Trade
+from freqtrade.plugins.protections import IProtection, ProtectionReturn
+
+
+logger = logging.getLogger(__name__)
+
+
+class LowProfitPairs(IProtection):
+
+ has_global_stop: bool = False
+ has_local_stop: bool = True
+
+ def __init__(self, config: Dict[str, Any], protection_config: Dict[str, Any]) -> None:
+ super().__init__(config, protection_config)
+
+ self._trade_limit = protection_config.get('trade_limit', 1)
+ self._required_profit = protection_config.get('required_profit', 0.0)
+
+ def short_desc(self) -> str:
+ """
+ Short method description - used for startup-messages
+ """
+ return (f"{self.name} - Low Profit Protection, locks pairs with "
+ f"profit < {self._required_profit} within {self.lookback_period_str}.")
+
+ def _reason(self, profit: float) -> str:
+ """
+ LockReason to use
+ """
+ return (f'{profit} < {self._required_profit} in {self.lookback_period_str}, '
+ f'locking for {self.stop_duration_str}.')
+
+ def _low_profit(self, date_now: datetime, pair: str) -> ProtectionReturn:
+ """
+ Evaluate recent trades for pair
+ """
+ look_back_until = date_now - timedelta(minutes=self._lookback_period)
+ # filters = [
+ # Trade.is_open.is_(False),
+ # Trade.close_date > look_back_until,
+ # ]
+ # if pair:
+ # filters.append(Trade.pair == pair)
+
+ trades = Trade.get_trades_proxy(pair=pair, is_open=False, close_date=look_back_until)
+ # trades = Trade.get_trades(filters).all()
+ if len(trades) < self._trade_limit:
+ # Not enough trades in the relevant period
+ return False, None, None
+
+ profit = sum(trade.close_profit for trade in trades)
+ if profit < self._required_profit:
+ self.log_once(
+ f"Trading for {pair} stopped due to {profit:.2f} < {self._required_profit} "
+ f"within {self._lookback_period} minutes.", logger.info)
+ until = self.calculate_lock_end(trades, self._stop_duration)
+
+ return True, until, self._reason(profit)
+
+ return False, None, None
+
+ def global_stop(self, date_now: datetime) -> ProtectionReturn:
+ """
+ Stops trading (position entering) for all pairs
+ This must evaluate to true for the whole period of the "cooldown period".
+ :return: Tuple of [bool, until, reason].
+ If true, all pairs will be locked with until
+ """
+ return False, None, None
+
+ def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn:
+ """
+ Stops trading (position entering) for this pair
+ This must evaluate to true for the whole period of the "cooldown period".
+ :return: Tuple of [bool, until, reason].
+ If true, this pair will be locked with until
+ """
+ return self._low_profit(date_now, pair=pair)
diff --git a/freqtrade/plugins/protections/max_drawdown_protection.py b/freqtrade/plugins/protections/max_drawdown_protection.py
new file mode 100644
index 000000000..d54e6699b
--- /dev/null
+++ b/freqtrade/plugins/protections/max_drawdown_protection.py
@@ -0,0 +1,88 @@
+
+import logging
+from datetime import datetime, timedelta
+from typing import Any, Dict
+
+import pandas as pd
+
+from freqtrade.data.btanalysis import calculate_max_drawdown
+from freqtrade.persistence import Trade
+from freqtrade.plugins.protections import IProtection, ProtectionReturn
+
+
+logger = logging.getLogger(__name__)
+
+
+class MaxDrawdown(IProtection):
+
+ has_global_stop: bool = True
+ has_local_stop: bool = False
+
+ def __init__(self, config: Dict[str, Any], protection_config: Dict[str, Any]) -> None:
+ super().__init__(config, protection_config)
+
+ self._trade_limit = protection_config.get('trade_limit', 1)
+ self._max_allowed_drawdown = protection_config.get('max_allowed_drawdown', 0.0)
+ # TODO: Implement checks to limit max_drawdown to sensible values
+
+ def short_desc(self) -> str:
+ """
+ Short method description - used for startup-messages
+ """
+ return (f"{self.name} - Max drawdown protection, stop trading if drawdown is > "
+ f"{self._max_allowed_drawdown} within {self.lookback_period_str}.")
+
+ def _reason(self, drawdown: float) -> str:
+ """
+ LockReason to use
+ """
+ return (f'{drawdown} > {self._max_allowed_drawdown} in {self.lookback_period_str}, '
+ f'locking for {self.stop_duration_str}.')
+
+ def _max_drawdown(self, date_now: datetime) -> ProtectionReturn:
+ """
+ Evaluate recent trades for drawdown ...
+ """
+ look_back_until = date_now - timedelta(minutes=self._lookback_period)
+
+ trades = Trade.get_trades_proxy(is_open=False, close_date=look_back_until)
+
+ trades_df = pd.DataFrame([trade.to_json() for trade in trades])
+
+ if len(trades) < self._trade_limit:
+ # Not enough trades in the relevant period
+ return False, None, None
+
+ # Drawdown is always positive
+ try:
+ drawdown, _, _ = calculate_max_drawdown(trades_df, value_col='close_profit')
+ except ValueError:
+ return False, None, None
+
+ if drawdown > self._max_allowed_drawdown:
+ self.log_once(
+ f"Trading stopped due to Max Drawdown {drawdown:.2f} < {self._max_allowed_drawdown}"
+ f" within {self.lookback_period_str}.", logger.info)
+ until = self.calculate_lock_end(trades, self._stop_duration)
+
+ return True, until, self._reason(drawdown)
+
+ return False, None, None
+
+ def global_stop(self, date_now: datetime) -> ProtectionReturn:
+ """
+ Stops trading (position entering) for all pairs
+ This must evaluate to true for the whole period of the "cooldown period".
+ :return: Tuple of [bool, until, reason].
+ If true, all pairs will be locked with until
+ """
+ return self._max_drawdown(date_now)
+
+ def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn:
+ """
+ Stops trading (position entering) for this pair
+ This must evaluate to true for the whole period of the "cooldown period".
+ :return: Tuple of [bool, until, reason].
+ If true, this pair will be locked with until
+ """
+ return False, None, None
diff --git a/freqtrade/plugins/protections/stoploss_guard.py b/freqtrade/plugins/protections/stoploss_guard.py
new file mode 100644
index 000000000..193907ddc
--- /dev/null
+++ b/freqtrade/plugins/protections/stoploss_guard.py
@@ -0,0 +1,86 @@
+
+import logging
+from datetime import datetime, timedelta
+from typing import Any, Dict
+
+from freqtrade.persistence import Trade
+from freqtrade.plugins.protections import IProtection, ProtectionReturn
+from freqtrade.strategy.interface import SellType
+
+
+logger = logging.getLogger(__name__)
+
+
+class StoplossGuard(IProtection):
+
+ has_global_stop: bool = True
+ has_local_stop: bool = True
+
+ def __init__(self, config: Dict[str, Any], protection_config: Dict[str, Any]) -> None:
+ super().__init__(config, protection_config)
+
+ self._trade_limit = protection_config.get('trade_limit', 10)
+ self._disable_global_stop = protection_config.get('only_per_pair', False)
+
+ def short_desc(self) -> str:
+ """
+ Short method description - used for startup-messages
+ """
+ return (f"{self.name} - Frequent Stoploss Guard, {self._trade_limit} stoplosses "
+ f"within {self.lookback_period_str}.")
+
+ def _reason(self) -> str:
+ """
+ LockReason to use
+ """
+ return (f'{self._trade_limit} stoplosses in {self._lookback_period} min, '
+ f'locking for {self._stop_duration} min.')
+
+ def _stoploss_guard(self, date_now: datetime, pair: str = None) -> ProtectionReturn:
+ """
+ Evaluate recent trades
+ """
+ look_back_until = date_now - timedelta(minutes=self._lookback_period)
+ # filters = [
+ # Trade.is_open.is_(False),
+ # Trade.close_date > look_back_until,
+ # or_(Trade.sell_reason == SellType.STOP_LOSS.value,
+ # and_(Trade.sell_reason == SellType.TRAILING_STOP_LOSS.value,
+ # Trade.close_profit < 0))
+ # ]
+ # if pair:
+ # filters.append(Trade.pair == pair)
+ # trades = Trade.get_trades(filters).all()
+
+ trades1 = Trade.get_trades_proxy(pair=pair, is_open=False, close_date=look_back_until)
+ trades = [trade for trade in trades1 if str(trade.sell_reason) == SellType.STOP_LOSS.value
+ or (str(trade.sell_reason) == SellType.TRAILING_STOP_LOSS.value
+ and trade.close_profit < 0)]
+
+ if len(trades) > self._trade_limit:
+ self.log_once(f"Trading stopped due to {self._trade_limit} "
+ f"stoplosses within {self._lookback_period} minutes.", logger.info)
+ until = self.calculate_lock_end(trades, self._stop_duration)
+ return True, until, self._reason()
+
+ return False, None, None
+
+ def global_stop(self, date_now: datetime) -> ProtectionReturn:
+ """
+ Stops trading (position entering) for all pairs
+ This must evaluate to true for the whole period of the "cooldown period".
+ :return: Tuple of [bool, until, reason].
+ If true, all pairs will be locked with until
+ """
+ if self._disable_global_stop:
+ return False, None, None
+ return self._stoploss_guard(date_now, None)
+
+ def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn:
+ """
+ Stops trading (position entering) for this pair
+ This must evaluate to true for the whole period of the "cooldown period".
+ :return: Tuple of [bool, until, reason].
+ If true, this pair will be locked with until
+ """
+ return self._stoploss_guard(date_now, pair)
diff --git a/freqtrade/resolvers/__init__.py b/freqtrade/resolvers/__init__.py
index b42ec4931..ef24bf481 100644
--- a/freqtrade/resolvers/__init__.py
+++ b/freqtrade/resolvers/__init__.py
@@ -6,6 +6,7 @@ from freqtrade.resolvers.exchange_resolver import ExchangeResolver
# Don't import HyperoptResolver to avoid loading the whole Optimize tree
# from freqtrade.resolvers.hyperopt_resolver import HyperOptResolver
from freqtrade.resolvers.pairlist_resolver import PairListResolver
+from freqtrade.resolvers.protection_resolver import ProtectionResolver
from freqtrade.resolvers.strategy_resolver import StrategyResolver
diff --git a/freqtrade/resolvers/protection_resolver.py b/freqtrade/resolvers/protection_resolver.py
new file mode 100644
index 000000000..c54ae1011
--- /dev/null
+++ b/freqtrade/resolvers/protection_resolver.py
@@ -0,0 +1,37 @@
+"""
+This module load custom pairlists
+"""
+import logging
+from pathlib import Path
+from typing import Dict
+
+from freqtrade.plugins.protections import IProtection
+from freqtrade.resolvers import IResolver
+
+
+logger = logging.getLogger(__name__)
+
+
+class ProtectionResolver(IResolver):
+ """
+ This class contains all the logic to load custom PairList class
+ """
+ object_type = IProtection
+ object_type_str = "Protection"
+ user_subdir = None
+ initial_search_path = Path(__file__).parent.parent.joinpath('plugins/protections').resolve()
+
+ @staticmethod
+ def load_protection(protection_name: str, config: Dict, protection_config: Dict) -> IProtection:
+ """
+ Load the protection with protection_name
+ :param protection_name: Classname of the pairlist
+ :param config: configuration dictionary
+ :param protection_config: Configuration dedicated to this pairlist
+ :return: initialized Protection class
+ """
+ return ProtectionResolver.load_object(protection_name, config,
+ kwargs={'config': config,
+ 'protection_config': protection_config,
+ },
+ )
diff --git a/freqtrade/rpc/rpc_manager.py b/freqtrade/rpc/rpc_manager.py
index b97a5357b..c42878f99 100644
--- a/freqtrade/rpc/rpc_manager.py
+++ b/freqtrade/rpc/rpc_manager.py
@@ -62,7 +62,7 @@ class RPCManager:
except NotImplementedError:
logger.error(f"Message type '{msg['type']}' not implemented by handler {mod.name}.")
- def startup_messages(self, config: Dict[str, Any], pairlist) -> None:
+ def startup_messages(self, config: Dict[str, Any], pairlist, protections) -> None:
if config['dry_run']:
self.send_msg({
'type': RPCMessageType.WARNING_NOTIFICATION,
@@ -90,3 +90,9 @@ class RPCManager:
'status': f'Searching for {stake_currency} pairs to buy and sell '
f'based on {pairlist.short_desc()}'
})
+ if len(protections.name_list) > 0:
+ prots = '\n'.join([p for prot in protections.short_desc() for k, p in prot.items()])
+ self.send_msg({
+ 'type': RPCMessageType.STARTUP_NOTIFICATION,
+ 'status': f'Using Protections: \n{prots}'
+ })
diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py
index 125211a85..027c5d36e 100644
--- a/freqtrade/strategy/interface.py
+++ b/freqtrade/strategy/interface.py
@@ -312,7 +312,7 @@ class IStrategy(ABC):
if not candle_date:
# Simple call ...
- return PairLocks.is_pair_locked(pair, candle_date)
+ return PairLocks.is_pair_locked(pair)
else:
lock_time = timeframe_to_next_date(self.timeframe, candle_date)
return PairLocks.is_pair_locked(pair, lock_time)
diff --git a/mkdocs.yml b/mkdocs.yml
index c791386ae..a7ae0cc96 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -19,6 +19,7 @@ nav:
- Backtesting: backtesting.md
- Hyperopt: hyperopt.md
- Edge Positioning: edge.md
+ - Plugins: plugins.md
- Utility Subcommands: utils.md
- FAQ: faq.md
- Data Analysis:
diff --git a/tests/optimize/test_backtesting.py b/tests/optimize/test_backtesting.py
index 45cbea68e..547e55db8 100644
--- a/tests/optimize/test_backtesting.py
+++ b/tests/optimize/test_backtesting.py
@@ -79,7 +79,7 @@ def load_data_test(what, testdatadir):
fill_missing=True)}
-def simple_backtest(config, contour, num_results, mocker, testdatadir) -> None:
+def simple_backtest(config, contour, mocker, testdatadir) -> None:
patch_exchange(mocker)
config['timeframe'] = '1m'
backtesting = Backtesting(config)
@@ -95,9 +95,10 @@ def simple_backtest(config, contour, num_results, mocker, testdatadir) -> None:
end_date=max_date,
max_open_trades=1,
position_stacking=False,
+ enable_protections=config.get('enable_protections', False),
)
# results ::
- assert len(results) == num_results
+ return results
# FIX: fixturize this?
@@ -531,13 +532,52 @@ def test_processed(default_conf, mocker, testdatadir) -> None:
assert col in cols
-def test_backtest_pricecontours(default_conf, fee, mocker, testdatadir) -> None:
- # TODO: Evaluate usefullness of this, the patterns and buy-signls are unrealistic
- mocker.patch('freqtrade.exchange.Exchange.get_fee', fee)
- tests = [['raise', 19], ['lower', 0], ['sine', 35]]
+def test_backtest_pricecontours_protections(default_conf, fee, mocker, testdatadir) -> None:
+ # While this test IS a copy of test_backtest_pricecontours, it's needed to ensure
+ # results do not carry-over to the next run, which is not given by using parametrize.
+ default_conf['protections'] = [
+ {
+ "method": "CooldownPeriod",
+ "stop_duration": 3,
+ }]
+ default_conf['enable_protections'] = True
+ mocker.patch('freqtrade.exchange.Exchange.get_fee', fee)
+ tests = [
+ ['sine', 9],
+ ['raise', 10],
+ ['lower', 0],
+ ['sine', 9],
+ ['raise', 10],
+ ]
+ # While buy-signals are unrealistic, running backtesting
+ # over and over again should not cause different results
for [contour, numres] in tests:
- simple_backtest(default_conf, contour, numres, mocker, testdatadir)
+ assert len(simple_backtest(default_conf, contour, mocker, testdatadir)) == numres
+
+
+@pytest.mark.parametrize('protections,contour,expected', [
+ (None, 'sine', 35),
+ (None, 'raise', 19),
+ (None, 'lower', 0),
+ (None, 'sine', 35),
+ (None, 'raise', 19),
+ ([{"method": "CooldownPeriod", "stop_duration": 3}], 'sine', 9),
+ ([{"method": "CooldownPeriod", "stop_duration": 3}], 'raise', 10),
+ ([{"method": "CooldownPeriod", "stop_duration": 3}], 'lower', 0),
+ ([{"method": "CooldownPeriod", "stop_duration": 3}], 'sine', 9),
+ ([{"method": "CooldownPeriod", "stop_duration": 3}], 'raise', 10),
+])
+def test_backtest_pricecontours(default_conf, fee, mocker, testdatadir,
+ protections, contour, expected) -> None:
+ if protections:
+ default_conf['protections'] = protections
+ default_conf['enable_protections'] = True
+
+ mocker.patch('freqtrade.exchange.Exchange.get_fee', fee)
+ # While buy-signals are unrealistic, running backtesting
+ # over and over again should not cause different results
+ assert len(simple_backtest(default_conf, contour, mocker, testdatadir)) == expected
def test_backtest_clash_buy_sell(mocker, default_conf, testdatadir):
diff --git a/tests/optimize/test_optimize_reports.py b/tests/optimize/test_optimize_reports.py
index d04929164..a0e1932ff 100644
--- a/tests/optimize/test_optimize_reports.py
+++ b/tests/optimize/test_optimize_reports.py
@@ -76,7 +76,8 @@ def test_generate_backtest_stats(default_conf, testdatadir):
"sell_reason": [SellType.ROI, SellType.STOP_LOSS,
SellType.ROI, SellType.FORCE_SELL]
}),
- 'config': default_conf}
+ 'config': default_conf,
+ 'locks': []}
}
timerange = TimeRange.parse_timerange('1510688220-1510700340')
min_date = Arrow.fromtimestamp(1510688220)
diff --git a/tests/plugins/__init__.py b/tests/plugins/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/pairlist/test_pairlist.py b/tests/plugins/test_pairlist.py
similarity index 99%
rename from tests/pairlist/test_pairlist.py
rename to tests/plugins/test_pairlist.py
index 1d2f16b45..c2a4a69d7 100644
--- a/tests/pairlist/test_pairlist.py
+++ b/tests/plugins/test_pairlist.py
@@ -92,7 +92,7 @@ def static_pl_conf(whitelist_conf):
return whitelist_conf
-def test_log_on_refresh(mocker, static_pl_conf, markets, tickers):
+def test_log_cached(mocker, static_pl_conf, markets, tickers):
mocker.patch.multiple('freqtrade.exchange.Exchange',
markets=PropertyMock(return_value=markets),
exchange_has=MagicMock(return_value=True),
@@ -102,14 +102,14 @@ def test_log_on_refresh(mocker, static_pl_conf, markets, tickers):
logmock = MagicMock()
# Assign starting whitelist
pl = freqtrade.pairlists._pairlist_handlers[0]
- pl.log_on_refresh(logmock, 'Hello world')
+ pl.log_once('Hello world', logmock)
assert logmock.call_count == 1
- pl.log_on_refresh(logmock, 'Hello world')
+ pl.log_once('Hello world', logmock)
assert logmock.call_count == 1
assert pl._log_cache.currsize == 1
assert ('Hello world',) in pl._log_cache._Cache__data
- pl.log_on_refresh(logmock, 'Hello world2')
+ pl.log_once('Hello world2', logmock)
assert logmock.call_count == 2
assert pl._log_cache.currsize == 2
diff --git a/tests/pairlist/test_pairlocks.py b/tests/plugins/test_pairlocks.py
similarity index 70%
rename from tests/pairlist/test_pairlocks.py
rename to tests/plugins/test_pairlocks.py
index 0b6b89717..bd103b21e 100644
--- a/tests/pairlist/test_pairlocks.py
+++ b/tests/plugins/test_pairlocks.py
@@ -79,4 +79,38 @@ def test_PairLocks(use_db):
# Nothing was pushed to the database
assert len(PairLock.query.all()) == 0
# Reset use-db variable
+ PairLocks.reset_locks()
+ PairLocks.use_db = True
+
+
+@pytest.mark.parametrize('use_db', (False, True))
+@pytest.mark.usefixtures("init_persistence")
+def test_PairLocks_getlongestlock(use_db):
+ PairLocks.timeframe = '5m'
+ # No lock should be present
+ if use_db:
+ assert len(PairLock.query.all()) == 0
+ else:
+ PairLocks.use_db = False
+
+ assert PairLocks.use_db == use_db
+
+ pair = 'ETH/BTC'
+ assert not PairLocks.is_pair_locked(pair)
+ PairLocks.lock_pair(pair, arrow.utcnow().shift(minutes=4).datetime)
+ # ETH/BTC locked for 4 minutes
+ assert PairLocks.is_pair_locked(pair)
+ lock = PairLocks.get_pair_longest_lock(pair)
+
+ assert lock.lock_end_time.replace(tzinfo=timezone.utc) > arrow.utcnow().shift(minutes=3)
+ assert lock.lock_end_time.replace(tzinfo=timezone.utc) < arrow.utcnow().shift(minutes=14)
+
+ PairLocks.lock_pair(pair, arrow.utcnow().shift(minutes=15).datetime)
+ assert PairLocks.is_pair_locked(pair)
+
+ lock = PairLocks.get_pair_longest_lock(pair)
+ # Must be longer than above
+ assert lock.lock_end_time.replace(tzinfo=timezone.utc) > arrow.utcnow().shift(minutes=14)
+
+ PairLocks.reset_locks()
PairLocks.use_db = True
diff --git a/tests/plugins/test_protections.py b/tests/plugins/test_protections.py
new file mode 100644
index 000000000..e36900a96
--- /dev/null
+++ b/tests/plugins/test_protections.py
@@ -0,0 +1,412 @@
+import random
+from datetime import datetime, timedelta
+
+import pytest
+
+from freqtrade import constants
+from freqtrade.persistence import PairLocks, Trade
+from freqtrade.plugins.protectionmanager import ProtectionManager
+from freqtrade.strategy.interface import SellType
+from tests.conftest import get_patched_freqtradebot, log_has_re
+
+
+def generate_mock_trade(pair: str, fee: float, is_open: bool,
+ sell_reason: str = SellType.SELL_SIGNAL,
+ min_ago_open: int = None, min_ago_close: int = None,
+ profit_rate: float = 0.9
+ ):
+ open_rate = random.random()
+
+ trade = Trade(
+ pair=pair,
+ stake_amount=0.01,
+ fee_open=fee,
+ fee_close=fee,
+ open_date=datetime.utcnow() - timedelta(minutes=min_ago_open or 200),
+ close_date=datetime.utcnow() - timedelta(minutes=min_ago_close or 30),
+ open_rate=open_rate,
+ is_open=is_open,
+ amount=0.01 / open_rate,
+ exchange='bittrex',
+ )
+ trade.recalc_open_trade_value()
+ if not is_open:
+ trade.close(open_rate * profit_rate)
+ trade.sell_reason = sell_reason
+
+ return trade
+
+
+def test_protectionmanager(mocker, default_conf):
+ default_conf['protections'] = [{'method': protection}
+ for protection in constants.AVAILABLE_PROTECTIONS]
+ freqtrade = get_patched_freqtradebot(mocker, default_conf)
+
+ for handler in freqtrade.protections._protection_handlers:
+ assert handler.name in constants.AVAILABLE_PROTECTIONS
+ if not handler.has_global_stop:
+ assert handler.global_stop(datetime.utcnow()) == (False, None, None)
+ if not handler.has_local_stop:
+ assert handler.stop_per_pair('XRP/BTC', datetime.utcnow()) == (False, None, None)
+
+
+@pytest.mark.parametrize('timeframe,expected,protconf', [
+ ('1m', [20, 10],
+ [{"method": "StoplossGuard", "lookback_period_candles": 20, "stop_duration": 10}]),
+ ('5m', [100, 15],
+ [{"method": "StoplossGuard", "lookback_period_candles": 20, "stop_duration": 15}]),
+ ('1h', [1200, 40],
+ [{"method": "StoplossGuard", "lookback_period_candles": 20, "stop_duration": 40}]),
+ ('1d', [1440, 5],
+ [{"method": "StoplossGuard", "lookback_period_candles": 1, "stop_duration": 5}]),
+ ('1m', [20, 5],
+ [{"method": "StoplossGuard", "lookback_period": 20, "stop_duration_candles": 5}]),
+ ('5m', [15, 25],
+ [{"method": "StoplossGuard", "lookback_period": 15, "stop_duration_candles": 5}]),
+ ('1h', [50, 600],
+ [{"method": "StoplossGuard", "lookback_period": 50, "stop_duration_candles": 10}]),
+ ('1h', [60, 540],
+ [{"method": "StoplossGuard", "lookback_period_candles": 1, "stop_duration_candles": 9}]),
+])
+def test_protections_init(mocker, default_conf, timeframe, expected, protconf):
+ default_conf['timeframe'] = timeframe
+ default_conf['protections'] = protconf
+ man = ProtectionManager(default_conf)
+ assert len(man._protection_handlers) == len(protconf)
+ assert man._protection_handlers[0]._lookback_period == expected[0]
+ assert man._protection_handlers[0]._stop_duration == expected[1]
+
+
+@pytest.mark.usefixtures("init_persistence")
+def test_stoploss_guard(mocker, default_conf, fee, caplog):
+ default_conf['protections'] = [{
+ "method": "StoplossGuard",
+ "lookback_period": 60,
+ "stop_duration": 40,
+ "trade_limit": 2
+ }]
+ freqtrade = get_patched_freqtradebot(mocker, default_conf)
+ message = r"Trading stopped due to .*"
+ assert not freqtrade.protections.global_stop()
+ assert not log_has_re(message, caplog)
+ caplog.clear()
+
+ Trade.session.add(generate_mock_trade(
+ 'XRP/BTC', fee.return_value, False, sell_reason=SellType.STOP_LOSS.value,
+ min_ago_open=200, min_ago_close=30,
+ ))
+
+ assert not freqtrade.protections.global_stop()
+ assert not log_has_re(message, caplog)
+ caplog.clear()
+ # This trade does not count, as it's closed too long ago
+ Trade.session.add(generate_mock_trade(
+ 'BCH/BTC', fee.return_value, False, sell_reason=SellType.STOP_LOSS.value,
+ min_ago_open=250, min_ago_close=100,
+ ))
+
+ Trade.session.add(generate_mock_trade(
+ 'ETH/BTC', fee.return_value, False, sell_reason=SellType.STOP_LOSS.value,
+ min_ago_open=240, min_ago_close=30,
+ ))
+ # 3 Trades closed - but the 2nd has been closed too long ago.
+ assert not freqtrade.protections.global_stop()
+ assert not log_has_re(message, caplog)
+ caplog.clear()
+
+ Trade.session.add(generate_mock_trade(
+ 'LTC/BTC', fee.return_value, False, sell_reason=SellType.STOP_LOSS.value,
+ min_ago_open=180, min_ago_close=30,
+ ))
+
+ assert freqtrade.protections.global_stop()
+ assert log_has_re(message, caplog)
+ assert PairLocks.is_global_lock()
+
+ # Test 5m after lock-period - this should try and relock the pair, but end-time
+ # should be the previous end-time
+ end_time = PairLocks.get_pair_longest_lock('*').lock_end_time + timedelta(minutes=5)
+ assert freqtrade.protections.global_stop(end_time)
+ assert not PairLocks.is_global_lock(end_time)
+
+
+@pytest.mark.parametrize('only_per_pair', [False, True])
+@pytest.mark.usefixtures("init_persistence")
+def test_stoploss_guard_perpair(mocker, default_conf, fee, caplog, only_per_pair):
+ default_conf['protections'] = [{
+ "method": "StoplossGuard",
+ "lookback_period": 60,
+ "trade_limit": 1,
+ "stop_duration": 60,
+ "only_per_pair": only_per_pair
+ }]
+ freqtrade = get_patched_freqtradebot(mocker, default_conf)
+ message = r"Trading stopped due to .*"
+ pair = 'XRP/BTC'
+ assert not freqtrade.protections.stop_per_pair(pair)
+ assert not freqtrade.protections.global_stop()
+ assert not log_has_re(message, caplog)
+ caplog.clear()
+
+ Trade.session.add(generate_mock_trade(
+ pair, fee.return_value, False, sell_reason=SellType.STOP_LOSS.value,
+ min_ago_open=200, min_ago_close=30, profit_rate=0.9,
+ ))
+
+ assert not freqtrade.protections.stop_per_pair(pair)
+ assert not freqtrade.protections.global_stop()
+ assert not log_has_re(message, caplog)
+ caplog.clear()
+ # This trade does not count, as it's closed too long ago
+ Trade.session.add(generate_mock_trade(
+ pair, fee.return_value, False, sell_reason=SellType.STOP_LOSS.value,
+ min_ago_open=250, min_ago_close=100, profit_rate=0.9,
+ ))
+ # Trade does not count for per pair stop as it's the wrong pair.
+ Trade.session.add(generate_mock_trade(
+ 'ETH/BTC', fee.return_value, False, sell_reason=SellType.STOP_LOSS.value,
+ min_ago_open=240, min_ago_close=30, profit_rate=0.9,
+ ))
+ # 3 Trades closed - but the 2nd has been closed too long ago.
+ assert not freqtrade.protections.stop_per_pair(pair)
+ assert freqtrade.protections.global_stop() != only_per_pair
+ if not only_per_pair:
+ assert log_has_re(message, caplog)
+ else:
+ assert not log_has_re(message, caplog)
+
+ caplog.clear()
+
+ # 2nd Trade that counts with correct pair
+ Trade.session.add(generate_mock_trade(
+ pair, fee.return_value, False, sell_reason=SellType.STOP_LOSS.value,
+ min_ago_open=180, min_ago_close=30, profit_rate=0.9,
+ ))
+
+ assert freqtrade.protections.stop_per_pair(pair)
+ assert freqtrade.protections.global_stop() != only_per_pair
+ assert PairLocks.is_pair_locked(pair)
+ assert PairLocks.is_global_lock() != only_per_pair
+
+
+@pytest.mark.usefixtures("init_persistence")
+def test_CooldownPeriod(mocker, default_conf, fee, caplog):
+ default_conf['protections'] = [{
+ "method": "CooldownPeriod",
+ "stop_duration": 60,
+ }]
+ freqtrade = get_patched_freqtradebot(mocker, default_conf)
+ message = r"Trading stopped due to .*"
+ assert not freqtrade.protections.global_stop()
+ assert not freqtrade.protections.stop_per_pair('XRP/BTC')
+
+ assert not log_has_re(message, caplog)
+ caplog.clear()
+
+ Trade.session.add(generate_mock_trade(
+ 'XRP/BTC', fee.return_value, False, sell_reason=SellType.STOP_LOSS.value,
+ min_ago_open=200, min_ago_close=30,
+ ))
+
+ assert not freqtrade.protections.global_stop()
+ assert freqtrade.protections.stop_per_pair('XRP/BTC')
+ assert PairLocks.is_pair_locked('XRP/BTC')
+ assert not PairLocks.is_global_lock()
+
+ Trade.session.add(generate_mock_trade(
+ 'ETH/BTC', fee.return_value, False, sell_reason=SellType.ROI.value,
+ min_ago_open=205, min_ago_close=35,
+ ))
+
+ assert not freqtrade.protections.global_stop()
+ assert not PairLocks.is_pair_locked('ETH/BTC')
+ assert freqtrade.protections.stop_per_pair('ETH/BTC')
+ assert PairLocks.is_pair_locked('ETH/BTC')
+ assert not PairLocks.is_global_lock()
+
+
+@pytest.mark.usefixtures("init_persistence")
+def test_LowProfitPairs(mocker, default_conf, fee, caplog):
+ default_conf['protections'] = [{
+ "method": "LowProfitPairs",
+ "lookback_period": 400,
+ "stop_duration": 60,
+ "trade_limit": 2,
+ "required_profit": 0.0,
+ }]
+ freqtrade = get_patched_freqtradebot(mocker, default_conf)
+ message = r"Trading stopped due to .*"
+ assert not freqtrade.protections.global_stop()
+ assert not freqtrade.protections.stop_per_pair('XRP/BTC')
+
+ assert not log_has_re(message, caplog)
+ caplog.clear()
+
+ Trade.session.add(generate_mock_trade(
+ 'XRP/BTC', fee.return_value, False, sell_reason=SellType.STOP_LOSS.value,
+ min_ago_open=800, min_ago_close=450, profit_rate=0.9,
+ ))
+
+ # Not locked with 1 trade
+ assert not freqtrade.protections.global_stop()
+ assert not freqtrade.protections.stop_per_pair('XRP/BTC')
+ assert not PairLocks.is_pair_locked('XRP/BTC')
+ assert not PairLocks.is_global_lock()
+
+ Trade.session.add(generate_mock_trade(
+ 'XRP/BTC', fee.return_value, False, sell_reason=SellType.STOP_LOSS.value,
+ min_ago_open=200, min_ago_close=120, profit_rate=0.9,
+ ))
+
+ # Not locked with 1 trade (first trade is outside of lookback_period)
+ assert not freqtrade.protections.global_stop()
+ assert not freqtrade.protections.stop_per_pair('XRP/BTC')
+ assert not PairLocks.is_pair_locked('XRP/BTC')
+ assert not PairLocks.is_global_lock()
+
+ # Add positive trade
+ Trade.session.add(generate_mock_trade(
+ 'XRP/BTC', fee.return_value, False, sell_reason=SellType.ROI.value,
+ min_ago_open=20, min_ago_close=10, profit_rate=1.15,
+ ))
+ assert not freqtrade.protections.stop_per_pair('XRP/BTC')
+ assert not PairLocks.is_pair_locked('XRP/BTC')
+
+ Trade.session.add(generate_mock_trade(
+ 'XRP/BTC', fee.return_value, False, sell_reason=SellType.STOP_LOSS.value,
+ min_ago_open=110, min_ago_close=20, profit_rate=0.8,
+ ))
+
+ # Locks due to 2nd trade
+ assert not freqtrade.protections.global_stop()
+ assert freqtrade.protections.stop_per_pair('XRP/BTC')
+ assert PairLocks.is_pair_locked('XRP/BTC')
+ assert not PairLocks.is_global_lock()
+
+
+@pytest.mark.usefixtures("init_persistence")
+def test_MaxDrawdown(mocker, default_conf, fee, caplog):
+ default_conf['protections'] = [{
+ "method": "MaxDrawdown",
+ "lookback_period": 1000,
+ "stop_duration": 60,
+ "trade_limit": 3,
+ "max_allowed_drawdown": 0.15
+ }]
+ freqtrade = get_patched_freqtradebot(mocker, default_conf)
+ message = r"Trading stopped due to Max.*"
+
+ assert not freqtrade.protections.global_stop()
+ assert not freqtrade.protections.stop_per_pair('XRP/BTC')
+ caplog.clear()
+
+ Trade.session.add(generate_mock_trade(
+ 'XRP/BTC', fee.return_value, False, sell_reason=SellType.STOP_LOSS.value,
+ min_ago_open=1000, min_ago_close=900, profit_rate=1.1,
+ ))
+ Trade.session.add(generate_mock_trade(
+ 'ETH/BTC', fee.return_value, False, sell_reason=SellType.STOP_LOSS.value,
+ min_ago_open=1000, min_ago_close=900, profit_rate=1.1,
+ ))
+ Trade.session.add(generate_mock_trade(
+ 'NEO/BTC', fee.return_value, False, sell_reason=SellType.STOP_LOSS.value,
+ min_ago_open=1000, min_ago_close=900, profit_rate=1.1,
+ ))
+ # No losing trade yet ... so max_drawdown will raise exception
+ assert not freqtrade.protections.global_stop()
+ assert not freqtrade.protections.stop_per_pair('XRP/BTC')
+
+ Trade.session.add(generate_mock_trade(
+ 'XRP/BTC', fee.return_value, False, sell_reason=SellType.STOP_LOSS.value,
+ min_ago_open=500, min_ago_close=400, profit_rate=0.9,
+ ))
+ # Not locked with one trade
+ assert not freqtrade.protections.global_stop()
+ assert not freqtrade.protections.stop_per_pair('XRP/BTC')
+ assert not PairLocks.is_pair_locked('XRP/BTC')
+ assert not PairLocks.is_global_lock()
+
+ Trade.session.add(generate_mock_trade(
+ 'XRP/BTC', fee.return_value, False, sell_reason=SellType.STOP_LOSS.value,
+ min_ago_open=1200, min_ago_close=1100, profit_rate=0.5,
+ ))
+
+ # Not locked with 1 trade (2nd trade is outside of lookback_period)
+ assert not freqtrade.protections.global_stop()
+ assert not freqtrade.protections.stop_per_pair('XRP/BTC')
+ assert not PairLocks.is_pair_locked('XRP/BTC')
+ assert not PairLocks.is_global_lock()
+ assert not log_has_re(message, caplog)
+
+ # Winning trade ... (should not lock, does not change drawdown!)
+ Trade.session.add(generate_mock_trade(
+ 'XRP/BTC', fee.return_value, False, sell_reason=SellType.ROI.value,
+ min_ago_open=320, min_ago_close=410, profit_rate=1.5,
+ ))
+ assert not freqtrade.protections.global_stop()
+ assert not PairLocks.is_global_lock()
+
+ caplog.clear()
+
+ # Add additional negative trade, causing a loss of > 15%
+ Trade.session.add(generate_mock_trade(
+ 'XRP/BTC', fee.return_value, False, sell_reason=SellType.ROI.value,
+ min_ago_open=20, min_ago_close=10, profit_rate=0.8,
+ ))
+ assert not freqtrade.protections.stop_per_pair('XRP/BTC')
+ # local lock not supported
+ assert not PairLocks.is_pair_locked('XRP/BTC')
+ assert freqtrade.protections.global_stop()
+ assert PairLocks.is_global_lock()
+ assert log_has_re(message, caplog)
+
+
+@pytest.mark.parametrize("protectionconf,desc_expected,exception_expected", [
+ ({"method": "StoplossGuard", "lookback_period": 60, "trade_limit": 2, "stop_duration": 60},
+ "[{'StoplossGuard': 'StoplossGuard - Frequent Stoploss Guard, "
+ "2 stoplosses within 60 minutes.'}]",
+ None
+ ),
+ ({"method": "CooldownPeriod", "stop_duration": 60},
+ "[{'CooldownPeriod': 'CooldownPeriod - Cooldown period of 60 minutes.'}]",
+ None
+ ),
+ ({"method": "LowProfitPairs", "lookback_period": 60, "stop_duration": 60},
+ "[{'LowProfitPairs': 'LowProfitPairs - Low Profit Protection, locks pairs with "
+ "profit < 0.0 within 60 minutes.'}]",
+ None
+ ),
+ ({"method": "MaxDrawdown", "lookback_period": 60, "stop_duration": 60},
+ "[{'MaxDrawdown': 'MaxDrawdown - Max drawdown protection, stop trading if drawdown is > 0.0 "
+ "within 60 minutes.'}]",
+ None
+ ),
+ ({"method": "StoplossGuard", "lookback_period_candles": 12, "trade_limit": 2,
+ "stop_duration": 60},
+ "[{'StoplossGuard': 'StoplossGuard - Frequent Stoploss Guard, "
+ "2 stoplosses within 12 candles.'}]",
+ None
+ ),
+ ({"method": "CooldownPeriod", "stop_duration_candles": 5},
+ "[{'CooldownPeriod': 'CooldownPeriod - Cooldown period of 5 candles.'}]",
+ None
+ ),
+ ({"method": "LowProfitPairs", "lookback_period_candles": 11, "stop_duration": 60},
+ "[{'LowProfitPairs': 'LowProfitPairs - Low Profit Protection, locks pairs with "
+ "profit < 0.0 within 11 candles.'}]",
+ None
+ ),
+ ({"method": "MaxDrawdown", "lookback_period_candles": 20, "stop_duration": 60},
+ "[{'MaxDrawdown': 'MaxDrawdown - Max drawdown protection, stop trading if drawdown is > 0.0 "
+ "within 20 candles.'}]",
+ None
+ ),
+])
+def test_protection_manager_desc(mocker, default_conf, protectionconf,
+ desc_expected, exception_expected):
+
+ default_conf['protections'] = [protectionconf]
+ freqtrade = get_patched_freqtradebot(mocker, default_conf)
+
+ short_desc = str(freqtrade.protections.short_desc())
+ assert short_desc == desc_expected
diff --git a/tests/rpc/test_rpc_manager.py b/tests/rpc/test_rpc_manager.py
index 4b715fc37..06706120f 100644
--- a/tests/rpc/test_rpc_manager.py
+++ b/tests/rpc/test_rpc_manager.py
@@ -137,7 +137,7 @@ def test_startupmessages_telegram_enabled(mocker, default_conf, caplog) -> None:
freqtradebot = get_patched_freqtradebot(mocker, default_conf)
rpc_manager = RPCManager(freqtradebot)
- rpc_manager.startup_messages(default_conf, freqtradebot.pairlists)
+ rpc_manager.startup_messages(default_conf, freqtradebot.pairlists, freqtradebot.protections)
assert telegram_mock.call_count == 3
assert "*Exchange:* `bittrex`" in telegram_mock.call_args_list[1][0][0]['status']
@@ -147,10 +147,14 @@ def test_startupmessages_telegram_enabled(mocker, default_conf, caplog) -> None:
default_conf['whitelist'] = {'method': 'VolumePairList',
'config': {'number_assets': 20}
}
+ default_conf['protections'] = [{"method": "StoplossGuard",
+ "lookback_period": 60, "trade_limit": 2, "stop_duration": 60}]
+ freqtradebot = get_patched_freqtradebot(mocker, default_conf)
- rpc_manager.startup_messages(default_conf, freqtradebot.pairlists)
- assert telegram_mock.call_count == 3
+ rpc_manager.startup_messages(default_conf, freqtradebot.pairlists, freqtradebot.protections)
+ assert telegram_mock.call_count == 4
assert "Dry run is enabled." in telegram_mock.call_args_list[0][0][0]['status']
+ assert 'StoplossGuard' in telegram_mock.call_args_list[-1][0][0]['status']
def test_init_apiserver_disabled(mocker, default_conf, caplog) -> None:
diff --git a/tests/test_configuration.py b/tests/test_configuration.py
index 167215f29..bebbc1508 100644
--- a/tests/test_configuration.py
+++ b/tests/test_configuration.py
@@ -880,6 +880,25 @@ def test_validate_whitelist(default_conf):
validate_config_consistency(conf)
+@pytest.mark.parametrize('protconf,expected', [
+ ([], None),
+ ([{"method": "StoplossGuard", "lookback_period": 2000, "stop_duration_candles": 10}], None),
+ ([{"method": "StoplossGuard", "lookback_period_candles": 20, "stop_duration": 10}], None),
+ ([{"method": "StoplossGuard", "lookback_period_candles": 20, "lookback_period": 2000,
+ "stop_duration": 10}], r'Protections must specify either `lookback_period`.*'),
+ ([{"method": "StoplossGuard", "lookback_period": 20, "stop_duration": 10,
+ "stop_duration_candles": 10}], r'Protections must specify either `stop_duration`.*'),
+])
+def test_validate_protections(default_conf, protconf, expected):
+ conf = deepcopy(default_conf)
+ conf['protections'] = protconf
+ if expected:
+ with pytest.raises(OperationalException, match=expected):
+ validate_config_consistency(conf)
+ else:
+ validate_config_consistency(conf)
+
+
def test_load_config_test_comments() -> None:
"""
Load config with comments
diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py
index 459a09c0c..12be5ae8b 100644
--- a/tests/test_freqtradebot.py
+++ b/tests/test_freqtradebot.py
@@ -15,7 +15,7 @@ from freqtrade.exceptions import (DependencyException, ExchangeError, Insufficie
InvalidOrderException, OperationalException, PricingError,
TemporaryError)
from freqtrade.freqtradebot import FreqtradeBot
-from freqtrade.persistence import Order, Trade
+from freqtrade.persistence import Order, PairLocks, Trade
from freqtrade.persistence.models import PairLock
from freqtrade.rpc import RPCMessageType
from freqtrade.state import RunMode, State
@@ -678,6 +678,32 @@ def test_enter_positions_no_pairs_in_whitelist(default_conf, ticker, limit_buy_o
assert log_has("Active pair whitelist is empty.", caplog)
+@pytest.mark.usefixtures("init_persistence")
+def test_enter_positions_global_pairlock(default_conf, ticker, limit_buy_order, fee,
+ mocker, caplog) -> None:
+ patch_RPCManager(mocker)
+ patch_exchange(mocker)
+ mocker.patch.multiple(
+ 'freqtrade.exchange.Exchange',
+ fetch_ticker=ticker,
+ buy=MagicMock(return_value={'id': limit_buy_order['id']}),
+ get_fee=fee,
+ )
+ freqtrade = FreqtradeBot(default_conf)
+ patch_get_signal(freqtrade)
+ n = freqtrade.enter_positions()
+ message = r"Global pairlock active until.* Not creating new trades."
+ n = freqtrade.enter_positions()
+ # 0 trades, but it's not because of pairlock.
+ assert n == 0
+ assert not log_has_re(message, caplog)
+
+ PairLocks.lock_pair('*', arrow.utcnow().shift(minutes=20).datetime, 'Just because')
+ n = freqtrade.enter_positions()
+ assert n == 0
+ assert log_has_re(message, caplog)
+
+
def test_create_trade_no_signal(default_conf, fee, mocker) -> None:
default_conf['dry_run'] = True
@@ -3263,7 +3289,7 @@ def test_locked_pairs(default_conf, ticker, fee, ticker_sell_down, mocker, caplo
caplog.clear()
freqtrade.enter_positions()
- assert log_has(f"Pair {trade.pair} is currently locked.", caplog)
+ assert log_has_re(f"Pair {trade.pair} is still locked.*", caplog)
def test_ignore_roi_if_buy_signal(default_conf, limit_buy_order, limit_buy_order_open,