Merge pull request #5599 from freqtrade/notify_trigger_prot
Notify trigger protections
This commit is contained in:
commit
fcca637107
@ -149,7 +149,9 @@
|
|||||||
},
|
},
|
||||||
"sell_fill": "on",
|
"sell_fill": "on",
|
||||||
"buy_cancel": "on",
|
"buy_cancel": "on",
|
||||||
"sell_cancel": "on"
|
"sell_cancel": "on",
|
||||||
|
"protection_trigger": "off",
|
||||||
|
"protection_trigger_global": "on"
|
||||||
},
|
},
|
||||||
"reload": true,
|
"reload": true,
|
||||||
"balance_dust_level": 0.01
|
"balance_dust_level": 0.01
|
||||||
|
@ -93,7 +93,9 @@ Example configuration showing the different settings:
|
|||||||
"buy_cancel": "silent",
|
"buy_cancel": "silent",
|
||||||
"sell_cancel": "on",
|
"sell_cancel": "on",
|
||||||
"buy_fill": "off",
|
"buy_fill": "off",
|
||||||
"sell_fill": "off"
|
"sell_fill": "off",
|
||||||
|
"protection_trigger": "off",
|
||||||
|
"protection_trigger_global": "on"
|
||||||
},
|
},
|
||||||
"reload": true,
|
"reload": true,
|
||||||
"balance_dust_level": 0.01
|
"balance_dust_level": 0.01
|
||||||
@ -103,6 +105,7 @@ Example configuration showing the different settings:
|
|||||||
`buy` notifications are sent when the order is placed, while `buy_fill` notifications are sent when the order is filled on the exchange.
|
`buy` notifications are sent when the order is placed, while `buy_fill` notifications are sent when the order is filled on the exchange.
|
||||||
`sell` notifications are sent when the order is placed, while `sell_fill` notifications are sent when the order is filled on the exchange.
|
`sell` notifications are sent when the order is placed, while `sell_fill` notifications are sent when the order is filled on the exchange.
|
||||||
`*_fill` notifications are off by default and must be explicitly enabled.
|
`*_fill` notifications are off by default and must be explicitly enabled.
|
||||||
|
`protection_trigger` notifications are sent when a protection triggers and `protection_trigger_global` notifications trigger when global protections are triggered.
|
||||||
|
|
||||||
|
|
||||||
`balance_dust_level` will define what the `/balance` command takes as "dust" - Currencies with a balance below this will be shown.
|
`balance_dust_level` will define what the `/balance` command takes as "dust" - Currencies with a balance below this will be shown.
|
||||||
|
@ -284,6 +284,15 @@ CONF_SCHEMA = {
|
|||||||
'enum': TELEGRAM_SETTING_OPTIONS,
|
'enum': TELEGRAM_SETTING_OPTIONS,
|
||||||
'default': 'off'
|
'default': 'off'
|
||||||
},
|
},
|
||||||
|
'protection_trigger': {
|
||||||
|
'type': 'string',
|
||||||
|
'enum': TELEGRAM_SETTING_OPTIONS,
|
||||||
|
'default': 'off'
|
||||||
|
},
|
||||||
|
'protection_trigger_global': {
|
||||||
|
'type': 'string',
|
||||||
|
'enum': TELEGRAM_SETTING_OPTIONS,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
'reload': {'type': 'boolean'},
|
'reload': {'type': 'boolean'},
|
||||||
|
@ -11,6 +11,8 @@ class RPCMessageType(Enum):
|
|||||||
SELL = 'sell'
|
SELL = 'sell'
|
||||||
SELL_FILL = 'sell_fill'
|
SELL_FILL = 'sell_fill'
|
||||||
SELL_CANCEL = 'sell_cancel'
|
SELL_CANCEL = 'sell_cancel'
|
||||||
|
PROTECTION_TRIGGER = 'protection_trigger'
|
||||||
|
PROTECTION_TRIGGER_GLOBAL = 'protection_trigger_global'
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return self.value
|
return self.value
|
||||||
|
@ -1292,8 +1292,7 @@ class FreqtradeBot(LoggingMixin):
|
|||||||
if not trade.is_open:
|
if not trade.is_open:
|
||||||
if not stoploss_order and not trade.open_order_id:
|
if not stoploss_order and not trade.open_order_id:
|
||||||
self._notify_exit(trade, '', True)
|
self._notify_exit(trade, '', True)
|
||||||
self.protections.stop_per_pair(trade.pair)
|
self.handle_protections(trade.pair)
|
||||||
self.protections.global_stop()
|
|
||||||
self.wallets.update()
|
self.wallets.update()
|
||||||
elif not trade.open_order_id:
|
elif not trade.open_order_id:
|
||||||
# Buy fill
|
# Buy fill
|
||||||
@ -1301,6 +1300,19 @@ class FreqtradeBot(LoggingMixin):
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def handle_protections(self, pair: str) -> None:
|
||||||
|
prot_trig = self.protections.stop_per_pair(pair)
|
||||||
|
if prot_trig:
|
||||||
|
msg = {'type': RPCMessageType.PROTECTION_TRIGGER, }
|
||||||
|
msg.update(prot_trig.to_json())
|
||||||
|
self.rpc.send_msg(msg)
|
||||||
|
|
||||||
|
prot_trig_glb = self.protections.global_stop()
|
||||||
|
if prot_trig_glb:
|
||||||
|
msg = {'type': RPCMessageType.PROTECTION_TRIGGER_GLOBAL, }
|
||||||
|
msg.update(prot_trig_glb.to_json())
|
||||||
|
self.rpc.send_msg(msg)
|
||||||
|
|
||||||
def apply_fee_conditional(self, trade: Trade, trade_base_currency: str,
|
def apply_fee_conditional(self, trade: Trade, trade_base_currency: str,
|
||||||
amount: float, fee_abs: float) -> float:
|
amount: float, fee_abs: float) -> float:
|
||||||
"""
|
"""
|
||||||
|
@ -30,7 +30,8 @@ class PairLocks():
|
|||||||
PairLocks.locks = []
|
PairLocks.locks = []
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def lock_pair(pair: str, until: datetime, reason: str = None, *, now: datetime = None) -> None:
|
def lock_pair(pair: str, until: datetime, reason: str = None, *,
|
||||||
|
now: datetime = None) -> PairLock:
|
||||||
"""
|
"""
|
||||||
Create PairLock from now to "until".
|
Create PairLock from now to "until".
|
||||||
Uses database by default, unless PairLocks.use_db is set to False,
|
Uses database by default, unless PairLocks.use_db is set to False,
|
||||||
@ -52,6 +53,7 @@ class PairLocks():
|
|||||||
PairLock.query.session.commit()
|
PairLock.query.session.commit()
|
||||||
else:
|
else:
|
||||||
PairLocks.locks.append(lock)
|
PairLocks.locks.append(lock)
|
||||||
|
return lock
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_pair_locks(pair: Optional[str], now: Optional[datetime] = None) -> List[PairLock]:
|
def get_pair_locks(pair: Optional[str], now: Optional[datetime] = None) -> List[PairLock]:
|
||||||
|
@ -6,6 +6,7 @@ from datetime import datetime, timezone
|
|||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
from freqtrade.persistence import PairLocks
|
from freqtrade.persistence import PairLocks
|
||||||
|
from freqtrade.persistence.models import PairLock
|
||||||
from freqtrade.plugins.protections import IProtection
|
from freqtrade.plugins.protections import IProtection
|
||||||
from freqtrade.resolvers import ProtectionResolver
|
from freqtrade.resolvers import ProtectionResolver
|
||||||
|
|
||||||
@ -43,30 +44,28 @@ class ProtectionManager():
|
|||||||
"""
|
"""
|
||||||
return [{p.name: p.short_desc()} for p in self._protection_handlers]
|
return [{p.name: p.short_desc()} for p in self._protection_handlers]
|
||||||
|
|
||||||
def global_stop(self, now: Optional[datetime] = None) -> bool:
|
def global_stop(self, now: Optional[datetime] = None) -> Optional[PairLock]:
|
||||||
if not now:
|
if not now:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
result = False
|
result = None
|
||||||
for protection_handler in self._protection_handlers:
|
for protection_handler in self._protection_handlers:
|
||||||
if protection_handler.has_global_stop:
|
if protection_handler.has_global_stop:
|
||||||
result, until, reason = protection_handler.global_stop(now)
|
lock, until, reason = protection_handler.global_stop(now)
|
||||||
|
|
||||||
# Early stopping - first positive result blocks further trades
|
# Early stopping - first positive result blocks further trades
|
||||||
if result and until:
|
if lock and until:
|
||||||
if not PairLocks.is_global_lock(until):
|
if not PairLocks.is_global_lock(until):
|
||||||
PairLocks.lock_pair('*', until, reason, now=now)
|
result = PairLocks.lock_pair('*', until, reason, now=now)
|
||||||
result = True
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def stop_per_pair(self, pair, now: Optional[datetime] = None) -> bool:
|
def stop_per_pair(self, pair, now: Optional[datetime] = None) -> Optional[PairLock]:
|
||||||
if not now:
|
if not now:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
result = False
|
result = None
|
||||||
for protection_handler in self._protection_handlers:
|
for protection_handler in self._protection_handlers:
|
||||||
if protection_handler.has_local_stop:
|
if protection_handler.has_local_stop:
|
||||||
result, until, reason = protection_handler.stop_per_pair(pair, now)
|
lock, until, reason = protection_handler.stop_per_pair(pair, now)
|
||||||
if result and until:
|
if lock and until:
|
||||||
if not PairLocks.is_pair_locked(pair, until):
|
if not PairLocks.is_pair_locked(pair, until):
|
||||||
PairLocks.lock_pair(pair, until, reason, now=now)
|
result = PairLocks.lock_pair(pair, until, reason, now=now)
|
||||||
result = True
|
|
||||||
return result
|
return result
|
||||||
|
@ -260,6 +260,50 @@ class Telegram(RPCHandler):
|
|||||||
|
|
||||||
return message
|
return message
|
||||||
|
|
||||||
|
def compose_message(self, msg: Dict[str, Any], msg_type: RPCMessageType) -> str:
|
||||||
|
|
||||||
|
if msg_type == RPCMessageType.BUY:
|
||||||
|
message = self._format_buy_msg(msg)
|
||||||
|
|
||||||
|
elif msg_type in (RPCMessageType.BUY_CANCEL, RPCMessageType.SELL_CANCEL):
|
||||||
|
msg['message_side'] = 'buy' if msg_type == RPCMessageType.BUY_CANCEL else 'sell'
|
||||||
|
message = ("\N{WARNING SIGN} *{exchange}:* "
|
||||||
|
"Cancelling open {message_side} Order for {pair} (#{trade_id}). "
|
||||||
|
"Reason: {reason}.".format(**msg))
|
||||||
|
|
||||||
|
elif msg_type == RPCMessageType.BUY_FILL:
|
||||||
|
message = ("\N{LARGE CIRCLE} *{exchange}:* "
|
||||||
|
"Buy order for {pair} (#{trade_id}) filled "
|
||||||
|
"for {open_rate}.".format(**msg))
|
||||||
|
elif msg_type == RPCMessageType.SELL_FILL:
|
||||||
|
message = ("\N{LARGE CIRCLE} *{exchange}:* "
|
||||||
|
"Sell order for {pair} (#{trade_id}) filled "
|
||||||
|
"for {close_rate}.".format(**msg))
|
||||||
|
elif msg_type == RPCMessageType.SELL:
|
||||||
|
message = self._format_sell_msg(msg)
|
||||||
|
elif msg_type == RPCMessageType.PROTECTION_TRIGGER:
|
||||||
|
message = (
|
||||||
|
"*Protection* triggered due to {reason}. "
|
||||||
|
"`{pair}` will be locked until `{lock_end_time}`."
|
||||||
|
).format(**msg)
|
||||||
|
elif msg_type == RPCMessageType.PROTECTION_TRIGGER_GLOBAL:
|
||||||
|
message = (
|
||||||
|
"*Protection* triggered due to {reason}. "
|
||||||
|
"*All pairs* will be locked until `{lock_end_time}`."
|
||||||
|
).format(**msg)
|
||||||
|
elif msg_type == RPCMessageType.STATUS:
|
||||||
|
message = '*Status:* `{status}`'.format(**msg)
|
||||||
|
|
||||||
|
elif msg_type == RPCMessageType.WARNING:
|
||||||
|
message = '\N{WARNING SIGN} *Warning:* `{status}`'.format(**msg)
|
||||||
|
|
||||||
|
elif msg_type == RPCMessageType.STARTUP:
|
||||||
|
message = '{status}'.format(**msg)
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise NotImplementedError('Unknown message type: {}'.format(msg_type))
|
||||||
|
return message
|
||||||
|
|
||||||
def send_msg(self, msg: Dict[str, Any]) -> None:
|
def send_msg(self, msg: Dict[str, Any]) -> None:
|
||||||
""" Send a message to telegram channel """
|
""" Send a message to telegram channel """
|
||||||
|
|
||||||
@ -284,37 +328,7 @@ class Telegram(RPCHandler):
|
|||||||
# Notification disabled
|
# Notification disabled
|
||||||
return
|
return
|
||||||
|
|
||||||
if msg_type == RPCMessageType.BUY:
|
message = self.compose_message(msg, msg_type)
|
||||||
message = self._format_buy_msg(msg)
|
|
||||||
|
|
||||||
elif msg_type in (RPCMessageType.BUY_CANCEL, RPCMessageType.SELL_CANCEL):
|
|
||||||
msg['message_side'] = 'buy' if msg_type == RPCMessageType.BUY_CANCEL else 'sell'
|
|
||||||
message = ("\N{WARNING SIGN} *{exchange}:* "
|
|
||||||
"Cancelling open {message_side} Order for {pair} (#{trade_id}). "
|
|
||||||
"Reason: {reason}.".format(**msg))
|
|
||||||
|
|
||||||
elif msg_type == RPCMessageType.BUY_FILL:
|
|
||||||
message = ("\N{LARGE CIRCLE} *{exchange}:* "
|
|
||||||
"Buy order for {pair} (#{trade_id}) filled "
|
|
||||||
"for {open_rate}.".format(**msg))
|
|
||||||
elif msg_type == RPCMessageType.SELL_FILL:
|
|
||||||
message = ("\N{LARGE CIRCLE} *{exchange}:* "
|
|
||||||
"Sell order for {pair} (#{trade_id}) filled "
|
|
||||||
"for {close_rate}.".format(**msg))
|
|
||||||
elif msg_type == RPCMessageType.SELL:
|
|
||||||
message = self._format_sell_msg(msg)
|
|
||||||
|
|
||||||
elif msg_type == RPCMessageType.STATUS:
|
|
||||||
message = '*Status:* `{status}`'.format(**msg)
|
|
||||||
|
|
||||||
elif msg_type == RPCMessageType.WARNING:
|
|
||||||
message = '\N{WARNING SIGN} *Warning:* `{status}`'.format(**msg)
|
|
||||||
|
|
||||||
elif msg_type == RPCMessageType.STARTUP:
|
|
||||||
message = '{status}'.format(**msg)
|
|
||||||
|
|
||||||
else:
|
|
||||||
raise NotImplementedError('Unknown message type: {}'.format(msg_type))
|
|
||||||
|
|
||||||
self._send_msg(message, disable_notification=(noti == 'silent'))
|
self._send_msg(message, disable_notification=(noti == 'silent'))
|
||||||
|
|
||||||
|
@ -125,7 +125,7 @@ def test_stoploss_guard(mocker, default_conf, fee, caplog):
|
|||||||
# Test 5m after lock-period - this should try and relock the pair, but end-time
|
# Test 5m after lock-period - this should try and relock the pair, but end-time
|
||||||
# should be the previous end-time
|
# should be the previous end-time
|
||||||
end_time = PairLocks.get_pair_longest_lock('*').lock_end_time + timedelta(minutes=5)
|
end_time = PairLocks.get_pair_longest_lock('*').lock_end_time + timedelta(minutes=5)
|
||||||
assert freqtrade.protections.global_stop(end_time)
|
freqtrade.protections.global_stop(end_time)
|
||||||
assert not PairLocks.is_global_lock(end_time)
|
assert not PairLocks.is_global_lock(end_time)
|
||||||
|
|
||||||
|
|
||||||
@ -182,7 +182,7 @@ def test_stoploss_guard_perpair(mocker, default_conf, fee, caplog, only_per_pair
|
|||||||
min_ago_open=180, min_ago_close=30, profit_rate=0.9,
|
min_ago_open=180, min_ago_close=30, profit_rate=0.9,
|
||||||
))
|
))
|
||||||
|
|
||||||
assert freqtrade.protections.stop_per_pair(pair)
|
freqtrade.protections.stop_per_pair(pair)
|
||||||
assert freqtrade.protections.global_stop() != only_per_pair
|
assert freqtrade.protections.global_stop() != only_per_pair
|
||||||
assert PairLocks.is_pair_locked(pair)
|
assert PairLocks.is_pair_locked(pair)
|
||||||
assert PairLocks.is_global_lock() != only_per_pair
|
assert PairLocks.is_global_lock() != only_per_pair
|
||||||
|
@ -1313,6 +1313,34 @@ def test_send_msg_buy_cancel_notification(default_conf, mocker) -> None:
|
|||||||
'Reason: cancelled due to timeout.')
|
'Reason: cancelled due to timeout.')
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_msg_protection_notification(default_conf, mocker, time_machine) -> None:
|
||||||
|
|
||||||
|
default_conf['telegram']['notification_settings']['protection_trigger'] = 'on'
|
||||||
|
|
||||||
|
telegram, _, msg_mock = get_telegram_testobject(mocker, default_conf)
|
||||||
|
time_machine.move_to("2021-09-01 05:00:00 +00:00")
|
||||||
|
lock = PairLocks.lock_pair('ETH/BTC', arrow.utcnow().shift(minutes=6).datetime, 'randreason')
|
||||||
|
msg = {
|
||||||
|
'type': RPCMessageType.PROTECTION_TRIGGER,
|
||||||
|
}
|
||||||
|
msg.update(lock.to_json())
|
||||||
|
telegram.send_msg(msg)
|
||||||
|
assert (msg_mock.call_args[0][0] == "*Protection* triggered due to randreason. "
|
||||||
|
"`ETH/BTC` will be locked until `2021-09-01 05:10:00`.")
|
||||||
|
|
||||||
|
msg_mock.reset_mock()
|
||||||
|
# Test global protection
|
||||||
|
|
||||||
|
msg = {
|
||||||
|
'type': RPCMessageType.PROTECTION_TRIGGER_GLOBAL,
|
||||||
|
}
|
||||||
|
lock = PairLocks.lock_pair('*', arrow.utcnow().shift(minutes=100).datetime, 'randreason')
|
||||||
|
msg.update(lock.to_json())
|
||||||
|
telegram.send_msg(msg)
|
||||||
|
assert (msg_mock.call_args[0][0] == "*Protection* triggered due to randreason. "
|
||||||
|
"*All pairs* will be locked until `2021-09-01 06:45:00`.")
|
||||||
|
|
||||||
|
|
||||||
def test_send_msg_buy_fill_notification(default_conf, mocker) -> None:
|
def test_send_msg_buy_fill_notification(default_conf, mocker) -> None:
|
||||||
|
|
||||||
default_conf['telegram']['notification_settings']['buy_fill'] = 'on'
|
default_conf['telegram']['notification_settings']['buy_fill'] = 'on'
|
||||||
|
@ -416,6 +416,29 @@ def test_enter_positions_global_pairlock(default_conf, ticker, limit_buy_order,
|
|||||||
assert log_has_re(message, caplog)
|
assert log_has_re(message, caplog)
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_protections(mocker, default_conf, fee):
|
||||||
|
default_conf['protections'] = [
|
||||||
|
{"method": "CooldownPeriod", "stop_duration": 60},
|
||||||
|
{
|
||||||
|
"method": "StoplossGuard",
|
||||||
|
"lookback_period_candles": 24,
|
||||||
|
"trade_limit": 4,
|
||||||
|
"stop_duration_candles": 4,
|
||||||
|
"only_per_pair": False
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
freqtrade = get_patched_freqtradebot(mocker, default_conf)
|
||||||
|
freqtrade.protections._protection_handlers[1].global_stop = MagicMock(
|
||||||
|
return_value=(True, arrow.utcnow().shift(hours=1).datetime, "asdf"))
|
||||||
|
create_mock_trades(fee)
|
||||||
|
freqtrade.handle_protections('ETC/BTC')
|
||||||
|
send_msg_mock = freqtrade.rpc.send_msg
|
||||||
|
assert send_msg_mock.call_count == 2
|
||||||
|
assert send_msg_mock.call_args_list[0][0][0]['type'] == RPCMessageType.PROTECTION_TRIGGER
|
||||||
|
assert send_msg_mock.call_args_list[1][0][0]['type'] == RPCMessageType.PROTECTION_TRIGGER_GLOBAL
|
||||||
|
|
||||||
|
|
||||||
def test_create_trade_no_signal(default_conf, fee, mocker) -> None:
|
def test_create_trade_no_signal(default_conf, fee, mocker) -> None:
|
||||||
default_conf['dry_run'] = True
|
default_conf['dry_run'] = True
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user