Merge pull request #6716 from freqtrade/pairlocks_direction

Pairlocks direction
This commit is contained in:
Matthias 2022-05-01 17:04:20 +02:00 committed by GitHub
commit 582e30bca6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 362 additions and 155 deletions

View File

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

View File

@ -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
}
]
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <reason> until <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".

View File

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

View File

@ -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 <reason> until <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".

View File

@ -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 <reason> until <until>
"""
return False, None, None
return None

View File

@ -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 <reason> until <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 <reason> until <until>
"""
return self._stoploss_guard(date_now, pair)
return self._stoploss_guard(date_now, pair, side)

View File

@ -291,6 +291,7 @@ class LockModel(BaseModel):
lock_time: str
lock_timestamp: int
pair: str
side: str
reason: str

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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