Merge pull request #6716 from freqtrade/pairlocks_direction
Pairlocks direction
This commit is contained in:
commit
582e30bca6
@ -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.
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
]
|
||||
```
|
||||
|
@ -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())
|
||||
|
@ -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()
|
||||
|
@ -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. "
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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]:
|
||||
|
@ -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
|
||||
|
@ -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".
|
||||
|
@ -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".
|
||||
|
@ -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".
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -291,6 +291,7 @@ class LockModel(BaseModel):
|
||||
lock_time: str
|
||||
lock_timestamp: int
|
||||
pair: str
|
||||
side: str
|
||||
reason: str
|
||||
|
||||
|
||||
|
@ -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:
|
||||
"""
|
||||
|
@ -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'
|
||||
|
@ -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')
|
||||
|
@ -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):
|
||||
|
@ -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])
|
||||
|
@ -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',
|
||||
|
Loading…
Reference in New Issue
Block a user