Merge pull request #4041 from freqtrade/plugins/protections_backtest
Introduce Protection Plugins
This commit is contained in:
commit
7c6357cc45
@ -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,
|
||||
|
@ -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).
|
||||
|
@ -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. <br>*Defaults to `true`.* <br> **Datatype:** Boolean
|
||||
| `pairlists` | Define one or more pairlists to be used. [More information below](#pairlists-and-pairlist-handlers). <br>*Defaults to `StaticPairList`.* <br> **Datatype:** List of Dicts
|
||||
| `protections` | Define one or more protections to be used. [More information below](#protections). <br> **Datatype:** List of Dicts
|
||||
| `telegram.enabled` | Enable the usage of Telegram. <br> **Datatype:** Boolean
|
||||
| `telegram.token` | Your Telegram bot token. Only required if `telegram.enabled` is `true`. <br>**Keep it in secret, do not disclose publicly.** <br> **Datatype:** String
|
||||
| `telegram.chat_id` | Your personal Telegram account id. Only required if `telegram.enabled` is `true`. <br>**Keep it in secret, do not disclose publicly.** <br> **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
|
||||
|
||||
|
@ -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
|
||||
|
169
docs/includes/protections.md
Normal file
169
docs/includes/protections.md
Normal file
@ -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. <br> **Datatype:** String, selected from [available Protections](#available-protections)
|
||||
| `stop_duration_candles` | For how many candles should the lock be set? <br> **Datatype:** Positive integer (in candles)
|
||||
| `stop_duration` | how many minutes should protections be locked. <br>Cannot be used together with `stop_duration_candles`. <br> **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. <br> **Datatype:** Positive integer (in candles).
|
||||
| `lookback_period` | Only trades that completed after `current_time - lookback_period` will be considered. <br>Cannot be used together with `lookback_period_candles`. <br>This setting may be ignored by some Protections. <br> **Datatype:** Float (in minutes)
|
||||
| `trade_limit` | Number of trades required at minimum (not used by all Protections). <br> **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
|
||||
}
|
||||
],
|
||||
```
|
3
docs/plugins.md
Normal file
3
docs/plugins.md
Normal file
@ -0,0 +1,3 @@
|
||||
# Plugins
|
||||
--8<-- "includes/pairlists.md"
|
||||
--8<-- "includes/protections.md"
|
@ -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"]
|
||||
|
@ -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. '
|
||||
|
@ -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')}"
|
||||
)
|
||||
|
@ -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')
|
||||
|
@ -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'],
|
||||
}
|
||||
|
@ -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
|
||||
|
||||
|
2
freqtrade/mixins/__init__.py
Normal file
2
freqtrade/mixins/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# flake8: noqa: F401
|
||||
from freqtrade.mixins.logging_mixin import LoggingMixin
|
38
freqtrade/mixins/logging_mixin.py
Normal file
38
freqtrade/mixins/logging_mixin.py
Normal file
@ -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)
|
@ -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)
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
"""
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
||||
|
@ -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)).
|
||||
"""
|
||||
|
@ -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:
|
||||
"""
|
||||
|
72
freqtrade/plugins/protectionmanager.py
Normal file
72
freqtrade/plugins/protectionmanager.py
Normal file
@ -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
|
2
freqtrade/plugins/protections/__init__.py
Normal file
2
freqtrade/plugins/protections/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
# flake8: noqa: F401
|
||||
from freqtrade.plugins.protections.iprotection import IProtection, ProtectionReturn
|
72
freqtrade/plugins/protections/cooldown_period.py
Normal file
72
freqtrade/plugins/protections/cooldown_period.py
Normal file
@ -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 <reason> until <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 <reason> until <until>
|
||||
"""
|
||||
return self._cooldown_period(pair, date_now)
|
107
freqtrade/plugins/protections/iprotection.py
Normal file
107
freqtrade/plugins/protections/iprotection.py
Normal file
@ -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 <reason> until <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
|
83
freqtrade/plugins/protections/low_profit_pairs.py
Normal file
83
freqtrade/plugins/protections/low_profit_pairs.py
Normal file
@ -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 <reason> until <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 <reason> until <until>
|
||||
"""
|
||||
return self._low_profit(date_now, pair=pair)
|
88
freqtrade/plugins/protections/max_drawdown_protection.py
Normal file
88
freqtrade/plugins/protections/max_drawdown_protection.py
Normal file
@ -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 <reason> until <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 <reason> until <until>
|
||||
"""
|
||||
return False, None, None
|
86
freqtrade/plugins/protections/stoploss_guard.py
Normal file
86
freqtrade/plugins/protections/stoploss_guard.py
Normal file
@ -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 <reason> until <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 <reason> until <until>
|
||||
"""
|
||||
return self._stoploss_guard(date_now, pair)
|
@ -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
|
||||
|
||||
|
||||
|
37
freqtrade/resolvers/protection_resolver.py
Normal file
37
freqtrade/resolvers/protection_resolver.py
Normal file
@ -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,
|
||||
},
|
||||
)
|
@ -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}'
|
||||
})
|
||||
|
@ -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)
|
||||
|
@ -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:
|
||||
|
@ -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 :: <class 'pandas.core.frame.DataFrame'>
|
||||
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):
|
||||
|
@ -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)
|
||||
|
0
tests/plugins/__init__.py
Normal file
0
tests/plugins/__init__.py
Normal file
@ -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
|
||||
|
@ -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
|
412
tests/plugins/test_protections.py
Normal file
412
tests/plugins/test_protections.py
Normal file
@ -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
|
@ -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:
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user