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()`
|
* `global_stop()`
|
||||||
* `stop_per_pair()`.
|
* `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 pair - boolean
|
||||||
* lock until - datetime - until when should the pair be locked (will be rounded up to the next new candle)
|
* 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
|
* 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.
|
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.
|
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.
|
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
|
``` python
|
||||||
@ -59,7 +61,8 @@ def protections(self):
|
|||||||
"lookback_period_candles": 24,
|
"lookback_period_candles": 24,
|
||||||
"trade_limit": 4,
|
"trade_limit": 4,
|
||||||
"stop_duration_candles": 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, "
|
logger.info("No currency pair in active pair whitelist, "
|
||||||
"but checking to exit open trades.")
|
"but checking to exit open trades.")
|
||||||
return trades_created
|
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('*')
|
lock = PairLocks.get_pair_longest_lock('*')
|
||||||
if lock:
|
if lock:
|
||||||
self.log_once(f"Global pairlock active until "
|
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)
|
analyzed_df, _ = self.dataprovider.get_analyzed_dataframe(pair, self.strategy.timeframe)
|
||||||
nowtime = analyzed_df.iloc[-1]['date'] if len(analyzed_df) > 0 else None
|
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
|
# 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
|
# 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 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)
|
stake_amount = self.wallets.get_trade_stake_amount(pair, self.edge)
|
||||||
|
|
||||||
bid_check_dom = self.config.get('entry_pricing', {}).get('check_depth_of_market', {})
|
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 not trade.is_open:
|
||||||
if send_msg and not stoploss_order and not trade.open_order_id:
|
if send_msg and not stoploss_order and not trade.open_order_id:
|
||||||
self._notify_exit(trade, '', True)
|
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:
|
elif send_msg and not trade.open_order_id:
|
||||||
# Enter fill
|
# Enter fill
|
||||||
self._notify_enter(trade, order, fill=True)
|
self._notify_enter(trade, order, fill=True)
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def handle_protections(self, pair: str) -> None:
|
def handle_protections(self, pair: str, side: LongShort) -> None:
|
||||||
prot_trig = self.protections.stop_per_pair(pair)
|
prot_trig = self.protections.stop_per_pair(pair, side=side)
|
||||||
if prot_trig:
|
if prot_trig:
|
||||||
msg = {'type': RPCMessageType.PROTECTION_TRIGGER, }
|
msg = {'type': RPCMessageType.PROTECTION_TRIGGER, }
|
||||||
msg.update(prot_trig.to_json())
|
msg.update(prot_trig.to_json())
|
||||||
self.rpc.send_msg(msg)
|
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:
|
if prot_trig_glb:
|
||||||
msg = {'type': RPCMessageType.PROTECTION_TRIGGER_GLOBAL, }
|
msg = {'type': RPCMessageType.PROTECTION_TRIGGER_GLOBAL, }
|
||||||
msg.update(prot_trig_glb.to_json())
|
msg.update(prot_trig_glb.to_json())
|
||||||
|
@ -861,10 +861,11 @@ class Backtesting:
|
|||||||
return 'short'
|
return 'short'
|
||||||
return None
|
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:
|
if enable_protections:
|
||||||
self.protections.stop_per_pair(pair, current_time)
|
self.protections.stop_per_pair(pair, current_time, side)
|
||||||
self.protections.global_stop(current_time)
|
self.protections.global_stop(current_time, side)
|
||||||
|
|
||||||
def check_order_cancel(self, trade: LocalTrade, current_time) -> bool:
|
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 self.trade_slot_available(max_open_trades, open_trade_count_start)
|
||||||
and current_time != end_date
|
and current_time != end_date
|
||||||
and trade_dir is not None
|
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)
|
trade = self._enter_trade(pair, row, trade_dir)
|
||||||
if trade:
|
if trade:
|
||||||
@ -1014,7 +1015,8 @@ class Backtesting:
|
|||||||
LocalTrade.close_bt_trade(trade)
|
LocalTrade.close_bt_trade(trade)
|
||||||
trades.append(trade)
|
trades.append(trade)
|
||||||
self.wallets.update()
|
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.
|
# Move time one configured time_interval ahead.
|
||||||
self.progress.increment()
|
self.progress.increment()
|
||||||
|
@ -9,7 +9,7 @@ from freqtrade.exceptions import OperationalException
|
|||||||
logger = logging.getLogger(__name__)
|
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)]
|
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
|
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
|
table_back_name = backup_prefix
|
||||||
for i, table_back_name in enumerate(tabs):
|
for i, table_back_name in enumerate(tabs):
|
||||||
table_back_name = f'{backup_prefix}{i}'
|
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}"))
|
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(
|
def migrate_trades_and_orders_table(
|
||||||
decl_base, inspector, engine,
|
decl_base, inspector, engine,
|
||||||
trade_back_name: str, cols: List,
|
trade_back_name: str, cols: List,
|
||||||
@ -116,13 +126,7 @@ def migrate_trades_and_orders_table(
|
|||||||
with engine.begin() as connection:
|
with engine.begin() as connection:
|
||||||
connection.execute(text(f"alter table trades rename to {trade_back_name}"))
|
connection.execute(text(f"alter table trades rename to {trade_back_name}"))
|
||||||
|
|
||||||
with engine.begin() as connection:
|
drop_index_on_table(engine, inspector, trade_back_name)
|
||||||
# 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']}"))
|
|
||||||
|
|
||||||
order_id, trade_id = get_last_sequence_ids(engine, trade_back_name, order_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):
|
def set_sqlite_to_wal(engine):
|
||||||
if engine.name == 'sqlite' and str(engine.url) != 'sqlite://':
|
if engine.name == 'sqlite' and str(engine.url) != 'sqlite://':
|
||||||
# Set Mode to
|
# Set Mode to
|
||||||
@ -220,10 +249,13 @@ def check_migrate(engine, decl_base, previous_tables) -> None:
|
|||||||
|
|
||||||
cols_trades = inspector.get_columns('trades')
|
cols_trades = inspector.get_columns('trades')
|
||||||
cols_orders = inspector.get_columns('orders')
|
cols_orders = inspector.get_columns('orders')
|
||||||
|
cols_pairlocks = inspector.get_columns('pairlocks')
|
||||||
tabs = get_table_names_for_table(inspector, 'trades')
|
tabs = get_table_names_for_table(inspector, 'trades')
|
||||||
table_back_name = get_backup_name(tabs, 'trades_bak')
|
table_back_name = get_backup_name(tabs, 'trades_bak')
|
||||||
order_tabs = get_table_names_for_table(inspector, 'orders')
|
order_tabs = get_table_names_for_table(inspector, 'orders')
|
||||||
order_table_bak_name = get_backup_name(order_tabs, 'orders_bak')
|
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
|
# Check if migration necessary
|
||||||
# Migrates both trades and orders table!
|
# 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,
|
decl_base, inspector, engine, table_back_name, cols_trades,
|
||||||
order_table_bak_name, cols_orders)
|
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:
|
if 'orders' not in previous_tables and 'trades' in previous_tables:
|
||||||
raise OperationalException(
|
raise OperationalException(
|
||||||
"Your database seems to be very old. "
|
"Your database seems to be very old. "
|
||||||
|
@ -7,13 +7,13 @@ from decimal import Decimal
|
|||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from sqlalchemy import (Boolean, Column, DateTime, Enum, Float, ForeignKey, Integer, String,
|
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.exc import NoSuchModuleError
|
||||||
from sqlalchemy.orm import Query, declarative_base, relationship, scoped_session, sessionmaker
|
from sqlalchemy.orm import Query, declarative_base, relationship, scoped_session, sessionmaker
|
||||||
from sqlalchemy.pool import StaticPool
|
from sqlalchemy.pool import StaticPool
|
||||||
from sqlalchemy.sql.schema import UniqueConstraint
|
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.enums import ExitType, TradingMode
|
||||||
from freqtrade.exceptions import DependencyException, OperationalException
|
from freqtrade.exceptions import DependencyException, OperationalException
|
||||||
from freqtrade.leverage import interest
|
from freqtrade.leverage import interest
|
||||||
@ -393,7 +393,7 @@ class LocalTrade():
|
|||||||
return "sell"
|
return "sell"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def trade_direction(self) -> str:
|
def trade_direction(self) -> LongShort:
|
||||||
if self.is_short:
|
if self.is_short:
|
||||||
return "short"
|
return "short"
|
||||||
else:
|
else:
|
||||||
@ -1426,6 +1426,8 @@ class PairLock(_DECL_BASE):
|
|||||||
id = Column(Integer, primary_key=True)
|
id = Column(Integer, primary_key=True)
|
||||||
|
|
||||||
pair = Column(String(25), nullable=False, index=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)
|
reason = Column(String(255), nullable=True)
|
||||||
# Time the pair was locked (start time)
|
# Time the pair was locked (start time)
|
||||||
lock_time = Column(DateTime, nullable=False)
|
lock_time = Column(DateTime, nullable=False)
|
||||||
@ -1437,11 +1439,12 @@ class PairLock(_DECL_BASE):
|
|||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
lock_time = self.lock_time.strftime(DATETIME_PRINT_FORMAT)
|
lock_time = self.lock_time.strftime(DATETIME_PRINT_FORMAT)
|
||||||
lock_end_time = self.lock_end_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}, '
|
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})')
|
f'lock_end_time={lock_end_time}, reason={self.reason}, active={self.active})')
|
||||||
|
|
||||||
@staticmethod
|
@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
|
Get all currently active locks for this pair
|
||||||
:param pair: Pair to check for. Returns all current locks if pair is empty
|
: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), ]
|
PairLock.active.is_(True), ]
|
||||||
if pair:
|
if pair:
|
||||||
filters.append(PairLock.pair == 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(
|
return PairLock.query.filter(
|
||||||
*filters
|
*filters
|
||||||
)
|
)
|
||||||
@ -1466,5 +1474,6 @@ class PairLock(_DECL_BASE):
|
|||||||
'lock_end_timestamp': int(self.lock_end_time.replace(tzinfo=timezone.utc
|
'lock_end_timestamp': int(self.lock_end_time.replace(tzinfo=timezone.utc
|
||||||
).timestamp() * 1000),
|
).timestamp() * 1000),
|
||||||
'reason': self.reason,
|
'reason': self.reason,
|
||||||
|
'side': self.side,
|
||||||
'active': self.active,
|
'active': self.active,
|
||||||
}
|
}
|
||||||
|
@ -31,7 +31,7 @@ class PairLocks():
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def lock_pair(pair: str, until: datetime, reason: str = None, *,
|
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".
|
Create PairLock from now to "until".
|
||||||
Uses database by default, unless PairLocks.use_db is set to False,
|
Uses database by default, unless PairLocks.use_db is set to False,
|
||||||
@ -40,12 +40,14 @@ class PairLocks():
|
|||||||
:param until: End time of the lock. Will be rounded up to the next candle.
|
: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 reason: Reason string that will be shown as reason for the lock
|
||||||
:param now: Current timestamp. Used to determine lock start time.
|
:param now: Current timestamp. Used to determine lock start time.
|
||||||
|
:param side: Side to lock pair, can be 'long', 'short' or '*'
|
||||||
"""
|
"""
|
||||||
lock = PairLock(
|
lock = PairLock(
|
||||||
pair=pair,
|
pair=pair,
|
||||||
lock_time=now or datetime.now(timezone.utc),
|
lock_time=now or datetime.now(timezone.utc),
|
||||||
lock_end_time=timeframe_to_next_date(PairLocks.timeframe, until),
|
lock_end_time=timeframe_to_next_date(PairLocks.timeframe, until),
|
||||||
reason=reason,
|
reason=reason,
|
||||||
|
side=side,
|
||||||
active=True
|
active=True
|
||||||
)
|
)
|
||||||
if PairLocks.use_db:
|
if PairLocks.use_db:
|
||||||
@ -56,7 +58,8 @@ class PairLocks():
|
|||||||
return lock
|
return lock
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_pair_locks(pair: Optional[str], now: Optional[datetime] = None) -> List[PairLock]:
|
def get_pair_locks(
|
||||||
|
pair: Optional[str], now: Optional[datetime] = None, side: str = '*') -> List[PairLock]:
|
||||||
"""
|
"""
|
||||||
Get all currently active locks for this pair
|
Get all currently active locks for this pair
|
||||||
:param pair: Pair to check for. Returns all current locks if pair is empty
|
: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)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
if PairLocks.use_db:
|
if PairLocks.use_db:
|
||||||
return PairLock.query_pair_locks(pair, now).all()
|
return PairLock.query_pair_locks(pair, now, side).all()
|
||||||
else:
|
else:
|
||||||
locks = [lock for lock in PairLocks.locks if (
|
locks = [lock for lock in PairLocks.locks if (
|
||||||
lock.lock_end_time >= now
|
lock.lock_end_time >= now
|
||||||
and lock.active is True
|
and lock.active is True
|
||||||
and (pair is None or lock.pair == pair)
|
and (pair is None or lock.pair == pair)
|
||||||
|
and (lock.side == '*' or lock.side == side)
|
||||||
)]
|
)]
|
||||||
return locks
|
return locks
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
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)
|
locks = sorted(locks, key=lambda l: l.lock_end_time, reverse=True)
|
||||||
return locks[0] if locks else None
|
return locks[0] if locks else None
|
||||||
|
|
||||||
@staticmethod
|
@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.
|
Release all locks for this pair.
|
||||||
:param pair: Pair to unlock
|
:param pair: Pair to unlock
|
||||||
@ -97,7 +102,7 @@ class PairLocks():
|
|||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
logger.info(f"Releasing all locks for {pair}.")
|
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:
|
for lock in locks:
|
||||||
lock.active = False
|
lock.active = False
|
||||||
if PairLocks.use_db:
|
if PairLocks.use_db:
|
||||||
@ -134,7 +139,7 @@ class PairLocks():
|
|||||||
lock.active = False
|
lock.active = False
|
||||||
|
|
||||||
@staticmethod
|
@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)).
|
:param now: Datetime object (generated via datetime.now(timezone.utc)).
|
||||||
defaults to datetime.now(timezone.utc)
|
defaults to datetime.now(timezone.utc)
|
||||||
@ -142,10 +147,10 @@ class PairLocks():
|
|||||||
if not now:
|
if not now:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
return len(PairLocks.get_pair_locks('*', now)) > 0
|
return len(PairLocks.get_pair_locks('*', now, side)) > 0
|
||||||
|
|
||||||
@staticmethod
|
@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 pair: Pair to check for
|
||||||
:param now: Datetime object (generated via datetime.now(timezone.utc)).
|
:param now: Datetime object (generated via datetime.now(timezone.utc)).
|
||||||
@ -154,7 +159,10 @@ class PairLocks():
|
|||||||
if not now:
|
if not now:
|
||||||
now = datetime.now(timezone.utc)
|
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
|
@staticmethod
|
||||||
def get_all_locks() -> List[PairLock]:
|
def get_all_locks() -> List[PairLock]:
|
||||||
|
@ -5,6 +5,7 @@ import logging
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Dict, List, Optional
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from freqtrade.constants import LongShort
|
||||||
from freqtrade.persistence import PairLocks
|
from freqtrade.persistence import PairLocks
|
||||||
from freqtrade.persistence.models import PairLock
|
from freqtrade.persistence.models import PairLock
|
||||||
from freqtrade.plugins.protections import IProtection
|
from freqtrade.plugins.protections import IProtection
|
||||||
@ -44,28 +45,31 @@ class ProtectionManager():
|
|||||||
"""
|
"""
|
||||||
return [{p.name: p.short_desc()} for p in self._protection_handlers]
|
return [{p.name: p.short_desc()} for p in self._protection_handlers]
|
||||||
|
|
||||||
def global_stop(self, now: Optional[datetime] = None) -> Optional[PairLock]:
|
def global_stop(self, now: Optional[datetime] = None,
|
||||||
|
side: LongShort = 'long') -> Optional[PairLock]:
|
||||||
if not now:
|
if not now:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
result = None
|
result = None
|
||||||
for protection_handler in self._protection_handlers:
|
for protection_handler in self._protection_handlers:
|
||||||
if protection_handler.has_global_stop:
|
if protection_handler.has_global_stop:
|
||||||
lock, until, reason = protection_handler.global_stop(now)
|
lock = protection_handler.global_stop(date_now=now, side=side)
|
||||||
|
if lock and lock.until:
|
||||||
# Early stopping - first positive result blocks further trades
|
if not PairLocks.is_global_lock(lock.until, side=lock.lock_side):
|
||||||
if lock and until:
|
result = PairLocks.lock_pair(
|
||||||
if not PairLocks.is_global_lock(until):
|
'*', lock.until, lock.reason, now=now, side=lock.lock_side)
|
||||||
result = PairLocks.lock_pair('*', until, reason, now=now)
|
|
||||||
return result
|
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:
|
if not now:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
result = None
|
result = None
|
||||||
for protection_handler in self._protection_handlers:
|
for protection_handler in self._protection_handlers:
|
||||||
if protection_handler.has_local_stop:
|
if protection_handler.has_local_stop:
|
||||||
lock, until, reason = protection_handler.stop_per_pair(pair, now)
|
lock = protection_handler.stop_per_pair(
|
||||||
if lock and until:
|
pair=pair, date_now=now, side=side)
|
||||||
if not PairLocks.is_pair_locked(pair, until):
|
if lock and lock.until:
|
||||||
result = PairLocks.lock_pair(pair, until, reason, now=now)
|
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
|
return result
|
||||||
|
@ -1,7 +1,9 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from freqtrade.constants import LongShort
|
||||||
from freqtrade.persistence import Trade
|
from freqtrade.persistence import Trade
|
||||||
from freqtrade.plugins.protections import IProtection, ProtectionReturn
|
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}.")
|
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
|
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)
|
self.log_once(f"Cooldown for {pair} for {self.stop_duration_str}.", logger.info)
|
||||||
until = self.calculate_lock_end([trade], self._stop_duration)
|
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
|
Stops trading (position entering) for all pairs
|
||||||
This must evaluate to true for the whole period of the "cooldown period".
|
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>
|
If true, all pairs will be locked with <reason> until <until>
|
||||||
"""
|
"""
|
||||||
# Not implemented for cooldown period.
|
# 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
|
Stops trading (position entering) for this pair
|
||||||
This must evaluate to true for the whole period of the "cooldown period".
|
This must evaluate to true for the whole period of the "cooldown period".
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
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.exchange import timeframe_to_minutes
|
||||||
from freqtrade.misc import plural
|
from freqtrade.misc import plural
|
||||||
from freqtrade.mixins import LoggingMixin
|
from freqtrade.mixins import LoggingMixin
|
||||||
@ -12,7 +14,13 @@ from freqtrade.persistence import LocalTrade
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
class IProtection(LoggingMixin, ABC):
|
||||||
@ -80,14 +88,15 @@ class IProtection(LoggingMixin, ABC):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@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
|
Stops trading (position entering) for all pairs
|
||||||
This must evaluate to true for the whole period of the "cooldown period".
|
This must evaluate to true for the whole period of the "cooldown period".
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@abstractmethod
|
@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
|
Stops trading (position entering) for this pair
|
||||||
This must evaluate to true for the whole period of the "cooldown period".
|
This must evaluate to true for the whole period of the "cooldown period".
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
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.persistence import Trade
|
||||||
from freqtrade.plugins.protections import IProtection, ProtectionReturn
|
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}, '
|
return (f'{profit} < {self._required_profit} in {self.lookback_period_str}, '
|
||||||
f'locking for {self.stop_duration_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
|
Evaluate recent trades for pair
|
||||||
"""
|
"""
|
||||||
@ -51,7 +52,7 @@ class LowProfitPairs(IProtection):
|
|||||||
# trades = Trade.get_trades(filters).all()
|
# trades = Trade.get_trades(filters).all()
|
||||||
if len(trades) < self._trade_limit:
|
if len(trades) < self._trade_limit:
|
||||||
# Not enough trades in the relevant period
|
# 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)
|
profit = sum(trade.close_profit for trade in trades if trade.close_profit)
|
||||||
if profit < self._required_profit:
|
if profit < self._required_profit:
|
||||||
@ -60,20 +61,25 @@ class LowProfitPairs(IProtection):
|
|||||||
f"within {self._lookback_period} minutes.", logger.info)
|
f"within {self._lookback_period} minutes.", logger.info)
|
||||||
until = self.calculate_lock_end(trades, self._stop_duration)
|
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
|
Stops trading (position entering) for all pairs
|
||||||
This must evaluate to true for the whole period of the "cooldown period".
|
This must evaluate to true for the whole period of the "cooldown period".
|
||||||
:return: Tuple of [bool, until, reason].
|
:return: Tuple of [bool, until, reason].
|
||||||
If true, all pairs will be locked with <reason> until <until>
|
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
|
Stops trading (position entering) for this pair
|
||||||
This must evaluate to true for the whole period of the "cooldown period".
|
This must evaluate to true for the whole period of the "cooldown period".
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any, Dict
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
|
|
||||||
|
from freqtrade.constants import LongShort
|
||||||
from freqtrade.data.metrics import calculate_max_drawdown
|
from freqtrade.data.metrics import calculate_max_drawdown
|
||||||
from freqtrade.persistence import Trade
|
from freqtrade.persistence import Trade
|
||||||
from freqtrade.plugins.protections import IProtection, ProtectionReturn
|
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}, '
|
return (f'{drawdown} passed {self._max_allowed_drawdown} in {self.lookback_period_str}, '
|
||||||
f'locking for {self.stop_duration_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 ...
|
Evaluate recent trades for drawdown ...
|
||||||
"""
|
"""
|
||||||
@ -51,14 +52,14 @@ class MaxDrawdown(IProtection):
|
|||||||
|
|
||||||
if len(trades) < self._trade_limit:
|
if len(trades) < self._trade_limit:
|
||||||
# Not enough trades in the relevant period
|
# Not enough trades in the relevant period
|
||||||
return False, None, None
|
return None
|
||||||
|
|
||||||
# Drawdown is always positive
|
# Drawdown is always positive
|
||||||
try:
|
try:
|
||||||
# TODO: This should use absolute profit calculation, considering account balance.
|
# TODO: This should use absolute profit calculation, considering account balance.
|
||||||
drawdown, _, _, _, _, _ = calculate_max_drawdown(trades_df, value_col='close_profit')
|
drawdown, _, _, _, _, _ = calculate_max_drawdown(trades_df, value_col='close_profit')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return False, None, None
|
return None
|
||||||
|
|
||||||
if drawdown > self._max_allowed_drawdown:
|
if drawdown > self._max_allowed_drawdown:
|
||||||
self.log_once(
|
self.log_once(
|
||||||
@ -66,11 +67,15 @@ class MaxDrawdown(IProtection):
|
|||||||
f" within {self.lookback_period_str}.", logger.info)
|
f" within {self.lookback_period_str}.", logger.info)
|
||||||
until = self.calculate_lock_end(trades, self._stop_duration)
|
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
|
Stops trading (position entering) for all pairs
|
||||||
This must evaluate to true for the whole period of the "cooldown period".
|
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)
|
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
|
Stops trading (position entering) for this pair
|
||||||
This must evaluate to true for the whole period of the "cooldown period".
|
This must evaluate to true for the whole period of the "cooldown period".
|
||||||
:return: Tuple of [bool, until, reason].
|
:return: Tuple of [bool, until, reason].
|
||||||
If true, this pair will be locked with <reason> until <until>
|
If true, this pair will be locked with <reason> until <until>
|
||||||
"""
|
"""
|
||||||
return False, None, None
|
return None
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, timedelta
|
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.enums import ExitType
|
||||||
from freqtrade.persistence import Trade
|
from freqtrade.persistence import Trade
|
||||||
from freqtrade.plugins.protections import IProtection, ProtectionReturn
|
from freqtrade.plugins.protections import IProtection, ProtectionReturn
|
||||||
@ -21,6 +22,7 @@ class StoplossGuard(IProtection):
|
|||||||
|
|
||||||
self._trade_limit = protection_config.get('trade_limit', 10)
|
self._trade_limit = protection_config.get('trade_limit', 10)
|
||||||
self._disable_global_stop = protection_config.get('only_per_pair', False)
|
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:
|
def short_desc(self) -> str:
|
||||||
"""
|
"""
|
||||||
@ -36,7 +38,8 @@ class StoplossGuard(IProtection):
|
|||||||
return (f'{self._trade_limit} stoplosses in {self._lookback_period} min, '
|
return (f'{self._trade_limit} stoplosses in {self._lookback_period} min, '
|
||||||
f'locking for {self._stop_duration} 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
|
Evaluate recent trades
|
||||||
"""
|
"""
|
||||||
@ -48,15 +51,24 @@ class StoplossGuard(IProtection):
|
|||||||
ExitType.STOPLOSS_ON_EXCHANGE.value)
|
ExitType.STOPLOSS_ON_EXCHANGE.value)
|
||||||
and trade.close_profit and trade.close_profit < 0)]
|
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:
|
if len(trades) < self._trade_limit:
|
||||||
return False, None, None
|
return None
|
||||||
|
|
||||||
self.log_once(f"Trading stopped due to {self._trade_limit} "
|
self.log_once(f"Trading stopped due to {self._trade_limit} "
|
||||||
f"stoplosses within {self._lookback_period} minutes.", logger.info)
|
f"stoplosses within {self._lookback_period} minutes.", logger.info)
|
||||||
until = self.calculate_lock_end(trades, self._stop_duration)
|
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
|
Stops trading (position entering) for all pairs
|
||||||
This must evaluate to true for the whole period of the "cooldown period".
|
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 true, all pairs will be locked with <reason> until <until>
|
||||||
"""
|
"""
|
||||||
if self._disable_global_stop:
|
if self._disable_global_stop:
|
||||||
return False, None, None
|
return None
|
||||||
return self._stoploss_guard(date_now, 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
|
Stops trading (position entering) for this pair
|
||||||
This must evaluate to true for the whole period of the "cooldown period".
|
This must evaluate to true for the whole period of the "cooldown period".
|
||||||
:return: Tuple of [bool, until, reason].
|
:return: Tuple of [bool, until, reason].
|
||||||
If true, this pair will be locked with <reason> until <until>
|
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_time: str
|
||||||
lock_timestamp: int
|
lock_timestamp: int
|
||||||
pair: str
|
pair: str
|
||||||
|
side: str
|
||||||
reason: str
|
reason: str
|
||||||
|
|
||||||
|
|
||||||
|
@ -545,7 +545,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
|||||||
"""
|
"""
|
||||||
return self.__class__.__name__
|
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.
|
Locks pair until a given timestamp happens.
|
||||||
Locked pairs are not analyzed, and are prevented from opening new trades.
|
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.
|
:param until: datetime in UTC until the pair should be blocked from opening new trades.
|
||||||
Needs to be timezone aware `datetime.now(timezone.utc)`
|
Needs to be timezone aware `datetime.now(timezone.utc)`
|
||||||
:param reason: Optional string explaining why the pair was locked.
|
: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:
|
def unlock_pair(self, pair: str) -> None:
|
||||||
"""
|
"""
|
||||||
@ -576,7 +577,7 @@ class IStrategy(ABC, HyperStrategyMixin):
|
|||||||
"""
|
"""
|
||||||
PairLocks.unlock_reason(reason, datetime.now(timezone.utc))
|
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
|
Checks if a pair is currently locked
|
||||||
The 2nd, optional parameter ensures that locks are applied until the new candle arrives,
|
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.
|
of 2 seconds for an entry order to happen on an old signal.
|
||||||
:param pair: "Pair to check"
|
:param pair: "Pair to check"
|
||||||
:param candle_date: Date of the last candle. Optional, defaults to current date
|
: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.
|
:returns: locking state of the pair in question.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not candle_date:
|
if not candle_date:
|
||||||
# Simple call ...
|
# Simple call ...
|
||||||
return PairLocks.is_pair_locked(pair)
|
return PairLocks.is_pair_locked(pair, side=side)
|
||||||
else:
|
else:
|
||||||
lock_time = timeframe_to_next_date(self.timeframe, candle_date)
|
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:
|
def analyze_ticker(self, dataframe: DataFrame, metadata: dict) -> DataFrame:
|
||||||
"""
|
"""
|
||||||
|
@ -21,8 +21,22 @@ def test_PairLocks(use_db):
|
|||||||
pair = 'ETH/BTC'
|
pair = 'ETH/BTC'
|
||||||
assert not PairLocks.is_pair_locked(pair)
|
assert not PairLocks.is_pair_locked(pair)
|
||||||
PairLocks.lock_pair(pair, arrow.utcnow().shift(minutes=4).datetime)
|
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)
|
||||||
|
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
|
# XRP/BTC should not be locked now
|
||||||
pair = 'XRP/BTC'
|
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,
|
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,
|
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()
|
open_rate = random.random()
|
||||||
|
|
||||||
@ -28,11 +29,12 @@ def generate_mock_trade(pair: str, fee: float, is_open: bool,
|
|||||||
is_open=is_open,
|
is_open=is_open,
|
||||||
amount=0.01 / open_rate,
|
amount=0.01 / open_rate,
|
||||||
exchange='binance',
|
exchange='binance',
|
||||||
|
is_short=is_short,
|
||||||
)
|
)
|
||||||
trade.recalc_open_trade_value()
|
trade.recalc_open_trade_value()
|
||||||
if not is_open:
|
if not is_open:
|
||||||
trade.close(open_rate * profit_rate)
|
trade.close(open_rate * (2 - profit_rate if is_short else profit_rate))
|
||||||
trade.exit_reason = sell_reason
|
trade.exit_reason = exit_reason
|
||||||
|
|
||||||
return trade
|
return trade
|
||||||
|
|
||||||
@ -45,9 +47,9 @@ def test_protectionmanager(mocker, default_conf):
|
|||||||
for handler in freqtrade.protections._protection_handlers:
|
for handler in freqtrade.protections._protection_handlers:
|
||||||
assert handler.name in constants.AVAILABLE_PROTECTIONS
|
assert handler.name in constants.AVAILABLE_PROTECTIONS
|
||||||
if not handler.has_global_stop:
|
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:
|
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', [
|
@pytest.mark.parametrize('timeframe,expected,protconf', [
|
||||||
@ -68,7 +70,7 @@ def test_protectionmanager(mocker, default_conf):
|
|||||||
('1h', [60, 540],
|
('1h', [60, 540],
|
||||||
[{"method": "StoplossGuard", "lookback_period_candles": 1, "stop_duration_candles": 9}]),
|
[{"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
|
default_conf['timeframe'] = timeframe
|
||||||
man = ProtectionManager(default_conf, protconf)
|
man = ProtectionManager(default_conf, protconf)
|
||||||
assert len(man._protection_handlers) == len(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]
|
assert man._protection_handlers[0]._stop_duration == expected[1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('is_short', [False, True])
|
||||||
@pytest.mark.usefixtures("init_persistence")
|
@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'] = [{
|
default_conf['protections'] = [{
|
||||||
"method": "StoplossGuard",
|
"method": "StoplossGuard",
|
||||||
"lookback_period": 60,
|
"lookback_period": 60,
|
||||||
@ -91,8 +95,8 @@ def test_stoploss_guard(mocker, default_conf, fee, caplog):
|
|||||||
caplog.clear()
|
caplog.clear()
|
||||||
|
|
||||||
Trade.query.session.add(generate_mock_trade(
|
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,
|
min_ago_open=200, min_ago_close=30, is_short=is_short,
|
||||||
))
|
))
|
||||||
|
|
||||||
assert not freqtrade.protections.global_stop()
|
assert not freqtrade.protections.global_stop()
|
||||||
@ -100,13 +104,13 @@ def test_stoploss_guard(mocker, default_conf, fee, caplog):
|
|||||||
caplog.clear()
|
caplog.clear()
|
||||||
# This trade does not count, as it's closed too long ago
|
# This trade does not count, as it's closed too long ago
|
||||||
Trade.query.session.add(generate_mock_trade(
|
Trade.query.session.add(generate_mock_trade(
|
||||||
'BCH/BTC', fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value,
|
'BCH/BTC', fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value,
|
||||||
min_ago_open=250, min_ago_close=100,
|
min_ago_open=250, min_ago_close=100, is_short=is_short,
|
||||||
))
|
))
|
||||||
|
|
||||||
Trade.query.session.add(generate_mock_trade(
|
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=240, min_ago_close=30,
|
min_ago_open=240, min_ago_close=30, is_short=is_short,
|
||||||
))
|
))
|
||||||
# 3 Trades closed - but the 2nd has been closed too long ago.
|
# 3 Trades closed - but the 2nd has been closed too long ago.
|
||||||
assert not freqtrade.protections.global_stop()
|
assert not freqtrade.protections.global_stop()
|
||||||
@ -114,8 +118,8 @@ def test_stoploss_guard(mocker, default_conf, fee, caplog):
|
|||||||
caplog.clear()
|
caplog.clear()
|
||||||
|
|
||||||
Trade.query.session.add(generate_mock_trade(
|
Trade.query.session.add(generate_mock_trade(
|
||||||
'LTC/BTC', fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value,
|
'LTC/BTC', fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value,
|
||||||
min_ago_open=180, min_ago_close=30,
|
min_ago_open=180, min_ago_close=30, is_short=is_short,
|
||||||
))
|
))
|
||||||
|
|
||||||
assert freqtrade.protections.global_stop()
|
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_pair', [False, True])
|
||||||
|
@pytest.mark.parametrize('only_per_side', [False, True])
|
||||||
@pytest.mark.usefixtures("init_persistence")
|
@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'] = [{
|
default_conf['protections'] = [{
|
||||||
"method": "StoplossGuard",
|
"method": "StoplossGuard",
|
||||||
"lookback_period": 60,
|
"lookback_period": 60,
|
||||||
"trade_limit": 2,
|
"trade_limit": 2,
|
||||||
"stop_duration": 60,
|
"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)
|
freqtrade = get_patched_freqtradebot(mocker, default_conf)
|
||||||
message = r"Trading stopped due to .*"
|
message = r"Trading stopped due to .*"
|
||||||
pair = 'XRP/BTC'
|
pair = 'XRP/BTC'
|
||||||
@ -148,8 +156,8 @@ def test_stoploss_guard_perpair(mocker, default_conf, fee, caplog, only_per_pair
|
|||||||
caplog.clear()
|
caplog.clear()
|
||||||
|
|
||||||
Trade.query.session.add(generate_mock_trade(
|
Trade.query.session.add(generate_mock_trade(
|
||||||
pair, fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value,
|
pair, fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value,
|
||||||
min_ago_open=200, min_ago_close=30, profit_rate=0.9,
|
min_ago_open=200, min_ago_close=30, profit_rate=0.9, is_short=is_short
|
||||||
))
|
))
|
||||||
|
|
||||||
assert not freqtrade.protections.stop_per_pair(pair)
|
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()
|
caplog.clear()
|
||||||
# This trade does not count, as it's closed too long ago
|
# This trade does not count, as it's closed too long ago
|
||||||
Trade.query.session.add(generate_mock_trade(
|
Trade.query.session.add(generate_mock_trade(
|
||||||
pair, fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value,
|
pair, fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value,
|
||||||
min_ago_open=250, min_ago_close=100, profit_rate=0.9,
|
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 does not count for per pair stop as it's the wrong pair.
|
||||||
Trade.query.session.add(generate_mock_trade(
|
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=240, min_ago_close=30, profit_rate=0.9,
|
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.
|
# 3 Trades closed - but the 2nd has been closed too long ago.
|
||||||
assert not freqtrade.protections.stop_per_pair(pair)
|
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()
|
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
|
# 2nd Trade that counts with correct pair
|
||||||
Trade.query.session.add(generate_mock_trade(
|
Trade.query.session.add(generate_mock_trade(
|
||||||
pair, fee.return_value, False, sell_reason=ExitType.STOP_LOSS.value,
|
pair, fee.return_value, False, exit_reason=ExitType.STOP_LOSS.value,
|
||||||
min_ago_open=180, min_ago_close=30, profit_rate=0.9,
|
min_ago_open=180, min_ago_close=30, profit_rate=0.9, is_short=is_short
|
||||||
))
|
))
|
||||||
|
|
||||||
freqtrade.protections.stop_per_pair(pair)
|
freqtrade.protections.stop_per_pair(pair)
|
||||||
assert freqtrade.protections.global_stop() != only_per_pair
|
assert freqtrade.protections.global_stop() != only_per_pair
|
||||||
assert PairLocks.is_pair_locked(pair)
|
assert PairLocks.is_pair_locked(pair, side=check_side)
|
||||||
assert PairLocks.is_global_lock() != 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='*')
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.usefixtures("init_persistence")
|
@pytest.mark.usefixtures("init_persistence")
|
||||||
@ -203,7 +229,7 @@ def test_CooldownPeriod(mocker, default_conf, fee, caplog):
|
|||||||
caplog.clear()
|
caplog.clear()
|
||||||
|
|
||||||
Trade.query.session.add(generate_mock_trade(
|
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,
|
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()
|
assert not PairLocks.is_global_lock()
|
||||||
|
|
||||||
Trade.query.session.add(generate_mock_trade(
|
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,
|
min_ago_open=205, min_ago_close=35,
|
||||||
))
|
))
|
||||||
|
|
||||||
@ -242,7 +268,7 @@ def test_LowProfitPairs(mocker, default_conf, fee, caplog):
|
|||||||
caplog.clear()
|
caplog.clear()
|
||||||
|
|
||||||
Trade.query.session.add(generate_mock_trade(
|
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,
|
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()
|
assert not PairLocks.is_global_lock()
|
||||||
|
|
||||||
Trade.query.session.add(generate_mock_trade(
|
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,
|
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
|
# Add positive trade
|
||||||
Trade.query.session.add(generate_mock_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,
|
min_ago_open=20, min_ago_close=10, profit_rate=1.15,
|
||||||
))
|
))
|
||||||
assert not freqtrade.protections.stop_per_pair('XRP/BTC')
|
assert not freqtrade.protections.stop_per_pair('XRP/BTC')
|
||||||
assert not PairLocks.is_pair_locked('XRP/BTC')
|
assert not PairLocks.is_pair_locked('XRP/BTC')
|
||||||
|
|
||||||
Trade.query.session.add(generate_mock_trade(
|
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,
|
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()
|
caplog.clear()
|
||||||
|
|
||||||
Trade.query.session.add(generate_mock_trade(
|
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,
|
min_ago_open=1000, min_ago_close=900, profit_rate=1.1,
|
||||||
))
|
))
|
||||||
Trade.query.session.add(generate_mock_trade(
|
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,
|
min_ago_open=1000, min_ago_close=900, profit_rate=1.1,
|
||||||
))
|
))
|
||||||
Trade.query.session.add(generate_mock_trade(
|
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,
|
min_ago_open=1000, min_ago_close=900, profit_rate=1.1,
|
||||||
))
|
))
|
||||||
# No losing trade yet ... so max_drawdown will raise exception
|
# 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')
|
assert not freqtrade.protections.stop_per_pair('XRP/BTC')
|
||||||
|
|
||||||
Trade.query.session.add(generate_mock_trade(
|
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,
|
min_ago_open=500, min_ago_close=400, profit_rate=0.9,
|
||||||
))
|
))
|
||||||
# Not locked with one trade
|
# Not locked with one trade
|
||||||
@ -326,7 +352,7 @@ def test_MaxDrawdown(mocker, default_conf, fee, caplog):
|
|||||||
assert not PairLocks.is_global_lock()
|
assert not PairLocks.is_global_lock()
|
||||||
|
|
||||||
Trade.query.session.add(generate_mock_trade(
|
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,
|
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!)
|
# Winning trade ... (should not lock, does not change drawdown!)
|
||||||
Trade.query.session.add(generate_mock_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=320, min_ago_close=410, profit_rate=1.5,
|
min_ago_open=320, min_ago_close=410, profit_rate=1.5,
|
||||||
))
|
))
|
||||||
assert not freqtrade.protections.global_stop()
|
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%
|
# Add additional negative trade, causing a loss of > 15%
|
||||||
Trade.query.session.add(generate_mock_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=0.8,
|
min_ago_open=20, min_ago_close=10, profit_rate=0.8,
|
||||||
))
|
))
|
||||||
assert not freqtrade.protections.stop_per_pair('XRP/BTC')
|
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)
|
assert not strategy.is_pair_locked(pair)
|
||||||
# latest candle is from 14:20, lock goes to 14:30
|
# 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, candle_date=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=-50))
|
||||||
|
|
||||||
# latest candle is from 14:25 (lock should be lifted)
|
# latest candle is from 14:25 (lock should be lifted)
|
||||||
# Since this is the "new candle" available at 14:30
|
# 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
|
# 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
|
# Change timeframe to 15m
|
||||||
strategy.timeframe = '15m'
|
strategy.timeframe = '15m'
|
||||||
# Candle from 14:14 - lock goes until 14:30
|
# 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, candle_date=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=-15, seconds=-2))
|
||||||
# Candle from 14:15 - lock goes until 14:30
|
# 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):
|
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.freqtradebot import FreqtradeBot
|
||||||
from freqtrade.persistence import Order, PairLocks, Trade
|
from freqtrade.persistence import Order, PairLocks, Trade
|
||||||
from freqtrade.persistence.models import PairLock
|
from freqtrade.persistence.models import PairLock
|
||||||
|
from freqtrade.plugins.protections.iprotection import ProtectionReturn
|
||||||
from freqtrade.worker import Worker
|
from freqtrade.worker import Worker
|
||||||
from tests.conftest import (create_mock_trades, get_patched_freqtradebot, get_patched_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,
|
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)
|
assert not log_has_re(message, caplog)
|
||||||
caplog.clear()
|
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()
|
n = freqtrade.enter_positions()
|
||||||
assert n == 0
|
assert n == 0
|
||||||
assert log_has_re(message, caplog)
|
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 = get_patched_freqtradebot(mocker, default_conf_usdt)
|
||||||
freqtrade.protections._protection_handlers[1].global_stop = MagicMock(
|
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)
|
create_mock_trades(fee, is_short)
|
||||||
freqtrade.handle_protections('ETC/BTC')
|
freqtrade.handle_protections('ETC/BTC', '*')
|
||||||
send_msg_mock = freqtrade.rpc.send_msg
|
send_msg_mock = freqtrade.rpc.send_msg
|
||||||
assert send_msg_mock.call_count == 2
|
assert send_msg_mock.call_count == 2
|
||||||
assert send_msg_mock.call_args_list[0][0][0]['type'] == RPCMessageType.PROTECTION_TRIGGER
|
assert send_msg_mock.call_args_list[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)
|
exit_check=ExitCheckTuple(exit_type=ExitType.STOP_LOSS)
|
||||||
)
|
)
|
||||||
trade.close(ticker_usdt_sell_down()['bid'])
|
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.
|
# reinit - should buy other pair.
|
||||||
caplog.clear()
|
caplog.clear()
|
||||||
freqtrade.enter_positions()
|
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])
|
@pytest.mark.parametrize("is_short", [False, True])
|
||||||
|
@ -15,6 +15,7 @@ from freqtrade.enums import TradingMode
|
|||||||
from freqtrade.exceptions import DependencyException, OperationalException
|
from freqtrade.exceptions import DependencyException, OperationalException
|
||||||
from freqtrade.persistence import LocalTrade, Order, Trade, clean_dry_run_db, init_db
|
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.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
|
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
|
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):
|
def test_adjust_stop_loss(fee):
|
||||||
trade = Trade(
|
trade = Trade(
|
||||||
pair='ADA/USDT',
|
pair='ADA/USDT',
|
||||||
|
Loading…
Reference in New Issue
Block a user