diff --git a/docs/developer.md b/docs/developer.md index 1cc16294b..185bfc92e 100644 --- a/docs/developer.md +++ b/docs/developer.md @@ -200,11 +200,12 @@ For that reason, they must implement the following methods: * `global_stop()` * `stop_per_pair()`. -`global_stop()` and `stop_per_pair()` must return a ProtectionReturn tuple, which consists of: +`global_stop()` and `stop_per_pair()` must return a ProtectionReturn object, 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 +* lock_side - long, short or '*'. The `until` portion should be calculated using the provided `calculate_lock_end()` method. diff --git a/docs/includes/protections.md b/docs/includes/protections.md index 0757d2f6d..bb4a7eb35 100644 --- a/docs/includes/protections.md +++ b/docs/includes/protections.md @@ -48,6 +48,8 @@ If `trade_limit` or more trades resulted in stoploss, trading will stop for `sto This applies across all pairs, unless `only_per_pair` is set to true, which will then only look at one pair at a time. +Similarly, this protection will by default look at all trades (long and short). For futures bots, setting `only_per_side` will make the bot only consider one side, and will then only lock this one side, allowing for example shorts to continue after a series of long stoplosses. + 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. ``` python @@ -59,7 +61,8 @@ def protections(self): "lookback_period_candles": 24, "trade_limit": 4, "stop_duration_candles": 4, - "only_per_pair": False + "only_per_pair": False, + "only_per_side": False } ] ``` diff --git a/freqtrade/freqtradebot.py b/freqtrade/freqtradebot.py index b6f0daeed..025746553 100644 --- a/freqtrade/freqtradebot.py +++ b/freqtrade/freqtradebot.py @@ -401,7 +401,10 @@ class FreqtradeBot(LoggingMixin): logger.info("No currency pair in active pair whitelist, " "but checking to exit open trades.") return trades_created - if PairLocks.is_global_lock(): + if PairLocks.is_global_lock(side='*'): + # This only checks for total locks (both sides). + # per-side locks will be evaluated by `is_pair_locked` within create_trade, + # once the direction for the trade is clear. lock = PairLocks.get_pair_longest_lock('*') if lock: self.log_once(f"Global pairlock active until " @@ -435,16 +438,6 @@ class FreqtradeBot(LoggingMixin): analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(pair, self.strategy.timeframe) 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)} " - f"due to {lock.reason}.", - 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 # but it is still used here to prevent opening too many trades within one iteration @@ -460,6 +453,16 @@ class FreqtradeBot(LoggingMixin): ) if signal: + if self.strategy.is_pair_locked(pair, candle_date=nowtime, side=signal): + lock = PairLocks.get_pair_longest_lock(pair, nowtime, signal) + if lock: + self.log_once(f"Pair {pair} {lock.side} is locked until " + f"{lock.lock_end_time.strftime(constants.DATETIME_PRINT_FORMAT)} " + f"due to {lock.reason}.", + logger.info) + else: + self.log_once(f"Pair {pair} is currently locked.", logger.info) + return False stake_amount = self.wallets.get_trade_stake_amount(pair, self.edge) bid_check_dom = self.config.get('entry_pricing', {}).get('check_depth_of_market', {}) @@ -1594,21 +1597,21 @@ class FreqtradeBot(LoggingMixin): if not trade.is_open: if send_msg and not stoploss_order and not trade.open_order_id: self._notify_exit(trade, '', True) - self.handle_protections(trade.pair) + self.handle_protections(trade.pair, trade.trade_direction) elif send_msg and not trade.open_order_id: # Enter fill self._notify_enter(trade, order, fill=True) return False - def handle_protections(self, pair: str) -> None: - prot_trig = self.protections.stop_per_pair(pair) + def handle_protections(self, pair: str, side: LongShort) -> None: + prot_trig = self.protections.stop_per_pair(pair, side=side) 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() + prot_trig_glb = self.protections.global_stop(side=side) if prot_trig_glb: msg = {'type': RPCMessageType.PROTECTION_TRIGGER_GLOBAL, } msg.update(prot_trig_glb.to_json()) diff --git a/freqtrade/optimize/backtesting.py b/freqtrade/optimize/backtesting.py index dd8d82b13..937ad43de 100755 --- a/freqtrade/optimize/backtesting.py +++ b/freqtrade/optimize/backtesting.py @@ -861,10 +861,11 @@ class Backtesting: return 'short' return None - def run_protections(self, enable_protections, pair: str, current_time: datetime): + def run_protections( + self, enable_protections, pair: str, current_time: datetime, side: LongShort): if enable_protections: - self.protections.stop_per_pair(pair, current_time) - self.protections.global_stop(current_time) + self.protections.stop_per_pair(pair, current_time, side) + self.protections.global_stop(current_time, side) def check_order_cancel(self, trade: LocalTrade, current_time) -> bool: """ @@ -976,7 +977,7 @@ class Backtesting: and self.trade_slot_available(max_open_trades, open_trade_count_start) and current_time != end_date and trade_dir is not None - and not PairLocks.is_pair_locked(pair, row[DATE_IDX]) + and not PairLocks.is_pair_locked(pair, row[DATE_IDX], trade_dir) ): trade = self._enter_trade(pair, row, trade_dir) if trade: @@ -1014,7 +1015,8 @@ class Backtesting: LocalTrade.close_bt_trade(trade) trades.append(trade) self.wallets.update() - self.run_protections(enable_protections, pair, current_time) + self.run_protections( + enable_protections, pair, current_time, trade.trade_direction) # Move time one configured time_interval ahead. self.progress.increment() diff --git a/freqtrade/persistence/migrations.py b/freqtrade/persistence/migrations.py index f020f990c..03f3c3fb9 100644 --- a/freqtrade/persistence/migrations.py +++ b/freqtrade/persistence/migrations.py @@ -9,7 +9,7 @@ from freqtrade.exceptions import OperationalException logger = logging.getLogger(__name__) -def get_table_names_for_table(inspector, tabletype): +def get_table_names_for_table(inspector, tabletype) -> List[str]: return [t for t in inspector.get_table_names() if t.startswith(tabletype)] @@ -21,7 +21,7 @@ def get_column_def(columns: List, column: str, default: str) -> str: return default if not has_column(columns, column) else column -def get_backup_name(tabs, backup_prefix: str): +def get_backup_name(tabs: List[str], backup_prefix: str): table_back_name = backup_prefix for i, table_back_name in enumerate(tabs): table_back_name = f'{backup_prefix}{i}' @@ -56,6 +56,16 @@ def set_sequence_ids(engine, order_id, trade_id): connection.execute(text(f"ALTER SEQUENCE trades_id_seq RESTART WITH {trade_id}")) +def drop_index_on_table(engine, inspector, table_bak_name): + with engine.begin() as connection: + # drop indexes on backup table in new session + for index in inspector.get_indexes(table_bak_name): + if engine.name == 'mysql': + connection.execute(text(f"drop index {index['name']} on {table_bak_name}")) + else: + connection.execute(text(f"drop index {index['name']}")) + + def migrate_trades_and_orders_table( decl_base, inspector, engine, trade_back_name: str, cols: List, @@ -116,13 +126,7 @@ def migrate_trades_and_orders_table( with engine.begin() as connection: connection.execute(text(f"alter table trades rename to {trade_back_name}")) - with engine.begin() as connection: - # drop indexes on backup table in new session - for index in inspector.get_indexes(trade_back_name): - if engine.name == 'mysql': - connection.execute(text(f"drop index {index['name']} on {trade_back_name}")) - else: - connection.execute(text(f"drop index {index['name']}")) + drop_index_on_table(engine, inspector, trade_back_name) order_id, trade_id = get_last_sequence_ids(engine, trade_back_name, order_back_name) @@ -205,6 +209,31 @@ def migrate_orders_table(engine, table_back_name: str, cols_order: List): """)) +def migrate_pairlocks_table( + decl_base, inspector, engine, + pairlock_back_name: str, cols: List): + + # Schema migration necessary + with engine.begin() as connection: + connection.execute(text(f"alter table pairlocks rename to {pairlock_back_name}")) + + drop_index_on_table(engine, inspector, pairlock_back_name) + + side = get_column_def(cols, 'side', "'*'") + + # let SQLAlchemy create the schema as required + decl_base.metadata.create_all(engine) + # Copy data back - following the correct schema + with engine.begin() as connection: + connection.execute(text(f"""insert into pairlocks + (id, pair, side, reason, lock_time, + lock_end_time, active) + select id, pair, {side} side, reason, lock_time, + lock_end_time, active + from {pairlock_back_name} + """)) + + def set_sqlite_to_wal(engine): if engine.name == 'sqlite' and str(engine.url) != 'sqlite://': # Set Mode to @@ -220,10 +249,13 @@ def check_migrate(engine, decl_base, previous_tables) -> None: cols_trades = inspector.get_columns('trades') cols_orders = inspector.get_columns('orders') + cols_pairlocks = inspector.get_columns('pairlocks') tabs = get_table_names_for_table(inspector, 'trades') table_back_name = get_backup_name(tabs, 'trades_bak') order_tabs = get_table_names_for_table(inspector, 'orders') order_table_bak_name = get_backup_name(order_tabs, 'orders_bak') + pairlock_tabs = get_table_names_for_table(inspector, 'pairlocks') + pairlock_table_bak_name = get_backup_name(pairlock_tabs, 'pairlocks_bak') # Check if migration necessary # Migrates both trades and orders table! @@ -236,6 +268,13 @@ def check_migrate(engine, decl_base, previous_tables) -> None: decl_base, inspector, engine, table_back_name, cols_trades, order_table_bak_name, cols_orders) + if not has_column(cols_pairlocks, 'side'): + logger.info(f"Running database migration for pairlocks - " + f"backup: {pairlock_table_bak_name}") + + migrate_pairlocks_table( + decl_base, inspector, engine, pairlock_table_bak_name, cols_pairlocks + ) if 'orders' not in previous_tables and 'trades' in previous_tables: raise OperationalException( "Your database seems to be very old. " diff --git a/freqtrade/persistence/models.py b/freqtrade/persistence/models.py index 2cacc06e2..299032bb4 100644 --- a/freqtrade/persistence/models.py +++ b/freqtrade/persistence/models.py @@ -7,13 +7,13 @@ from decimal import Decimal from typing import Any, Dict, List, Optional from sqlalchemy import (Boolean, Column, DateTime, Enum, Float, ForeignKey, Integer, String, - create_engine, desc, func, inspect) + create_engine, desc, func, inspect, or_) from sqlalchemy.exc import NoSuchModuleError from sqlalchemy.orm import Query, declarative_base, relationship, scoped_session, sessionmaker from sqlalchemy.pool import StaticPool from sqlalchemy.sql.schema import UniqueConstraint -from freqtrade.constants import DATETIME_PRINT_FORMAT, NON_OPEN_EXCHANGE_STATES +from freqtrade.constants import DATETIME_PRINT_FORMAT, NON_OPEN_EXCHANGE_STATES, LongShort from freqtrade.enums import ExitType, TradingMode from freqtrade.exceptions import DependencyException, OperationalException from freqtrade.leverage import interest @@ -393,7 +393,7 @@ class LocalTrade(): return "sell" @property - def trade_direction(self) -> str: + def trade_direction(self) -> LongShort: if self.is_short: return "short" else: @@ -1426,6 +1426,8 @@ class PairLock(_DECL_BASE): id = Column(Integer, primary_key=True) pair = Column(String(25), nullable=False, index=True) + # lock direction - long, short or * (for both) + side = Column(String(25), nullable=False, default="*") reason = Column(String(255), nullable=True) # Time the pair was locked (start time) lock_time = Column(DateTime, nullable=False) @@ -1437,11 +1439,12 @@ class PairLock(_DECL_BASE): def __repr__(self): lock_time = self.lock_time.strftime(DATETIME_PRINT_FORMAT) lock_end_time = self.lock_end_time.strftime(DATETIME_PRINT_FORMAT) - return (f'PairLock(id={self.id}, pair={self.pair}, lock_time={lock_time}, ' - f'lock_end_time={lock_end_time}, reason={self.reason}, active={self.active})') + return ( + f'PairLock(id={self.id}, pair={self.pair}, side={self.side}, lock_time={lock_time}, ' + f'lock_end_time={lock_end_time}, reason={self.reason}, active={self.active})') @staticmethod - def query_pair_locks(pair: Optional[str], now: datetime) -> Query: + def query_pair_locks(pair: Optional[str], now: datetime, side: str = '*') -> Query: """ Get all currently active locks for this pair :param pair: Pair to check for. Returns all current locks if pair is empty @@ -1452,6 +1455,11 @@ class PairLock(_DECL_BASE): PairLock.active.is_(True), ] if pair: filters.append(PairLock.pair == pair) + if side != '*': + filters.append(or_(PairLock.side == side, PairLock.side == '*')) + else: + filters.append(PairLock.side == '*') + return PairLock.query.filter( *filters ) @@ -1466,5 +1474,6 @@ class PairLock(_DECL_BASE): 'lock_end_timestamp': int(self.lock_end_time.replace(tzinfo=timezone.utc ).timestamp() * 1000), 'reason': self.reason, + 'side': self.side, 'active': self.active, } diff --git a/freqtrade/persistence/pairlock_middleware.py b/freqtrade/persistence/pairlock_middleware.py index afbd9781b..ec57e91fc 100644 --- a/freqtrade/persistence/pairlock_middleware.py +++ b/freqtrade/persistence/pairlock_middleware.py @@ -31,7 +31,7 @@ class PairLocks(): @staticmethod def lock_pair(pair: str, until: datetime, reason: str = None, *, - now: datetime = None) -> PairLock: + now: datetime = None, side: str = '*') -> PairLock: """ Create PairLock from now to "until". Uses database by default, unless PairLocks.use_db is set to False, @@ -40,12 +40,14 @@ class PairLocks(): :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. + :param side: Side to lock pair, can be 'long', 'short' or '*' """ lock = PairLock( pair=pair, lock_time=now or datetime.now(timezone.utc), lock_end_time=timeframe_to_next_date(PairLocks.timeframe, until), reason=reason, + side=side, active=True ) if PairLocks.use_db: @@ -56,7 +58,8 @@ class PairLocks(): return lock @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, side: str = '*') -> List[PairLock]: """ Get all currently active locks for this pair :param pair: Pair to check for. Returns all current locks if pair is empty @@ -67,26 +70,28 @@ class PairLocks(): now = datetime.now(timezone.utc) if PairLocks.use_db: - return PairLock.query_pair_locks(pair, now).all() + return PairLock.query_pair_locks(pair, now, side).all() else: locks = [lock for lock in PairLocks.locks if ( lock.lock_end_time >= now and lock.active is True and (pair is None or lock.pair == pair) + and (lock.side == '*' or lock.side == side) )] return locks @staticmethod - def get_pair_longest_lock(pair: str, now: Optional[datetime] = None) -> Optional[PairLock]: + def get_pair_longest_lock( + pair: str, now: Optional[datetime] = None, side: str = '*') -> Optional[PairLock]: """ Get the lock that expires the latest for the pair given. """ - locks = PairLocks.get_pair_locks(pair, now) + locks = PairLocks.get_pair_locks(pair, now, side=side) 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: + def unlock_pair(pair: str, now: Optional[datetime] = None, side: str = '*') -> None: """ Release all locks for this pair. :param pair: Pair to unlock @@ -97,7 +102,7 @@ class PairLocks(): now = datetime.now(timezone.utc) logger.info(f"Releasing all locks for {pair}.") - locks = PairLocks.get_pair_locks(pair, now) + locks = PairLocks.get_pair_locks(pair, now, side=side) for lock in locks: lock.active = False if PairLocks.use_db: @@ -134,7 +139,7 @@ class PairLocks(): lock.active = False @staticmethod - def is_global_lock(now: Optional[datetime] = None) -> bool: + def is_global_lock(now: Optional[datetime] = None, side: str = '*') -> bool: """ :param now: Datetime object (generated via datetime.now(timezone.utc)). defaults to datetime.now(timezone.utc) @@ -142,10 +147,10 @@ class PairLocks(): if not now: now = datetime.now(timezone.utc) - return len(PairLocks.get_pair_locks('*', now)) > 0 + return len(PairLocks.get_pair_locks('*', now, side)) > 0 @staticmethod - def is_pair_locked(pair: str, now: Optional[datetime] = None) -> bool: + def is_pair_locked(pair: str, now: Optional[datetime] = None, side: str = '*') -> bool: """ :param pair: Pair to check for :param now: Datetime object (generated via datetime.now(timezone.utc)). @@ -154,7 +159,10 @@ class PairLocks(): if not now: now = datetime.now(timezone.utc) - return len(PairLocks.get_pair_locks(pair, now)) > 0 or PairLocks.is_global_lock(now) + return ( + len(PairLocks.get_pair_locks(pair, now, side)) > 0 + or PairLocks.is_global_lock(now, side) + ) @staticmethod def get_all_locks() -> List[PairLock]: diff --git a/freqtrade/plugins/protectionmanager.py b/freqtrade/plugins/protectionmanager.py index 2510d6fee..d33294fa7 100644 --- a/freqtrade/plugins/protectionmanager.py +++ b/freqtrade/plugins/protectionmanager.py @@ -5,6 +5,7 @@ import logging from datetime import datetime, timezone from typing import Dict, List, Optional +from freqtrade.constants import LongShort from freqtrade.persistence import PairLocks from freqtrade.persistence.models import PairLock from freqtrade.plugins.protections import IProtection @@ -44,28 +45,31 @@ class ProtectionManager(): """ return [{p.name: p.short_desc()} for p in self._protection_handlers] - def global_stop(self, now: Optional[datetime] = None) -> Optional[PairLock]: + def global_stop(self, now: Optional[datetime] = None, + side: LongShort = 'long') -> Optional[PairLock]: if not now: now = datetime.now(timezone.utc) result = None for protection_handler in self._protection_handlers: if protection_handler.has_global_stop: - lock, until, reason = protection_handler.global_stop(now) - - # Early stopping - first positive result blocks further trades - if lock and until: - if not PairLocks.is_global_lock(until): - result = PairLocks.lock_pair('*', until, reason, now=now) + lock = protection_handler.global_stop(date_now=now, side=side) + if lock and lock.until: + if not PairLocks.is_global_lock(lock.until, side=lock.lock_side): + result = PairLocks.lock_pair( + '*', lock.until, lock.reason, now=now, side=lock.lock_side) return result - def stop_per_pair(self, pair, now: Optional[datetime] = None) -> Optional[PairLock]: + def stop_per_pair(self, pair, now: Optional[datetime] = None, + side: LongShort = 'long') -> Optional[PairLock]: if not now: now = datetime.now(timezone.utc) result = None for protection_handler in self._protection_handlers: if protection_handler.has_local_stop: - lock, until, reason = protection_handler.stop_per_pair(pair, now) - if lock and until: - if not PairLocks.is_pair_locked(pair, until): - result = PairLocks.lock_pair(pair, until, reason, now=now) + lock = protection_handler.stop_per_pair( + pair=pair, date_now=now, side=side) + if lock and lock.until: + if not PairLocks.is_pair_locked(pair, lock.until, lock.lock_side): + result = PairLocks.lock_pair( + pair, lock.until, lock.reason, now=now, side=lock.lock_side) return result diff --git a/freqtrade/plugins/protections/cooldown_period.py b/freqtrade/plugins/protections/cooldown_period.py index a2d8eca34..426b8f1b6 100644 --- a/freqtrade/plugins/protections/cooldown_period.py +++ b/freqtrade/plugins/protections/cooldown_period.py @@ -1,7 +1,9 @@ import logging from datetime import datetime, timedelta +from typing import Optional +from freqtrade.constants import LongShort from freqtrade.persistence import Trade from freqtrade.plugins.protections import IProtection, ProtectionReturn @@ -26,7 +28,7 @@ class CooldownPeriod(IProtection): """ return (f"{self.name} - Cooldown period of {self.stop_duration_str}.") - def _cooldown_period(self, pair: str, date_now: datetime, ) -> ProtectionReturn: + def _cooldown_period(self, pair: str, date_now: datetime) -> Optional[ProtectionReturn]: """ Get last trade for this pair """ @@ -45,11 +47,15 @@ class CooldownPeriod(IProtection): 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 ProtectionReturn( + lock=True, + until=until, + reason=self._reason(), + ) - return False, None, None + return None - def global_stop(self, date_now: datetime) -> ProtectionReturn: + def global_stop(self, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]: """ Stops trading (position entering) for all pairs This must evaluate to true for the whole period of the "cooldown period". @@ -57,9 +63,10 @@ class CooldownPeriod(IProtection): If true, all pairs will be locked with until """ # Not implemented for cooldown period. - return False, None, None + return None - def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn: + def stop_per_pair( + self, pair: str, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]: """ Stops trading (position entering) for this pair This must evaluate to true for the whole period of the "cooldown period". diff --git a/freqtrade/plugins/protections/iprotection.py b/freqtrade/plugins/protections/iprotection.py index e0a89e334..890988226 100644 --- a/freqtrade/plugins/protections/iprotection.py +++ b/freqtrade/plugins/protections/iprotection.py @@ -1,9 +1,11 @@ import logging from abc import ABC, abstractmethod +from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional +from freqtrade.constants import LongShort from freqtrade.exchange import timeframe_to_minutes from freqtrade.misc import plural from freqtrade.mixins import LoggingMixin @@ -12,7 +14,13 @@ from freqtrade.persistence import LocalTrade logger = logging.getLogger(__name__) -ProtectionReturn = Tuple[bool, Optional[datetime], Optional[str]] + +@dataclass +class ProtectionReturn: + lock: bool + until: datetime + reason: Optional[str] + lock_side: str = '*' class IProtection(LoggingMixin, ABC): @@ -80,14 +88,15 @@ class IProtection(LoggingMixin, ABC): """ @abstractmethod - def global_stop(self, date_now: datetime) -> ProtectionReturn: + def global_stop(self, date_now: datetime, side: LongShort) -> Optional[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: + def stop_per_pair( + self, pair: str, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]: """ Stops trading (position entering) for this pair This must evaluate to true for the whole period of the "cooldown period". diff --git a/freqtrade/plugins/protections/low_profit_pairs.py b/freqtrade/plugins/protections/low_profit_pairs.py index 7822ce73c..7d5d6054d 100644 --- a/freqtrade/plugins/protections/low_profit_pairs.py +++ b/freqtrade/plugins/protections/low_profit_pairs.py @@ -1,8 +1,9 @@ import logging from datetime import datetime, timedelta -from typing import Any, Dict +from typing import Any, Dict, Optional +from freqtrade.constants import LongShort from freqtrade.persistence import Trade from freqtrade.plugins.protections import IProtection, ProtectionReturn @@ -35,7 +36,7 @@ class LowProfitPairs(IProtection): 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: + def _low_profit(self, date_now: datetime, pair: str) -> Optional[ProtectionReturn]: """ Evaluate recent trades for pair """ @@ -51,7 +52,7 @@ class LowProfitPairs(IProtection): # trades = Trade.get_trades(filters).all() if len(trades) < self._trade_limit: # Not enough trades in the relevant period - return False, None, None + return None profit = sum(trade.close_profit for trade in trades if trade.close_profit) if profit < self._required_profit: @@ -60,20 +61,25 @@ class LowProfitPairs(IProtection): f"within {self._lookback_period} minutes.", logger.info) until = self.calculate_lock_end(trades, self._stop_duration) - return True, until, self._reason(profit) + return ProtectionReturn( + lock=True, + until=until, + reason=self._reason(profit), + ) - return False, None, None + return None - def global_stop(self, date_now: datetime) -> ProtectionReturn: + def global_stop(self, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]: """ Stops trading (position entering) for all pairs This must evaluate to true for the whole period of the "cooldown period". :return: Tuple of [bool, until, reason]. If true, all pairs will be locked with until """ - return False, None, None + return None - def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn: + def stop_per_pair( + self, pair: str, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]: """ Stops trading (position entering) for this pair This must evaluate to true for the whole period of the "cooldown period". diff --git a/freqtrade/plugins/protections/max_drawdown_protection.py b/freqtrade/plugins/protections/max_drawdown_protection.py index 4111b7ff4..e0b016cb8 100644 --- a/freqtrade/plugins/protections/max_drawdown_protection.py +++ b/freqtrade/plugins/protections/max_drawdown_protection.py @@ -1,10 +1,11 @@ import logging from datetime import datetime, timedelta -from typing import Any, Dict +from typing import Any, Dict, Optional import pandas as pd +from freqtrade.constants import LongShort from freqtrade.data.metrics import calculate_max_drawdown from freqtrade.persistence import Trade from freqtrade.plugins.protections import IProtection, ProtectionReturn @@ -39,7 +40,7 @@ class MaxDrawdown(IProtection): return (f'{drawdown} passed {self._max_allowed_drawdown} in {self.lookback_period_str}, ' f'locking for {self.stop_duration_str}.') - def _max_drawdown(self, date_now: datetime) -> ProtectionReturn: + def _max_drawdown(self, date_now: datetime) -> Optional[ProtectionReturn]: """ Evaluate recent trades for drawdown ... """ @@ -51,14 +52,14 @@ class MaxDrawdown(IProtection): if len(trades) < self._trade_limit: # Not enough trades in the relevant period - return False, None, None + return None # Drawdown is always positive try: # TODO: This should use absolute profit calculation, considering account balance. drawdown, _, _, _, _, _ = calculate_max_drawdown(trades_df, value_col='close_profit') except ValueError: - return False, None, None + return None if drawdown > self._max_allowed_drawdown: self.log_once( @@ -66,11 +67,15 @@ class MaxDrawdown(IProtection): f" within {self.lookback_period_str}.", logger.info) until = self.calculate_lock_end(trades, self._stop_duration) - return True, until, self._reason(drawdown) + return ProtectionReturn( + lock=True, + until=until, + reason=self._reason(drawdown), + ) - return False, None, None + return None - def global_stop(self, date_now: datetime) -> ProtectionReturn: + def global_stop(self, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]: """ Stops trading (position entering) for all pairs This must evaluate to true for the whole period of the "cooldown period". @@ -79,11 +84,12 @@ class MaxDrawdown(IProtection): """ return self._max_drawdown(date_now) - def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn: + def stop_per_pair( + self, pair: str, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]: """ Stops trading (position entering) for this pair This must evaluate to true for the whole period of the "cooldown period". :return: Tuple of [bool, until, reason]. If true, this pair will be locked with until """ - return False, None, None + return None diff --git a/freqtrade/plugins/protections/stoploss_guard.py b/freqtrade/plugins/protections/stoploss_guard.py index 8d7fb2a0e..f9fe039d6 100644 --- a/freqtrade/plugins/protections/stoploss_guard.py +++ b/freqtrade/plugins/protections/stoploss_guard.py @@ -1,8 +1,9 @@ import logging from datetime import datetime, timedelta -from typing import Any, Dict +from typing import Any, Dict, Optional +from freqtrade.constants import LongShort from freqtrade.enums import ExitType from freqtrade.persistence import Trade from freqtrade.plugins.protections import IProtection, ProtectionReturn @@ -21,6 +22,7 @@ class StoplossGuard(IProtection): self._trade_limit = protection_config.get('trade_limit', 10) self._disable_global_stop = protection_config.get('only_per_pair', False) + self._only_per_side = protection_config.get('only_per_side', False) def short_desc(self) -> str: """ @@ -36,7 +38,8 @@ class StoplossGuard(IProtection): 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: + def _stoploss_guard( + self, date_now: datetime, pair: Optional[str], side: str) -> Optional[ProtectionReturn]: """ Evaluate recent trades """ @@ -48,15 +51,24 @@ class StoplossGuard(IProtection): ExitType.STOPLOSS_ON_EXCHANGE.value) and trade.close_profit and trade.close_profit < 0)] + if self._only_per_side: + # Long or short trades only + trades = [trade for trade in trades if trade.trade_direction == side] + if len(trades) < self._trade_limit: - return False, None, None + return None 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 ProtectionReturn( + lock=True, + until=until, + reason=self._reason(), + lock_side=(side if self._only_per_side else '*') + ) - def global_stop(self, date_now: datetime) -> ProtectionReturn: + def global_stop(self, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]: """ Stops trading (position entering) for all pairs This must evaluate to true for the whole period of the "cooldown period". @@ -64,14 +76,15 @@ class StoplossGuard(IProtection): If true, all pairs will be locked with until """ if self._disable_global_stop: - return False, None, None - return self._stoploss_guard(date_now, None) + return None + return self._stoploss_guard(date_now, None, side) - def stop_per_pair(self, pair: str, date_now: datetime) -> ProtectionReturn: + def stop_per_pair( + self, pair: str, date_now: datetime, side: LongShort) -> Optional[ProtectionReturn]: """ Stops trading (position entering) for this pair This must evaluate to true for the whole period of the "cooldown period". :return: Tuple of [bool, until, reason]. If true, this pair will be locked with until """ - return self._stoploss_guard(date_now, pair) + return self._stoploss_guard(date_now, pair, side) diff --git a/freqtrade/rpc/api_server/api_schemas.py b/freqtrade/rpc/api_server/api_schemas.py index a9135cce2..d78ea8b78 100644 --- a/freqtrade/rpc/api_server/api_schemas.py +++ b/freqtrade/rpc/api_server/api_schemas.py @@ -291,6 +291,7 @@ class LockModel(BaseModel): lock_time: str lock_timestamp: int pair: str + side: str reason: str diff --git a/freqtrade/strategy/interface.py b/freqtrade/strategy/interface.py index a23269770..51ed1ba48 100644 --- a/freqtrade/strategy/interface.py +++ b/freqtrade/strategy/interface.py @@ -545,7 +545,7 @@ class IStrategy(ABC, HyperStrategyMixin): """ return self.__class__.__name__ - def lock_pair(self, pair: str, until: datetime, reason: str = None) -> None: + def lock_pair(self, pair: str, until: datetime, reason: str = None, side: str = '*') -> None: """ Locks pair until a given timestamp happens. Locked pairs are not analyzed, and are prevented from opening new trades. @@ -555,8 +555,9 @@ class IStrategy(ABC, HyperStrategyMixin): :param until: datetime in UTC until the pair should be blocked from opening new trades. Needs to be timezone aware `datetime.now(timezone.utc)` :param reason: Optional string explaining why the pair was locked. + :param side: Side to check, can be long, short or '*' """ - PairLocks.lock_pair(pair, until, reason) + PairLocks.lock_pair(pair, until, reason, side=side) def unlock_pair(self, pair: str) -> None: """ @@ -576,7 +577,7 @@ class IStrategy(ABC, HyperStrategyMixin): """ PairLocks.unlock_reason(reason, datetime.now(timezone.utc)) - def is_pair_locked(self, pair: str, candle_date: datetime = None) -> bool: + def is_pair_locked(self, pair: str, *, candle_date: datetime = None, side: str = '*') -> bool: """ Checks if a pair is currently locked The 2nd, optional parameter ensures that locks are applied until the new candle arrives, @@ -584,15 +585,16 @@ class IStrategy(ABC, HyperStrategyMixin): of 2 seconds for an entry order to happen on an old signal. :param pair: "Pair to check" :param candle_date: Date of the last candle. Optional, defaults to current date + :param side: Side to check, can be long, short or '*' :returns: locking state of the pair in question. """ if not candle_date: # Simple call ... - return PairLocks.is_pair_locked(pair) + return PairLocks.is_pair_locked(pair, side=side) else: lock_time = timeframe_to_next_date(self.timeframe, candle_date) - return PairLocks.is_pair_locked(pair, lock_time) + return PairLocks.is_pair_locked(pair, lock_time, side=side) def analyze_ticker(self, dataframe: DataFrame, metadata: dict) -> DataFrame: """ diff --git a/tests/plugins/test_pairlocks.py b/tests/plugins/test_pairlocks.py index f9e5583ed..0ba9bb746 100644 --- a/tests/plugins/test_pairlocks.py +++ b/tests/plugins/test_pairlocks.py @@ -21,8 +21,22 @@ def test_PairLocks(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 + # ETH/BTC locked for 4 minutes (on both sides) assert PairLocks.is_pair_locked(pair) + assert PairLocks.is_pair_locked(pair, side='long') + assert PairLocks.is_pair_locked(pair, side='short') + + pair = 'BNB/BTC' + PairLocks.lock_pair(pair, arrow.utcnow().shift(minutes=4).datetime, side='long') + assert not PairLocks.is_pair_locked(pair) + assert PairLocks.is_pair_locked(pair, side='long') + assert not PairLocks.is_pair_locked(pair, side='short') + + pair = 'BNB/USDT' + PairLocks.lock_pair(pair, arrow.utcnow().shift(minutes=4).datetime, side='short') + assert not PairLocks.is_pair_locked(pair) + assert not PairLocks.is_pair_locked(pair, side='long') + assert PairLocks.is_pair_locked(pair, side='short') # XRP/BTC should not be locked now pair = 'XRP/BTC' diff --git a/tests/plugins/test_protections.py b/tests/plugins/test_protections.py index 6b69f5481..b2dc99610 100644 --- a/tests/plugins/test_protections.py +++ b/tests/plugins/test_protections.py @@ -11,9 +11,10 @@ from tests.conftest import get_patched_freqtradebot, log_has_re def generate_mock_trade(pair: str, fee: float, is_open: bool, - sell_reason: str = ExitType.EXIT_SIGNAL, + exit_reason: str = ExitType.EXIT_SIGNAL, min_ago_open: int = None, min_ago_close: int = None, - profit_rate: float = 0.9 + profit_rate: float = 0.9, + is_short: bool = False, ): open_rate = random.random() @@ -28,11 +29,12 @@ def generate_mock_trade(pair: str, fee: float, is_open: bool, is_open=is_open, amount=0.01 / open_rate, exchange='binance', + is_short=is_short, ) trade.recalc_open_trade_value() if not is_open: - trade.close(open_rate * profit_rate) - trade.exit_reason = sell_reason + trade.close(open_rate * (2 - profit_rate if is_short else profit_rate)) + trade.exit_reason = exit_reason return trade @@ -45,9 +47,9 @@ def test_protectionmanager(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) + assert handler.global_stop(datetime.utcnow(), '*') is None if not handler.has_local_stop: - assert handler.stop_per_pair('XRP/BTC', datetime.utcnow()) == (False, None, None) + assert handler.stop_per_pair('XRP/BTC', datetime.utcnow(), '*') is None @pytest.mark.parametrize('timeframe,expected,protconf', [ @@ -68,7 +70,7 @@ def test_protectionmanager(mocker, default_conf): ('1h', [60, 540], [{"method": "StoplossGuard", "lookback_period_candles": 1, "stop_duration_candles": 9}]), ]) -def test_protections_init(mocker, default_conf, timeframe, expected, protconf): +def test_protections_init(default_conf, timeframe, expected, protconf): default_conf['timeframe'] = timeframe man = ProtectionManager(default_conf, protconf) assert len(man._protection_handlers) == len(protconf) @@ -76,8 +78,10 @@ def test_protections_init(mocker, default_conf, timeframe, expected, protconf): assert man._protection_handlers[0]._stop_duration == expected[1] +@pytest.mark.parametrize('is_short', [False, True]) @pytest.mark.usefixtures("init_persistence") -def test_stoploss_guard(mocker, default_conf, fee, caplog): +def test_stoploss_guard(mocker, default_conf, fee, caplog, is_short): + # Active for both sides (long and short) default_conf['protections'] = [{ "method": "StoplossGuard", "lookback_period": 60, @@ -91,8 +95,8 @@ def test_stoploss_guard(mocker, default_conf, fee, caplog): caplog.clear() Trade.query.session.add(generate_mock_trade( - 'XRP/BTC', fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value, - min_ago_open=200, min_ago_close=30, + 'XRP/BTC', fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value, + min_ago_open=200, min_ago_close=30, is_short=is_short, )) assert not freqtrade.protections.global_stop() @@ -100,13 +104,13 @@ def test_stoploss_guard(mocker, default_conf, fee, caplog): caplog.clear() # This trade does not count, as it's closed too long ago Trade.query.session.add(generate_mock_trade( - 'BCH/BTC', fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value, - min_ago_open=250, min_ago_close=100, + 'BCH/BTC', fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value, + min_ago_open=250, min_ago_close=100, is_short=is_short, )) Trade.query.session.add(generate_mock_trade( - 'ETH/BTC', fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value, - min_ago_open=240, min_ago_close=30, + 'ETH/BTC', fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value, + min_ago_open=240, min_ago_close=30, is_short=is_short, )) # 3 Trades closed - but the 2nd has been closed too long ago. assert not freqtrade.protections.global_stop() @@ -114,8 +118,8 @@ def test_stoploss_guard(mocker, default_conf, fee, caplog): caplog.clear() Trade.query.session.add(generate_mock_trade( - 'LTC/BTC', fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value, - min_ago_open=180, min_ago_close=30, + 'LTC/BTC', fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value, + min_ago_open=180, min_ago_close=30, is_short=is_short, )) assert freqtrade.protections.global_stop() @@ -130,15 +134,19 @@ def test_stoploss_guard(mocker, default_conf, fee, caplog): @pytest.mark.parametrize('only_per_pair', [False, True]) +@pytest.mark.parametrize('only_per_side', [False, True]) @pytest.mark.usefixtures("init_persistence") -def test_stoploss_guard_perpair(mocker, default_conf, fee, caplog, only_per_pair): +def test_stoploss_guard_perpair(mocker, default_conf, fee, caplog, only_per_pair, only_per_side): default_conf['protections'] = [{ "method": "StoplossGuard", "lookback_period": 60, "trade_limit": 2, "stop_duration": 60, - "only_per_pair": only_per_pair + "only_per_pair": only_per_pair, + "only_per_side": only_per_side, }] + check_side = 'long' if only_per_side else '*' + is_short = False freqtrade = get_patched_freqtradebot(mocker, default_conf) message = r"Trading stopped due to .*" pair = 'XRP/BTC' @@ -148,8 +156,8 @@ def test_stoploss_guard_perpair(mocker, default_conf, fee, caplog, only_per_pair caplog.clear() Trade.query.session.add(generate_mock_trade( - pair, fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value, - min_ago_open=200, min_ago_close=30, profit_rate=0.9, + pair, fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value, + min_ago_open=200, min_ago_close=30, profit_rate=0.9, is_short=is_short )) assert not freqtrade.protections.stop_per_pair(pair) @@ -158,13 +166,13 @@ def test_stoploss_guard_perpair(mocker, default_conf, fee, caplog, only_per_pair caplog.clear() # This trade does not count, as it's closed too long ago Trade.query.session.add(generate_mock_trade( - pair, fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value, - min_ago_open=250, min_ago_close=100, profit_rate=0.9, + pair, fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value, + min_ago_open=250, min_ago_close=100, profit_rate=0.9, is_short=is_short )) # Trade does not count for per pair stop as it's the wrong pair. Trade.query.session.add(generate_mock_trade( - 'ETH/BTC', fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value, - min_ago_open=240, min_ago_close=30, profit_rate=0.9, + 'ETH/BTC', fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value, + min_ago_open=240, min_ago_close=30, profit_rate=0.9, is_short=is_short )) # 3 Trades closed - but the 2nd has been closed too long ago. assert not freqtrade.protections.stop_per_pair(pair) @@ -176,16 +184,34 @@ def test_stoploss_guard_perpair(mocker, default_conf, fee, caplog, only_per_pair caplog.clear() + # Trade does not count potentially, as it's in the wrong direction + Trade.query.session.add(generate_mock_trade( + pair, fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value, + min_ago_open=150, min_ago_close=25, profit_rate=0.9, is_short=not is_short + )) + freqtrade.protections.stop_per_pair(pair) + assert freqtrade.protections.global_stop() != only_per_pair + assert PairLocks.is_pair_locked(pair, side=check_side) != (only_per_side and only_per_pair) + assert PairLocks.is_global_lock(side=check_side) != only_per_pair + if only_per_side: + assert not PairLocks.is_pair_locked(pair, side='*') + assert not PairLocks.is_global_lock(side='*') + + caplog.clear() + # 2nd Trade that counts with correct pair Trade.query.session.add(generate_mock_trade( - pair, fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value, - min_ago_open=180, min_ago_close=30, profit_rate=0.9, + pair, fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value, + min_ago_open=180, min_ago_close=30, profit_rate=0.9, is_short=is_short )) 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 + assert PairLocks.is_pair_locked(pair, side=check_side) + assert PairLocks.is_global_lock(side=check_side) != only_per_pair + if only_per_side: + assert not PairLocks.is_pair_locked(pair, side='*') + assert not PairLocks.is_global_lock(side='*') @pytest.mark.usefixtures("init_persistence") @@ -203,7 +229,7 @@ def test_CooldownPeriod(mocker, default_conf, fee, caplog): caplog.clear() Trade.query.session.add(generate_mock_trade( - 'XRP/BTC', fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value, + 'XRP/BTC', fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value, min_ago_open=200, min_ago_close=30, )) @@ -213,7 +239,7 @@ def test_CooldownPeriod(mocker, default_conf, fee, caplog): assert not PairLocks.is_global_lock() Trade.query.session.add(generate_mock_trade( - 'ETH/BTC', fee.return_value, False, sell_reason=ExitType.ROI.value, + 'ETH/BTC', fee.return_value, False, exit_reason=ExitType.ROI.value, min_ago_open=205, min_ago_close=35, )) @@ -242,7 +268,7 @@ def test_LowProfitPairs(mocker, default_conf, fee, caplog): caplog.clear() Trade.query.session.add(generate_mock_trade( - 'XRP/BTC', fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value, + 'XRP/BTC', fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value, min_ago_open=800, min_ago_close=450, profit_rate=0.9, )) @@ -253,7 +279,7 @@ def test_LowProfitPairs(mocker, default_conf, fee, caplog): assert not PairLocks.is_global_lock() Trade.query.session.add(generate_mock_trade( - 'XRP/BTC', fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value, + 'XRP/BTC', fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value, min_ago_open=200, min_ago_close=120, profit_rate=0.9, )) @@ -265,14 +291,14 @@ def test_LowProfitPairs(mocker, default_conf, fee, caplog): # Add positive trade Trade.query.session.add(generate_mock_trade( - 'XRP/BTC', fee.return_value, False, sell_reason=ExitType.ROI.value, + 'XRP/BTC', fee.return_value, False, exit_reason=ExitType.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.query.session.add(generate_mock_trade( - 'XRP/BTC', fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value, + 'XRP/BTC', fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value, min_ago_open=110, min_ago_close=20, profit_rate=0.8, )) @@ -300,15 +326,15 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog): caplog.clear() Trade.query.session.add(generate_mock_trade( - 'XRP/BTC', fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value, + 'XRP/BTC', fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value, min_ago_open=1000, min_ago_close=900, profit_rate=1.1, )) Trade.query.session.add(generate_mock_trade( - 'ETH/BTC', fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value, + 'ETH/BTC', fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value, min_ago_open=1000, min_ago_close=900, profit_rate=1.1, )) Trade.query.session.add(generate_mock_trade( - 'NEO/BTC', fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value, + 'NEO/BTC', fee.return_value, False, exit_reason=ExitType.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 @@ -316,7 +342,7 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog): assert not freqtrade.protections.stop_per_pair('XRP/BTC') Trade.query.session.add(generate_mock_trade( - 'XRP/BTC', fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value, + 'XRP/BTC', fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value, min_ago_open=500, min_ago_close=400, profit_rate=0.9, )) # Not locked with one trade @@ -326,7 +352,7 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog): assert not PairLocks.is_global_lock() Trade.query.session.add(generate_mock_trade( - 'XRP/BTC', fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value, + 'XRP/BTC', fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value, min_ago_open=1200, min_ago_close=1100, profit_rate=0.5, )) @@ -339,7 +365,7 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog): # Winning trade ... (should not lock, does not change drawdown!) Trade.query.session.add(generate_mock_trade( - 'XRP/BTC', fee.return_value, False, sell_reason=ExitType.ROI.value, + 'XRP/BTC', fee.return_value, False, exit_reason=ExitType.ROI.value, min_ago_open=320, min_ago_close=410, profit_rate=1.5, )) assert not freqtrade.protections.global_stop() @@ -349,7 +375,7 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog): # Add additional negative trade, causing a loss of > 15% Trade.query.session.add(generate_mock_trade( - 'XRP/BTC', fee.return_value, False, sell_reason=ExitType.ROI.value, + 'XRP/BTC', fee.return_value, False, exit_reason=ExitType.ROI.value, min_ago_open=20, min_ago_close=10, profit_rate=0.8, )) assert not freqtrade.protections.stop_per_pair('XRP/BTC') diff --git a/tests/strategy/test_interface.py b/tests/strategy/test_interface.py index ea81fe968..6e57a3182 100644 --- a/tests/strategy/test_interface.py +++ b/tests/strategy/test_interface.py @@ -666,23 +666,23 @@ def test_is_pair_locked(default_conf): assert not strategy.is_pair_locked(pair) # latest candle is from 14:20, lock goes to 14:30 - assert strategy.is_pair_locked(pair, lock_time + timedelta(minutes=-10)) - assert strategy.is_pair_locked(pair, lock_time + timedelta(minutes=-50)) + assert strategy.is_pair_locked(pair, candle_date=lock_time + timedelta(minutes=-10)) + assert strategy.is_pair_locked(pair, candle_date=lock_time + timedelta(minutes=-50)) # latest candle is from 14:25 (lock should be lifted) # Since this is the "new candle" available at 14:30 - assert not strategy.is_pair_locked(pair, lock_time + timedelta(minutes=-4)) + assert not strategy.is_pair_locked(pair, candle_date=lock_time + timedelta(minutes=-4)) # Should not be locked after time expired - assert not strategy.is_pair_locked(pair, lock_time + timedelta(minutes=10)) + assert not strategy.is_pair_locked(pair, candle_date=lock_time + timedelta(minutes=10)) # Change timeframe to 15m strategy.timeframe = '15m' # Candle from 14:14 - lock goes until 14:30 - assert strategy.is_pair_locked(pair, lock_time + timedelta(minutes=-16)) - assert strategy.is_pair_locked(pair, lock_time + timedelta(minutes=-15, seconds=-2)) + assert strategy.is_pair_locked(pair, candle_date=lock_time + timedelta(minutes=-16)) + assert strategy.is_pair_locked(pair, candle_date=lock_time + timedelta(minutes=-15, seconds=-2)) # Candle from 14:15 - lock goes until 14:30 - assert not strategy.is_pair_locked(pair, lock_time + timedelta(minutes=-15)) + assert not strategy.is_pair_locked(pair, candle_date=lock_time + timedelta(minutes=-15)) def test_is_informative_pairs_callback(default_conf): diff --git a/tests/test_freqtradebot.py b/tests/test_freqtradebot.py index 89fe88a2c..bb9c0c391 100644 --- a/tests/test_freqtradebot.py +++ b/tests/test_freqtradebot.py @@ -21,6 +21,7 @@ from freqtrade.exceptions import (DependencyException, ExchangeError, Insufficie from freqtrade.freqtradebot import FreqtradeBot from freqtrade.persistence import Order, PairLocks, Trade from freqtrade.persistence.models import PairLock +from freqtrade.plugins.protections.iprotection import ProtectionReturn from freqtrade.worker import Worker from tests.conftest import (create_mock_trades, get_patched_freqtradebot, get_patched_worker, log_has, log_has_re, patch_edge, patch_exchange, patch_get_signal, @@ -420,7 +421,7 @@ def test_enter_positions_global_pairlock(default_conf_usdt, ticker_usdt, limit_b assert not log_has_re(message, caplog) caplog.clear() - PairLocks.lock_pair('*', arrow.utcnow().shift(minutes=20).datetime, 'Just because') + PairLocks.lock_pair('*', arrow.utcnow().shift(minutes=20).datetime, 'Just because', side='*') n = freqtrade.enter_positions() assert n == 0 assert log_has_re(message, caplog) @@ -441,9 +442,9 @@ def test_handle_protections(mocker, default_conf_usdt, fee, is_short): freqtrade = get_patched_freqtradebot(mocker, default_conf_usdt) freqtrade.protections._protection_handlers[1].global_stop = MagicMock( - return_value=(True, arrow.utcnow().shift(hours=1).datetime, "asdf")) + return_value=ProtectionReturn(True, arrow.utcnow().shift(hours=1).datetime, "asdf")) create_mock_trades(fee, is_short) - freqtrade.handle_protections('ETC/BTC') + 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 @@ -3793,13 +3794,16 @@ def test_locked_pairs(default_conf_usdt, ticker_usdt, fee, exit_check=ExitCheckTuple(exit_type=ExitType.STOP_LOSS) ) trade.close(ticker_usdt_sell_down()['bid']) - assert freqtrade.strategy.is_pair_locked(trade.pair) + assert freqtrade.strategy.is_pair_locked(trade.pair, side='*') + # Boths sides are locked + assert freqtrade.strategy.is_pair_locked(trade.pair, side='long') + assert freqtrade.strategy.is_pair_locked(trade.pair, side='short') # reinit - should buy other pair. caplog.clear() freqtrade.enter_positions() - assert log_has_re(f"Pair {trade.pair} is still locked.*", caplog) + assert log_has_re(fr"Pair {trade.pair} \* is locked.*", caplog) @pytest.mark.parametrize("is_short", [False, True]) diff --git a/tests/test_persistence.py b/tests/test_persistence.py index 801e0e35f..b66c12086 100644 --- a/tests/test_persistence.py +++ b/tests/test_persistence.py @@ -15,6 +15,7 @@ from freqtrade.enums import TradingMode from freqtrade.exceptions import DependencyException, OperationalException from freqtrade.persistence import LocalTrade, Order, Trade, clean_dry_run_db, init_db from freqtrade.persistence.migrations import get_last_sequence_ids, set_sequence_ids +from freqtrade.persistence.models import PairLock from tests.conftest import create_mock_trades, create_mock_trades_with_leverage, log_has, log_has_re @@ -1427,6 +1428,55 @@ def test_migrate_set_sequence_ids(): assert engine.begin.call_count == 0 +def test_migrate_pairlocks(mocker, default_conf, fee, caplog): + """ + Test Database migration (starting with new pairformat) + """ + caplog.set_level(logging.DEBUG) + # Always create all columns apart from the last! + create_table_old = """CREATE TABLE pairlocks ( + id INTEGER NOT NULL, + pair VARCHAR(25) NOT NULL, + reason VARCHAR(255), + lock_time DATETIME NOT NULL, + lock_end_time DATETIME NOT NULL, + active BOOLEAN NOT NULL, + PRIMARY KEY (id) + ) + """ + create_index1 = "CREATE INDEX ix_pairlocks_pair ON pairlocks (pair)" + create_index2 = "CREATE INDEX ix_pairlocks_lock_end_time ON pairlocks (lock_end_time)" + create_index3 = "CREATE INDEX ix_pairlocks_active ON pairlocks (active)" + insert_table_old = """INSERT INTO pairlocks ( + id, pair, reason, lock_time, lock_end_time, active) + VALUES (1, 'ETH/BTC', 'Auto lock', '2021-07-12 18:41:03', '2021-07-11 18:45:00', 1) + """ + insert_table_old2 = """INSERT INTO pairlocks ( + id, pair, reason, lock_time, lock_end_time, active) + VALUES (2, '*', 'Lock all', '2021-07-12 18:41:03', '2021-07-12 19:00:00', 1) + """ + engine = create_engine('sqlite://') + mocker.patch('freqtrade.persistence.models.create_engine', lambda *args, **kwargs: engine) + # Create table using the old format + with engine.begin() as connection: + connection.execute(text(create_table_old)) + + connection.execute(text(insert_table_old)) + connection.execute(text(insert_table_old2)) + connection.execute(text(create_index1)) + connection.execute(text(create_index2)) + connection.execute(text(create_index3)) + + init_db(default_conf['db_url'], default_conf['dry_run']) + + assert len(PairLock.query.all()) == 2 + assert len(PairLock.query.filter(PairLock.pair == '*').all()) == 1 + pairlocks = PairLock.query.filter(PairLock.pair == 'ETH/BTC').all() + assert len(pairlocks) == 1 + pairlocks[0].pair == 'ETH/BTC' + pairlocks[0].side == '*' + + def test_adjust_stop_loss(fee): trade = Trade( pair='ADA/USDT',