Merge branch 'freqtrade:develop' into develop

This commit is contained in:
Robert Roman 2021-09-26 02:57:02 -05:00 committed by GitHub
commit a77ca22026
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 187 additions and 59 deletions

View File

@ -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

View File

@ -1,4 +1,4 @@
mkdocs==1.2.2 mkdocs==1.2.2
mkdocs-material==7.2.6 mkdocs-material==7.3.0
mdx_truly_sane_lists==1.2 mdx_truly_sane_lists==1.2
pymdown-extensions==8.2 pymdown-extensions==8.2

View File

@ -701,3 +701,33 @@ The variable 'content', will contain the strategy file in a BASE64 encoded form.
``` ```
Please ensure that 'NameOfStrategy' is identical to the strategy name! Please ensure that 'NameOfStrategy' is identical to the strategy name!
## Performance warning
When executing a strategy, one can sometimes be greeted by the following in the logs
> PerformanceWarning: DataFrame is highly fragmented.
This is a warning from [`pandas`](https://github.com/pandas-dev/pandas) and as the warning continues to say:
use `pd.concat(axis=1)`.
This can have slight performance implications, which are usually only visible during hyperopt (when optimizing an indicator).
For example:
```python
for val in self.buy_ema_short.range:
dataframe[f'ema_short_{val}'] = ta.EMA(dataframe, timeperiod=val)
```
should be rewritten to
```python
frames = [dataframe]
for val in self.buy_ema_short.range:
frames.append({
f'ema_short_{val}': ta.EMA(dataframe, timeperiod=val)
})
# Append columns to existing dataframe
merged_frame = pd.concat(frames, axis=1)
```

View File

@ -942,6 +942,8 @@ Printing more than a few rows is also possible (simply use `print(dataframe)` i
## Common mistakes when developing strategies ## Common mistakes when developing strategies
### Peeking into the future while backtesting
Backtesting analyzes the whole time-range at once for performance reasons. Because of this, strategy authors need to make sure that strategies do not look-ahead into the future. Backtesting analyzes the whole time-range at once for performance reasons. Because of this, strategy authors need to make sure that strategies do not look-ahead into the future.
This is a common pain-point, which can cause huge differences between backtesting and dry/live run methods, since they all use data which is not available during dry/live runs, so these strategies will perform well during backtesting, but will fail / perform badly in real conditions. This is a common pain-point, which can cause huge differences between backtesting and dry/live run methods, since they all use data which is not available during dry/live runs, so these strategies will perform well during backtesting, but will fail / perform badly in real conditions.

View File

@ -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.

View File

@ -113,7 +113,7 @@ CONF_SCHEMA = {
}, },
'tradable_balance_ratio': { 'tradable_balance_ratio': {
'type': 'number', 'type': 'number',
'minimum': 0.1, 'minimum': 0.0,
'maximum': 1, 'maximum': 1,
'default': 0.99 'default': 0.99
}, },
@ -287,6 +287,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'},

View File

@ -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

View File

@ -1217,7 +1217,7 @@ class FreqtradeBot(LoggingMixin):
'exchange': trade.exchange.capitalize(), 'exchange': trade.exchange.capitalize(),
'pair': trade.pair, 'pair': trade.pair,
'gain': gain, 'gain': gain,
'limit': profit_rate, 'limit': profit_rate or 0,
'order_type': order_type, 'order_type': order_type,
'amount': trade.amount, 'amount': trade.amount,
'open_rate': trade.open_rate, 'open_rate': trade.open_rate,
@ -1226,7 +1226,7 @@ class FreqtradeBot(LoggingMixin):
'profit_ratio': profit_ratio, 'profit_ratio': profit_ratio,
'sell_reason': trade.sell_reason, 'sell_reason': trade.sell_reason,
'open_date': trade.open_date, 'open_date': trade.open_date,
'close_date': trade.close_date, 'close_date': trade.close_date or datetime.now(timezone.utc),
'stake_currency': self.config['stake_currency'], 'stake_currency': self.config['stake_currency'],
'fiat_currency': self.config.get('fiat_display_currency', None), 'fiat_currency': self.config.get('fiat_display_currency', None),
'reason': reason, 'reason': reason,
@ -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:
""" """

View File

@ -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]:

View File

@ -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

View File

@ -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'))

View File

@ -23,5 +23,5 @@ nbconvert==6.1.0
# mypy types # mypy types
types-cachetools==4.2.0 types-cachetools==4.2.0
types-filelock==0.1.5 types-filelock==0.1.5
types-requests==2.25.6 types-requests==2.25.8
types-tabulate==0.8.2 types-tabulate==0.8.2

View File

@ -8,4 +8,4 @@ scikit-optimize==0.8.1
filelock==3.0.12 filelock==3.0.12
joblib==1.0.1 joblib==1.0.1
psutil==5.8.0 psutil==5.8.0
progressbar2==3.53.2 progressbar2==3.53.3

View File

@ -1,16 +1,17 @@
numpy==1.21.2 numpy==1.21.2
pandas==1.3.3 pandas==1.3.3
pandas-ta==0.3.14b
ccxt==1.56.30 ccxt==1.56.86
# Pin cryptography for now due to rust build errors with piwheels # Pin cryptography for now due to rust build errors with piwheels
cryptography==3.4.8 cryptography==3.4.8
aiohttp==3.7.4.post0 aiohttp==3.7.4.post0
SQLAlchemy==1.4.23 SQLAlchemy==1.4.25
python-telegram-bot==13.7 python-telegram-bot==13.7
arrow==1.1.1 arrow==1.1.1
cachetools==4.2.2 cachetools==4.2.2
requests==2.26.0 requests==2.26.0
urllib3==1.26.6 urllib3==1.26.7
wrapt==1.12.1 wrapt==1.12.1
jsonschema==3.2.0 jsonschema==3.2.0
TA-Lib==0.4.21 TA-Lib==0.4.21

View File

@ -54,6 +54,7 @@ setup(
'wrapt', 'wrapt',
'jsonschema', 'jsonschema',
'TA-Lib', 'TA-Lib',
'pandas-ta',
'technical', 'technical',
'tabulate', 'tabulate',
'pycoingecko', 'pycoingecko',

View File

@ -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

View File

@ -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'

View File

@ -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