1133 lines
		
	
	
		
			45 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			1133 lines
		
	
	
		
			45 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| """
 | |
| This module contains the class to persist trades into SQLite
 | |
| """
 | |
| import logging
 | |
| from datetime import datetime, timezone
 | |
| from decimal import Decimal
 | |
| from typing import Any, Dict, List, Optional
 | |
| 
 | |
| from sqlalchemy import (Boolean, Column, DateTime, Float, ForeignKey, Integer, String,
 | |
|                         create_engine, desc, func, inspect)
 | |
| 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
 | |
| from freqtrade.enums import SellType
 | |
| from freqtrade.exceptions import DependencyException, OperationalException
 | |
| from freqtrade.misc import safe_value_fallback
 | |
| from freqtrade.persistence.migrations import check_migrate
 | |
| 
 | |
| 
 | |
| logger = logging.getLogger(__name__)
 | |
| 
 | |
| 
 | |
| _DECL_BASE: Any = declarative_base()
 | |
| _SQL_DOCS_URL = 'http://docs.sqlalchemy.org/en/latest/core/engines.html#database-urls'
 | |
| 
 | |
| 
 | |
| def init_db(db_url: str, clean_open_orders: bool = False) -> None:
 | |
|     """
 | |
|         Initializes this module with the given config,
 | |
|         registers all known command handlers
 | |
|         and starts polling for message updates
 | |
|         :param db_url: Database to use
 | |
|         :param clean_open_orders: Remove open orders from the database.
 | |
|             Useful for dry-run or if all orders have been reset on the exchange.
 | |
|         :return: None
 | |
|     """
 | |
|     kwargs = {}
 | |
| 
 | |
|     if db_url == 'sqlite://':
 | |
|         kwargs.update({
 | |
|             'poolclass': StaticPool,
 | |
|         })
 | |
|     # Take care of thread ownership
 | |
|     if db_url.startswith('sqlite://'):
 | |
|         kwargs.update({
 | |
|             'connect_args': {'check_same_thread': False},
 | |
|         })
 | |
| 
 | |
|     try:
 | |
|         engine = create_engine(db_url, future=True, **kwargs)
 | |
|     except NoSuchModuleError:
 | |
|         raise OperationalException(f"Given value for db_url: '{db_url}' "
 | |
|                                    f"is no valid database URL! (See {_SQL_DOCS_URL})")
 | |
| 
 | |
|     # https://docs.sqlalchemy.org/en/13/orm/contextual.html#thread-local-scope
 | |
|     # Scoped sessions proxy requests to the appropriate thread-local session.
 | |
|     # We should use the scoped_session object - not a seperately initialized version
 | |
|     Trade._session = scoped_session(sessionmaker(bind=engine, autoflush=True))
 | |
|     Trade.query = Trade._session.query_property()
 | |
|     Order.query = Trade._session.query_property()
 | |
|     PairLock.query = Trade._session.query_property()
 | |
| 
 | |
|     previous_tables = inspect(engine).get_table_names()
 | |
|     _DECL_BASE.metadata.create_all(engine)
 | |
|     check_migrate(engine, decl_base=_DECL_BASE, previous_tables=previous_tables)
 | |
| 
 | |
|     # Clean dry_run DB if the db is not in-memory
 | |
|     if clean_open_orders and db_url != 'sqlite://':
 | |
|         clean_dry_run_db()
 | |
| 
 | |
| 
 | |
| def cleanup_db() -> None:
 | |
|     """
 | |
|     Flushes all pending operations to disk.
 | |
|     :return: None
 | |
|     """
 | |
|     Trade.commit()
 | |
| 
 | |
| 
 | |
| def clean_dry_run_db() -> None:
 | |
|     """
 | |
|     Remove open_order_id from a Dry_run DB
 | |
|     :return: None
 | |
|     """
 | |
|     for trade in Trade.query.filter(Trade.open_order_id.isnot(None)).all():
 | |
|         # Check we are updating only a dry_run order not a prod one
 | |
|         if 'dry_run' in trade.open_order_id:
 | |
|             trade.open_order_id = None
 | |
|     Trade.commit()
 | |
| 
 | |
| 
 | |
| class Order(_DECL_BASE):
 | |
|     """
 | |
|     Order database model
 | |
|     Keeps a record of all orders placed on the exchange
 | |
| 
 | |
|     One to many relationship with Trades:
 | |
|       - One trade can have many orders
 | |
|       - One Order can only be associated with one Trade
 | |
| 
 | |
|     Mirrors CCXT Order structure
 | |
|     """
 | |
|     __tablename__ = 'orders'
 | |
|     # Uniqueness should be ensured over pair, order_id
 | |
|     # its likely that order_id is unique per Pair on some exchanges.
 | |
|     __table_args__ = (UniqueConstraint('ft_pair', 'order_id', name="_order_pair_order_id"),)
 | |
| 
 | |
|     id = Column(Integer, primary_key=True)
 | |
|     ft_trade_id = Column(Integer, ForeignKey('trades.id'), index=True)
 | |
| 
 | |
|     trade = relationship("Trade", back_populates="orders")
 | |
| 
 | |
|     ft_order_side = Column(String(25), nullable=False)
 | |
|     ft_pair = Column(String(25), nullable=False)
 | |
|     ft_is_open = Column(Boolean, nullable=False, default=True, index=True)
 | |
| 
 | |
|     order_id = Column(String(255), nullable=False, index=True)
 | |
|     status = Column(String(255), nullable=True)
 | |
|     symbol = Column(String(25), nullable=True)
 | |
|     order_type = Column(String(50), nullable=True)
 | |
|     side = Column(String(25), nullable=True)
 | |
|     price = Column(Float, nullable=True)
 | |
|     average = Column(Float, nullable=True)
 | |
|     amount = Column(Float, nullable=True)
 | |
|     filled = Column(Float, nullable=True)
 | |
|     remaining = Column(Float, nullable=True)
 | |
|     cost = Column(Float, nullable=True)
 | |
|     order_date = Column(DateTime, nullable=True, default=datetime.utcnow)
 | |
|     order_filled_date = Column(DateTime, nullable=True)
 | |
|     order_update_date = Column(DateTime, nullable=True)
 | |
| 
 | |
|     leverage = Column(Float, nullable=True, default=None)
 | |
|     is_short = Column(Boolean, nullable=True, default=False)
 | |
| 
 | |
|     def __repr__(self):
 | |
|         return (f'Order(id={self.id}, order_id={self.order_id}, trade_id={self.ft_trade_id}, '
 | |
|                 f'side={self.side}, order_type={self.order_type}, status={self.status})')
 | |
| 
 | |
|     def update_from_ccxt_object(self, order):
 | |
|         """
 | |
|         Update Order from ccxt response
 | |
|         Only updates if fields are available from ccxt -
 | |
|         """
 | |
|         if self.order_id != str(order['id']):
 | |
|             raise DependencyException("Order-id's don't match")
 | |
| 
 | |
|         self.status = order.get('status', self.status)
 | |
|         self.symbol = order.get('symbol', self.symbol)
 | |
|         self.order_type = order.get('type', self.order_type)
 | |
|         self.side = order.get('side', self.side)
 | |
|         self.price = order.get('price', self.price)
 | |
|         self.amount = order.get('amount', self.amount)
 | |
|         self.filled = order.get('filled', self.filled)
 | |
|         self.average = order.get('average', self.average)
 | |
|         self.remaining = order.get('remaining', self.remaining)
 | |
|         self.cost = order.get('cost', self.cost)
 | |
|         self.leverage = order.get('leverage', self.leverage)
 | |
|         if 'timestamp' in order and order['timestamp'] is not None:
 | |
|             self.order_date = datetime.fromtimestamp(order['timestamp'] / 1000, tz=timezone.utc)
 | |
| 
 | |
|         self.ft_is_open = True
 | |
|         if self.status in ('closed', 'canceled', 'cancelled'):
 | |
|             self.ft_is_open = False
 | |
|             if order.get('filled', 0) > 0:
 | |
|                 self.order_filled_date = datetime.now(timezone.utc)
 | |
|         self.order_update_date = datetime.now(timezone.utc)
 | |
| 
 | |
|     @staticmethod
 | |
|     def update_orders(orders: List['Order'], order: Dict[str, Any]):
 | |
|         """
 | |
|         Get all non-closed orders - useful when trying to batch-update orders
 | |
|         """
 | |
|         if not isinstance(order, dict):
 | |
|             logger.warning(f"{order} is not a valid response object.")
 | |
|             return
 | |
| 
 | |
|         filtered_orders = [o for o in orders if o.order_id == order.get('id')]
 | |
|         if filtered_orders:
 | |
|             oobj = filtered_orders[0]
 | |
|             oobj.update_from_ccxt_object(order)
 | |
|             Order.query.session.commit()
 | |
|         else:
 | |
|             logger.warning(f"Did not find order for {order}.")
 | |
| 
 | |
|     @staticmethod
 | |
|     def parse_from_ccxt_object(order: Dict[str, Any], pair: str, side: str) -> 'Order':
 | |
|         """
 | |
|         Parse an order from a ccxt object and return a new order Object.
 | |
|         """
 | |
|         o = Order(order_id=str(order['id']), ft_order_side=side, ft_pair=pair)
 | |
| 
 | |
|         o.update_from_ccxt_object(order)
 | |
|         return o
 | |
| 
 | |
|     @staticmethod
 | |
|     def get_open_orders() -> List['Order']:
 | |
|         """
 | |
|         """
 | |
|         return Order.query.filter(Order.ft_is_open.is_(True)).all()
 | |
| 
 | |
| 
 | |
| class LocalTrade():
 | |
|     """
 | |
|     Trade database model.
 | |
|     Used in backtesting - must be aligned to Trade model!
 | |
| 
 | |
|     """
 | |
|     use_db: bool = False
 | |
|     # Trades container for backtesting
 | |
|     trades: List['LocalTrade'] = []
 | |
|     trades_open: List['LocalTrade'] = []
 | |
|     total_profit: float = 0
 | |
| 
 | |
|     id: int = 0
 | |
| 
 | |
|     orders: List[Order] = []
 | |
| 
 | |
|     exchange: str = ''
 | |
|     pair: str = ''
 | |
|     is_open: bool = True
 | |
|     fee_open: float = 0.0
 | |
|     fee_open_cost: Optional[float] = None
 | |
|     fee_open_currency: str = ''
 | |
|     fee_close: float = 0.0
 | |
|     fee_close_cost: Optional[float] = None
 | |
|     fee_close_currency: str = ''
 | |
|     open_rate: float = 0.0
 | |
|     open_rate_requested: Optional[float] = None
 | |
| 
 | |
|     # open_trade_value - calculated via _calc_open_trade_value
 | |
|     open_trade_value: float = 0.0
 | |
|     close_rate: Optional[float] = None
 | |
|     close_rate_requested: Optional[float] = None
 | |
|     close_profit: Optional[float] = None
 | |
|     close_profit_abs: Optional[float] = None
 | |
|     stake_amount: float = 0.0
 | |
|     _amount: float = 0.0
 | |
|     amount_requested: Optional[float] = None
 | |
|     open_date: datetime
 | |
|     close_date: Optional[datetime] = None
 | |
|     open_order_id: Optional[str] = None
 | |
|     # absolute value of the stop loss
 | |
|     stop_loss: float = 0.0
 | |
|     # percentage value of the stop loss
 | |
|     stop_loss_pct: float = 0.0
 | |
|     # absolute value of the initial stop loss
 | |
|     initial_stop_loss: float = 0.0
 | |
|     # percentage value of the initial stop loss
 | |
|     initial_stop_loss_pct: float = 0.0
 | |
|     # stoploss order id which is on exchange
 | |
|     stoploss_order_id: Optional[str] = None
 | |
|     # last update time of the stoploss order on exchange
 | |
|     stoploss_last_update: Optional[datetime] = None
 | |
|     # absolute value of the highest reached price
 | |
|     max_rate: float = 0.0
 | |
|     # Lowest price reached
 | |
|     min_rate: float = 0.0
 | |
|     sell_reason: str = ''
 | |
|     sell_order_status: str = ''
 | |
|     strategy: str = ''
 | |
|     timeframe: Optional[int] = None
 | |
| 
 | |
|     # Margin trading properties
 | |
|     borrowed_currency: str = None
 | |
|     collateral_currency: str = None
 | |
|     interest_rate: float = 0.0
 | |
|     liquidation_price: float = None
 | |
|     is_short: bool = False
 | |
|     borrowed: float = 0.0
 | |
|     _leverage: float = None  # * You probably want to use LocalTrade.leverage instead
 | |
| 
 | |
|     # @property
 | |
|     # def base_currency(self) -> str:
 | |
|     #     if not self.pair:
 | |
|     #         raise OperationalException('LocalTrade.pair must be assigned')
 | |
|     #     return self.pair.split("/")[1]
 | |
| 
 | |
|     @property
 | |
|     def amount(self) -> float:
 | |
|         if self.leverage is not None:
 | |
|             return self._amount * self.leverage
 | |
|         else:
 | |
|             return self._amount
 | |
| 
 | |
|     @amount.setter
 | |
|     def amount(self, value):
 | |
|         self._amount = value
 | |
| 
 | |
|     @property
 | |
|     def leverage(self) -> float:
 | |
|         return self._leverage
 | |
| 
 | |
|     @leverage.setter
 | |
|     def leverage(self, value):
 | |
|         # def set_leverage(self, lev: float, is_short: Optional[bool], amount: Optional[float]):
 | |
|         # TODO: Should this be @leverage.setter, or should it take arguments is_short and amount
 | |
|         # if is_short is None:
 | |
|         #     is_short = self.is_short
 | |
|         # if amount is None:
 | |
|         #     amount = self.amount
 | |
|         if self.is_short is None or self.amount is None:
 | |
|             raise OperationalException(
 | |
|                 'LocalTrade.amount and LocalTrade.is_short must be assigned before assigning leverage')
 | |
| 
 | |
|         self._leverage = value
 | |
|         if self.is_short:
 | |
|             # If shorting the full amount must be borrowed
 | |
|             self.borrowed = self.amount * value
 | |
|         else:
 | |
|             # If not shorting, then the trader already owns a bit
 | |
|             self.borrowed = self.amount * (value-1)
 | |
|         # TODO: Maybe amount should be a computed property, so we don't have to modify it
 | |
|         self.amount = self.amount * value
 | |
| 
 | |
|     # End of margin trading properties
 | |
| 
 | |
|     @property
 | |
|     def open_date_utc(self):
 | |
|         return self.open_date.replace(tzinfo=timezone.utc)
 | |
| 
 | |
|     @property
 | |
|     def close_date_utc(self):
 | |
|         return self.close_date.replace(tzinfo=timezone.utc)
 | |
| 
 | |
|     def __init__(self, **kwargs):
 | |
|         if kwargs.get('leverage') and kwargs.get('borrowed'):
 | |
|             # TODO-mg: should I raise an error?
 | |
|             raise OperationalException('Cannot pass both borrowed and leverage to Trade')
 | |
|         for key in kwargs:
 | |
|             setattr(self, key, kwargs[key])
 | |
|         if not self.is_short:
 | |
|             self.is_short = False
 | |
|         self.recalc_open_trade_value()
 | |
| 
 | |
|     def __repr__(self):
 | |
|         open_since = self.open_date.strftime(DATETIME_PRINT_FORMAT) if self.is_open else 'closed'
 | |
| 
 | |
|         return (f'Trade(id={self.id}, pair={self.pair}, amount={self.amount:.8f}, '
 | |
|                 f'open_rate={self.open_rate:.8f}, open_since={open_since})')
 | |
| 
 | |
|     def to_json(self) -> Dict[str, Any]:
 | |
|         return {
 | |
|             'trade_id': self.id,
 | |
|             'pair': self.pair,
 | |
|             'is_open': self.is_open,
 | |
|             'exchange': self.exchange,
 | |
|             'amount': round(self.amount, 8),
 | |
|             'amount_requested': round(self.amount_requested, 8) if self.amount_requested else None,
 | |
|             'stake_amount': round(self.stake_amount, 8),
 | |
|             'strategy': self.strategy,
 | |
|             'timeframe': self.timeframe,
 | |
| 
 | |
|             'fee_open': self.fee_open,
 | |
|             'fee_open_cost': self.fee_open_cost,
 | |
|             'fee_open_currency': self.fee_open_currency,
 | |
|             'fee_close': self.fee_close,
 | |
|             'fee_close_cost': self.fee_close_cost,
 | |
|             'fee_close_currency': self.fee_close_currency,
 | |
| 
 | |
|             'open_date': self.open_date.strftime(DATETIME_PRINT_FORMAT),
 | |
|             'open_timestamp': int(self.open_date.replace(tzinfo=timezone.utc).timestamp() * 1000),
 | |
|             'open_rate': self.open_rate,
 | |
|             'open_rate_requested': self.open_rate_requested,
 | |
|             'open_trade_value': round(self.open_trade_value, 8),
 | |
| 
 | |
|             'close_date': (self.close_date.strftime(DATETIME_PRINT_FORMAT)
 | |
|                            if self.close_date else None),
 | |
|             'close_timestamp': int(self.close_date.replace(
 | |
|                 tzinfo=timezone.utc).timestamp() * 1000) if self.close_date else None,
 | |
|             'close_rate': self.close_rate,
 | |
|             'close_rate_requested': self.close_rate_requested,
 | |
|             'close_profit': self.close_profit,  # Deprecated
 | |
|             'close_profit_pct': round(self.close_profit * 100, 2) if self.close_profit else None,
 | |
|             'close_profit_abs': self.close_profit_abs,  # Deprecated
 | |
| 
 | |
|             'trade_duration_s': (int((self.close_date_utc - self.open_date_utc).total_seconds())
 | |
|                                  if self.close_date else None),
 | |
|             'trade_duration': (int((self.close_date_utc - self.open_date_utc).total_seconds() // 60)
 | |
|                                if self.close_date else None),
 | |
| 
 | |
|             'profit_ratio': self.close_profit,
 | |
|             'profit_pct': round(self.close_profit * 100, 2) if self.close_profit else None,
 | |
|             'profit_abs': self.close_profit_abs,
 | |
| 
 | |
|             'sell_reason': self.sell_reason,
 | |
|             'sell_order_status': self.sell_order_status,
 | |
|             'stop_loss_abs': self.stop_loss,
 | |
|             'stop_loss_ratio': self.stop_loss_pct if self.stop_loss_pct else None,
 | |
|             'stop_loss_pct': (self.stop_loss_pct * 100) if self.stop_loss_pct else None,
 | |
|             'stoploss_order_id': self.stoploss_order_id,
 | |
|             'stoploss_last_update': (self.stoploss_last_update.strftime(DATETIME_PRINT_FORMAT)
 | |
|                                      if self.stoploss_last_update else None),
 | |
|             'stoploss_last_update_timestamp': int(self.stoploss_last_update.replace(
 | |
|                 tzinfo=timezone.utc).timestamp() * 1000) if self.stoploss_last_update else None,
 | |
|             'initial_stop_loss_abs': self.initial_stop_loss,
 | |
|             'initial_stop_loss_ratio': (self.initial_stop_loss_pct
 | |
|                                         if self.initial_stop_loss_pct else None),
 | |
|             'initial_stop_loss_pct': (self.initial_stop_loss_pct * 100
 | |
|                                       if self.initial_stop_loss_pct else None),
 | |
|             'min_rate': self.min_rate,
 | |
|             'max_rate': self.max_rate,
 | |
| 
 | |
|             'leverage': self.leverage,
 | |
|             'borrowed': self.borrowed,
 | |
|             'borrowed_currency': self.borrowed_currency,
 | |
|             'collateral_currency': self.collateral_currency,
 | |
|             'interest_rate': self.interest_rate,
 | |
|             'liquidation_price': self.liquidation_price,
 | |
|             'is_short': self.is_short,
 | |
| 
 | |
|             'open_order_id': self.open_order_id,
 | |
|         }
 | |
| 
 | |
|     @staticmethod
 | |
|     def reset_trades() -> None:
 | |
|         """
 | |
|         Resets all trades. Only active for backtesting mode.
 | |
|         """
 | |
|         LocalTrade.trades = []
 | |
|         LocalTrade.trades_open = []
 | |
|         LocalTrade.total_profit = 0
 | |
| 
 | |
|     def adjust_min_max_rates(self, current_price: float) -> None:
 | |
|         """
 | |
|         Adjust the max_rate and min_rate.
 | |
|         """
 | |
|         self.max_rate = max(current_price, self.max_rate or self.open_rate)
 | |
|         self.min_rate = min(current_price, self.min_rate or self.open_rate)
 | |
| 
 | |
|     def _set_new_stoploss(self, new_loss: float, stoploss: float):
 | |
|         """Assign new stop value"""
 | |
|         self.stop_loss = new_loss
 | |
|         if self.is_short:
 | |
|             self.stop_loss_pct = abs(stoploss)
 | |
|         else:
 | |
|             self.stop_loss_pct = -1 * abs(stoploss)
 | |
|         self.stoploss_last_update = datetime.utcnow()
 | |
| 
 | |
|     def adjust_stop_loss(self, current_price: float, stoploss: float,
 | |
|                          initial: bool = False) -> None:
 | |
|         """
 | |
|         This adjusts the stop loss to it's most recently observed setting
 | |
|         :param current_price: Current rate the asset is traded
 | |
|         :param stoploss: Stoploss as factor (sample -0.05 -> -5% below current price).
 | |
|         :param initial: Called to initiate stop_loss.
 | |
|             Skips everything if self.stop_loss is already set.
 | |
|         """
 | |
|         if initial and not (self.stop_loss is None or self.stop_loss == 0):
 | |
|             # Don't modify if called with initial and nothing to do
 | |
|             return
 | |
| 
 | |
|         if self.is_short:
 | |
|             new_loss = float(current_price * (1 + abs(stoploss)))
 | |
|             if self.liquidation_price:  # If trading on margin, don't set the stoploss below the liquidation price
 | |
|                 new_loss = min(self.liquidation_price, new_loss)
 | |
|         else:
 | |
|             new_loss = float(current_price * (1 - abs(stoploss)))
 | |
|             if self.liquidation_price:  # If trading on margin, don't set the stoploss below the liquidation price
 | |
|                 new_loss = max(self.liquidation_price, new_loss)
 | |
| 
 | |
|         # no stop loss assigned yet
 | |
|         if not self.stop_loss:
 | |
|             logger.debug(f"{self.pair} - Assigning new stoploss...")
 | |
|             self._set_new_stoploss(new_loss, stoploss)
 | |
|             self.initial_stop_loss = new_loss
 | |
|             if self.is_short:
 | |
|                 self.initial_stop_loss_pct = abs(stoploss)
 | |
|             else:
 | |
|                 self.initial_stop_loss_pct = -1 * abs(stoploss)
 | |
| 
 | |
|         # evaluate if the stop loss needs to be updated
 | |
|         else:
 | |
|             # stop losses only walk up, never down!, #TODO: But adding more to a margin account would create a lower liquidation price, decreasing the minimum stoploss
 | |
|             if (new_loss > self.stop_loss and not self.is_short) or (new_loss < self.stop_loss and self.is_short):
 | |
|                 logger.debug(f"{self.pair} - Adjusting stoploss...")
 | |
|                 self._set_new_stoploss(new_loss, stoploss)
 | |
|             else:
 | |
|                 logger.debug(f"{self.pair} - Keeping current stoploss...")
 | |
| 
 | |
|         logger.debug(
 | |
|             f"{self.pair} - Stoploss adjusted. current_price={current_price:.8f}, "
 | |
|             f"open_rate={self.open_rate:.8f}, max_rate={self.max_rate:.8f}, "
 | |
|             f"initial_stop_loss={self.initial_stop_loss:.8f}, "
 | |
|             f"stop_loss={self.stop_loss:.8f}. "
 | |
|             f"Trailing stoploss saved us: "
 | |
|             f"{float(self.stop_loss) - float(self.initial_stop_loss):.8f}.")
 | |
| 
 | |
|     def is_opening_trade(self, side) -> bool:
 | |
|         """
 | |
|         Determines if the trade is an opening (long buy or short sell) trade
 | |
|         :param side (string): the side (buy/sell) that order happens on
 | |
|         """
 | |
|         return (side == 'buy' and not self.is_short) or (side == 'sell' and self.is_short)
 | |
| 
 | |
|     def is_closing_trade(self, side) -> bool:
 | |
|         """
 | |
|         Determines if the trade is an closing (long sell or short buy) trade
 | |
|         :param side (string): the side (buy/sell) that order happens on
 | |
|         """
 | |
|         return (side == 'sell' and not self.is_short) or (side == 'buy' and self.is_short)
 | |
| 
 | |
|     def update(self, order: Dict) -> None:
 | |
|         """
 | |
|         Updates this entity with amount and actual open/close rates.
 | |
|         :param order: order retrieved by exchange.fetch_order()
 | |
|         :return: None
 | |
|         """
 | |
|         order_type = order['type']
 | |
| 
 | |
|         if ('leverage' in order and 'borrowed' in order):
 | |
|             raise OperationalException(
 | |
|                 'Pass only one of Leverage or Borrowed to the order in update trade')
 | |
| 
 | |
|         if 'is_short' in order and order['side'] == 'sell':
 | |
|             # Only set's is_short on opening trades, ignores non-shorts
 | |
|             # TODO-mg: I don't like this, but it might be the only way
 | |
|             self.is_short = order['is_short']
 | |
| 
 | |
|         # Ignore open and cancelled orders
 | |
|         if order['status'] == 'open' or safe_value_fallback(order, 'average', 'price') is None:
 | |
|             return
 | |
| 
 | |
|         logger.info('Updating trade (id=%s) ...', self.id)
 | |
| 
 | |
|         if order_type in ('market', 'limit') and self.is_opening_trade(order['side']):
 | |
|             # Update open rate and actual amount
 | |
| 
 | |
|             self.open_rate = float(safe_value_fallback(order, 'average', 'price'))
 | |
|             self.amount = float(safe_value_fallback(order, 'filled', 'amount'))
 | |
| 
 | |
|             if 'borrowed' in order:
 | |
|                 self.borrowed = order['borrowed']
 | |
|             elif 'leverage' in order:
 | |
|                 self.leverage = order['leverage']
 | |
| 
 | |
|             self.recalc_open_trade_value()
 | |
|             if self.is_open:
 | |
|                 payment = "SELL" if self.is_short else "BUY"
 | |
|                 logger.info(f'{order_type.upper()}_{payment} has been fulfilled for {self}.')
 | |
|             self.open_order_id = None
 | |
|         elif order_type in ('market', 'limit') and self.is_closing_trade(order['side']):
 | |
|             if self.is_open:
 | |
|                 payment = "BUY" if self.is_short else "SELL"
 | |
|                 # TODO: On Shorts technically your buying a little bit more than the amount because it's the ammount plus the interest
 | |
|                 logger.info(f'{order_type.upper()}_{payment} has been fulfilled for {self}.')
 | |
|             self.close(safe_value_fallback(order, 'average', 'price'))  # TODO: Double check this
 | |
|         elif order_type in ('stop_loss_limit', 'stop-loss', 'stop-loss-limit', 'stop'):
 | |
|             self.stoploss_order_id = None
 | |
|             self.close_rate_requested = self.stop_loss
 | |
|             self.sell_reason = SellType.STOPLOSS_ON_EXCHANGE.value
 | |
|             if self.is_open:
 | |
|                 logger.info(f'{order_type.upper()} is hit for {self}.')
 | |
|             self.close(safe_value_fallback(order, 'average', 'price'))
 | |
|         else:
 | |
|             raise ValueError(f'Unknown order type: {order_type}')
 | |
|         Trade.commit()
 | |
| 
 | |
|     def close(self, rate: float, *, show_msg: bool = True) -> None:
 | |
|         """
 | |
|         Sets close_rate to the given rate, calculates total profit
 | |
|         and marks trade as closed
 | |
|         """
 | |
|         self.close_rate = rate
 | |
|         self.close_date = self.close_date or datetime.utcnow()
 | |
|         self.close_profit = self.calc_profit_ratio()
 | |
|         self.close_profit_abs = self.calc_profit()
 | |
|         self.is_open = False
 | |
|         self.sell_order_status = 'closed'
 | |
|         self.open_order_id = None
 | |
|         if show_msg:
 | |
|             logger.info(
 | |
|                 'Marking %s as closed as the trade is fulfilled and found no open orders for it.',
 | |
|                 self
 | |
|             )
 | |
| 
 | |
|     def update_fee(self, fee_cost: float, fee_currency: Optional[str], fee_rate: Optional[float],
 | |
|                    side: str) -> None:
 | |
|         """
 | |
|         Update Fee parameters. Only acts once per side
 | |
|         """
 | |
|         if self.is_opening_trade(side) and self.fee_open_currency is None:
 | |
|             self.fee_open_cost = fee_cost
 | |
|             self.fee_open_currency = fee_currency
 | |
|             if fee_rate is not None:
 | |
|                 self.fee_open = fee_rate
 | |
|                 # Assume close-fee will fall into the same fee category and take an educated guess
 | |
|                 self.fee_close = fee_rate
 | |
|         elif self.is_closing_trade(side) and self.fee_close_currency is None:
 | |
|             self.fee_close_cost = fee_cost
 | |
|             self.fee_close_currency = fee_currency
 | |
|             if fee_rate is not None:
 | |
|                 self.fee_close = fee_rate
 | |
| 
 | |
|     def fee_updated(self, side: str) -> bool:
 | |
|         """
 | |
|         Verify if this side (buy / sell) has already been updated
 | |
|         """
 | |
|         if self.is_opening_trade(side):
 | |
|             return self.fee_open_currency is not None
 | |
|         elif self.is_closing_trade(side):
 | |
|             return self.fee_close_currency is not None
 | |
|         else:
 | |
|             return False
 | |
| 
 | |
|     def update_order(self, order: Dict) -> None:
 | |
|         Order.update_orders(self.orders, order)
 | |
| 
 | |
|     def _calc_open_trade_value(self) -> float:
 | |
|         """
 | |
|         Calculate the open_rate including open_fee.
 | |
|         :return: Price in of the open trade incl. Fees
 | |
|         """
 | |
|         open_trade = Decimal(self.amount) * Decimal(self.open_rate)
 | |
|         fees = open_trade * Decimal(self.fee_open)
 | |
|         if (self.is_short):
 | |
|             return float(open_trade - fees)
 | |
|         else:
 | |
|             return float(open_trade + fees)
 | |
| 
 | |
|     def recalc_open_trade_value(self) -> None:
 | |
|         """
 | |
|         Recalculate open_trade_value.
 | |
|         Must be called whenever open_rate or fee_open is changed.
 | |
|         """
 | |
|         self.open_trade_value = self._calc_open_trade_value()
 | |
| 
 | |
|     def calculate_interest(self, interest_rate: Optional[float] = None) -> Decimal:
 | |
|         """
 | |
|         : param interest_rate: interest_charge for borrowing this coin(optional).
 | |
|         If interest_rate is not set self.interest_rate will be used
 | |
|         """
 | |
|         # TODO-mg: Need to set other conditions because sometimes self.open_date is not defined, but why would it ever not be set
 | |
|         zero = Decimal(0.0)
 | |
|         if not (self.borrowed):
 | |
|             return zero
 | |
| 
 | |
|         open_date = self.open_date.replace(tzinfo=None)
 | |
|         now = datetime.utcnow()
 | |
|         # sec_per_day = Decimal(86400)
 | |
|         sec_per_hour = Decimal(3600)
 | |
|         total_seconds = Decimal((now - open_date).total_seconds())
 | |
|         #days = total_seconds/sec_per_day or zero
 | |
|         hours = total_seconds/sec_per_hour or zero
 | |
| 
 | |
|         rate = Decimal(interest_rate or self.interest_rate)
 | |
|         borrowed = Decimal(self.borrowed)
 | |
|         one = Decimal(1.0)
 | |
|         twenty_four = Decimal(24.0)
 | |
|         four = Decimal(4.0)
 | |
| 
 | |
|         if self.exchange == 'binance':
 | |
|             # Rate is per day but accrued hourly or something
 | |
|             # binance: https://www.binance.com/en-AU/support/faq/360030157812
 | |
|             return borrowed * rate * max(hours, one)/twenty_four  # TODO-mg: Is hours rounded?
 | |
|         elif self.exchange == 'kraken':
 | |
|             # https://support.kraken.com/hc/en-us/articles/206161568-What-are-the-fees-for-margin-trading-
 | |
|             opening_fee = borrowed * rate
 | |
|             roll_over_fee = borrowed * rate * max(0, (hours-four)/four)
 | |
|             return opening_fee + roll_over_fee
 | |
|         elif self.exchange == 'binance_usdm_futures':
 | |
|             # ! TODO-mg: This is incorrect, I didn't look it up
 | |
|             return borrowed * (rate/twenty_four) * max(hours, one)
 | |
|         elif self.exchange == 'binance_coinm_futures':
 | |
|             # ! TODO-mg: This is incorrect, I didn't look it up
 | |
|             return borrowed * (rate/twenty_four) * max(hours, one)
 | |
|         else:
 | |
|             # TODO-mg: make sure this breaks and can't be squelched
 | |
|             raise OperationalException("Leverage not available on this exchange")
 | |
| 
 | |
|     def calc_close_trade_value(self, rate: Optional[float] = None,
 | |
|                                fee: Optional[float] = None,
 | |
|                                interest_rate: Optional[float] = None) -> float:
 | |
|         """
 | |
|         Calculate the close_rate including fee
 | |
|         :param fee: fee to use on the close rate (optional).
 | |
|             If rate is not set self.fee will be used
 | |
|         :param rate: rate to compare with (optional).
 | |
|             If rate is not set self.close_rate will be used
 | |
|         :param interest_rate: interest_charge for borrowing this coin (optional).
 | |
|             If interest_rate is not set self.interest_rate will be used
 | |
|         :return: Price in BTC of the open trade
 | |
|         """
 | |
|         if rate is None and not self.close_rate:
 | |
|             return 0.0
 | |
| 
 | |
|         interest = self.calculate_interest(interest_rate)
 | |
|         if self.is_short:
 | |
|             amount = Decimal(self.amount) + Decimal(interest)
 | |
|         else:
 | |
|             # The interest does not need to be purchased on longs because the user already owns that currency in your wallet
 | |
|             amount = Decimal(self.amount)
 | |
| 
 | |
|         close_trade = Decimal(amount) * Decimal(rate or self.close_rate)  # type: ignore
 | |
|         fees = close_trade * Decimal(fee or self.fee_close)
 | |
| 
 | |
|         if (self.is_short):
 | |
|             return float(close_trade + fees)
 | |
|         else:
 | |
|             return float(close_trade - fees - interest)
 | |
| 
 | |
|     def calc_profit(self, rate: Optional[float] = None,
 | |
|                     fee: Optional[float] = None,
 | |
|                     interest_rate: Optional[float] = None) -> float:
 | |
|         """
 | |
|         Calculate the absolute profit in stake currency between Close and Open trade
 | |
|         :param fee: fee to use on the close rate (optional).
 | |
|             If fee is not set self.fee will be used
 | |
|         :param rate: close rate to compare with (optional).
 | |
|             If rate is not set self.close_rate will be used
 | |
|         :param interest_rate: interest_charge for borrowing this coin (optional).
 | |
|             If interest_rate is not set self.interest_rate will be used
 | |
|         :return:  profit in stake currency as float
 | |
|         """
 | |
|         close_trade_value = self.calc_close_trade_value(
 | |
|             rate=(rate or self.close_rate),
 | |
|             fee=(fee or self.fee_close),
 | |
|             interest_rate=(interest_rate or self.interest_rate)
 | |
|         )
 | |
| 
 | |
|         if self.is_short:
 | |
|             profit = self.open_trade_value - close_trade_value
 | |
|         else:
 | |
|             profit = close_trade_value - self.open_trade_value
 | |
|         return float(f"{profit:.8f}")
 | |
| 
 | |
|     def calc_profit_ratio(self, rate: Optional[float] = None,
 | |
|                           fee: Optional[float] = None,
 | |
|                           interest_rate: Optional[float] = None) -> float:
 | |
|         """
 | |
|         Calculates the profit as ratio (including fee).
 | |
|         :param rate: rate to compare with (optional).
 | |
|             If rate is not set self.close_rate will be used
 | |
|         :param fee: fee to use on the close rate (optional).
 | |
|         :param interest_rate: interest_charge for borrowing this coin (optional).
 | |
|             If interest_rate is not set self.interest_rate will be used
 | |
|         :return: profit ratio as float
 | |
|         """
 | |
|         close_trade_value = self.calc_close_trade_value(
 | |
|             rate=(rate or self.close_rate),
 | |
|             fee=(fee or self.fee_close),
 | |
|             interest_rate=(interest_rate or self.interest_rate)
 | |
|         )
 | |
|         if self.is_short:
 | |
|             if close_trade_value == 0.0:
 | |
|                 return 0.0
 | |
|             else:
 | |
|                 profit_ratio = (self.open_trade_value / close_trade_value) - 1
 | |
| 
 | |
|         else:
 | |
|             if self.open_trade_value == 0.0:
 | |
|                 return 0.0
 | |
|             else:
 | |
|                 profit_ratio = (close_trade_value / self.open_trade_value) - 1
 | |
|         return float(f"{profit_ratio:.8f}")
 | |
| 
 | |
|     def select_order(self, order_side: str, is_open: Optional[bool]) -> Optional[Order]:
 | |
|         """
 | |
|         Finds latest order for this orderside and status
 | |
|         :param order_side: Side of the order (either 'buy' or 'sell')
 | |
|         :param is_open: Only search for open orders?
 | |
|         :return: latest Order object if it exists, else None
 | |
|         """
 | |
|         orders = [o for o in self.orders if o.side == order_side]
 | |
|         if is_open is not None:
 | |
|             orders = [o for o in orders if o.ft_is_open == is_open]
 | |
|         if len(orders) > 0:
 | |
|             return orders[-1]
 | |
|         else:
 | |
|             return None
 | |
| 
 | |
|     @staticmethod
 | |
|     def get_trades_proxy(*, pair: str = None, is_open: bool = None,
 | |
|                          open_date: datetime = None, close_date: datetime = None,
 | |
|                          ) -> List['LocalTrade']:
 | |
|         """
 | |
|         Helper function to query Trades.
 | |
|         Returns a List of trades, filtered on the parameters given.
 | |
|         In live mode, converts the filter to a database query and returns all rows
 | |
|         In Backtest mode, uses filters on Trade.trades to get the result.
 | |
| 
 | |
|         :return: unsorted List[Trade]
 | |
|         """
 | |
| 
 | |
|         # Offline mode - without database
 | |
|         if is_open is not None:
 | |
|             if is_open:
 | |
|                 sel_trades = LocalTrade.trades_open
 | |
|             else:
 | |
|                 sel_trades = LocalTrade.trades
 | |
| 
 | |
|         else:
 | |
|             # Not used during backtesting, but might be used by a strategy
 | |
|             sel_trades = list(LocalTrade.trades + LocalTrade.trades_open)
 | |
| 
 | |
|         if pair:
 | |
|             sel_trades = [trade for trade in sel_trades if trade.pair == pair]
 | |
|         if open_date:
 | |
|             sel_trades = [trade for trade in sel_trades if trade.open_date > open_date]
 | |
|         if close_date:
 | |
|             sel_trades = [trade for trade in sel_trades if trade.close_date
 | |
|                           and trade.close_date > close_date]
 | |
| 
 | |
|         return sel_trades
 | |
| 
 | |
|     @staticmethod
 | |
|     def close_bt_trade(trade):
 | |
|         LocalTrade.trades_open.remove(trade)
 | |
|         LocalTrade.trades.append(trade)
 | |
|         LocalTrade.total_profit += trade.close_profit_abs
 | |
| 
 | |
|     @staticmethod
 | |
|     def add_bt_trade(trade):
 | |
|         if trade.is_open:
 | |
|             LocalTrade.trades_open.append(trade)
 | |
|         else:
 | |
|             LocalTrade.trades.append(trade)
 | |
| 
 | |
|     @staticmethod
 | |
|     def get_open_trades() -> List[Any]:
 | |
|         """
 | |
|         Query trades from persistence layer
 | |
|         """
 | |
|         return Trade.get_trades_proxy(is_open=True)
 | |
| 
 | |
|     @staticmethod
 | |
|     def stoploss_reinitialization(desired_stoploss):
 | |
|         """
 | |
|         Adjust initial Stoploss to desired stoploss for all open trades.
 | |
|         """
 | |
|         for trade in Trade.get_open_trades():
 | |
|             logger.info("Found open trade: %s", trade)
 | |
| 
 | |
|             # skip case if trailing-stop changed the stoploss already.
 | |
|             if (trade.stop_loss == trade.initial_stop_loss
 | |
|                and trade.initial_stop_loss_pct != desired_stoploss):
 | |
|                 # Stoploss value got changed
 | |
| 
 | |
|                 logger.info(f"Stoploss for {trade} needs adjustment...")
 | |
|                 # Force reset of stoploss
 | |
|                 trade.stop_loss = None
 | |
|                 trade.adjust_stop_loss(trade.open_rate, desired_stoploss)
 | |
|                 logger.info(f"New stoploss: {trade.stop_loss}.")
 | |
| 
 | |
| 
 | |
| class Trade(_DECL_BASE, LocalTrade):
 | |
|     """
 | |
|     Trade database model.
 | |
|     Also handles updating and querying trades
 | |
| 
 | |
|     Note: Fields must be aligned with LocalTrade class
 | |
|     """
 | |
|     __tablename__ = 'trades'
 | |
| 
 | |
|     use_db: bool = True
 | |
| 
 | |
|     id = Column(Integer, primary_key=True)
 | |
| 
 | |
|     orders = relationship("Order", order_by="Order.id", cascade="all, delete-orphan")
 | |
| 
 | |
|     exchange = Column(String(25), nullable=False)
 | |
|     pair = Column(String(25), nullable=False, index=True)
 | |
|     is_open = Column(Boolean, nullable=False, default=True, index=True)
 | |
|     fee_open = Column(Float, nullable=False, default=0.0)
 | |
|     fee_open_cost = Column(Float, nullable=True)
 | |
|     fee_open_currency = Column(String(25), nullable=True)
 | |
|     fee_close = Column(Float, nullable=False, default=0.0)
 | |
|     fee_close_cost = Column(Float, nullable=True)
 | |
|     fee_close_currency = Column(String(25), nullable=True)
 | |
|     open_rate = Column(Float)
 | |
|     open_rate_requested = Column(Float)
 | |
|     # open_trade_value - calculated via _calc_open_trade_value
 | |
|     open_trade_value = Column(Float)
 | |
|     close_rate = Column(Float)
 | |
|     close_rate_requested = Column(Float)
 | |
|     close_profit = Column(Float)
 | |
|     close_profit_abs = Column(Float)
 | |
|     stake_amount = Column(Float, nullable=False)
 | |
|     amount = Column(Float)
 | |
|     amount_requested = Column(Float)
 | |
|     open_date = Column(DateTime, nullable=False, default=datetime.utcnow)
 | |
|     close_date = Column(DateTime)
 | |
|     open_order_id = Column(String(255))
 | |
|     # absolute value of the stop loss
 | |
|     stop_loss = Column(Float, nullable=True, default=0.0)
 | |
|     # percentage value of the stop loss
 | |
|     stop_loss_pct = Column(Float, nullable=True)
 | |
|     # absolute value of the initial stop loss
 | |
|     initial_stop_loss = Column(Float, nullable=True, default=0.0)
 | |
|     # percentage value of the initial stop loss
 | |
|     initial_stop_loss_pct = Column(Float, nullable=True)
 | |
|     # stoploss order id which is on exchange
 | |
|     stoploss_order_id = Column(String(255), nullable=True, index=True)
 | |
|     # last update time of the stoploss order on exchange
 | |
|     stoploss_last_update = Column(DateTime, nullable=True)
 | |
|     # absolute value of the highest reached price
 | |
|     max_rate = Column(Float, nullable=True, default=0.0)
 | |
|     # Lowest price reached
 | |
|     min_rate = Column(Float, nullable=True)
 | |
|     sell_reason = Column(String(100), nullable=True)  # TODO: Change to close_reason
 | |
|     sell_order_status = Column(String(100), nullable=True)  # TODO: Change to close_order_status
 | |
|     strategy = Column(String(100), nullable=True)
 | |
|     timeframe = Column(Integer, nullable=True)
 | |
| 
 | |
|     # Margin trading properties
 | |
|     _leverage: float = None  # * You probably want to use LocalTrade.leverage instead
 | |
|     borrowed = Column(Float, nullable=False, default=0.0)
 | |
|     interest_rate = Column(Float, nullable=False, default=0.0)
 | |
|     liquidation_price = Column(Float, nullable=True)
 | |
|     is_short = Column(Boolean, nullable=False, default=False)
 | |
|     # TODO: Bottom 2 might not be needed
 | |
|     borrowed_currency = Column(Float, nullable=True)
 | |
|     collateral_currency = Column(String(25), nullable=True)
 | |
|     # End of margin trading properties
 | |
| 
 | |
|     def __init__(self, **kwargs):
 | |
|         super().__init__(**kwargs)
 | |
|         self.recalc_open_trade_value()
 | |
| 
 | |
|     def delete(self) -> None:
 | |
| 
 | |
|         for order in self.orders:
 | |
|             Order.query.session.delete(order)
 | |
| 
 | |
|         Trade.query.session.delete(self)
 | |
|         Trade.commit()
 | |
| 
 | |
|     @staticmethod
 | |
|     def commit():
 | |
|         Trade.query.session.commit()
 | |
| 
 | |
|     @staticmethod
 | |
|     def get_trades_proxy(*, pair: str = None, is_open: bool = None,
 | |
|                          open_date: datetime = None, close_date: datetime = None,
 | |
|                          ) -> List['LocalTrade']:
 | |
|         """
 | |
|         Helper function to query Trades.j
 | |
|         Returns a List of trades, filtered on the parameters given.
 | |
|         In live mode, converts the filter to a database query and returns all rows
 | |
|         In Backtest mode, uses filters on Trade.trades to get the result.
 | |
| 
 | |
|         :return: unsorted List[Trade]
 | |
|         """
 | |
|         if Trade.use_db:
 | |
|             trade_filter = []
 | |
|             if pair:
 | |
|                 trade_filter.append(Trade.pair == pair)
 | |
|             if open_date:
 | |
|                 trade_filter.append(Trade.open_date > open_date)
 | |
|             if close_date:
 | |
|                 trade_filter.append(Trade.close_date > close_date)
 | |
|             if is_open is not None:
 | |
|                 trade_filter.append(Trade.is_open.is_(is_open))
 | |
|             return Trade.get_trades(trade_filter).all()
 | |
|         else:
 | |
|             return LocalTrade.get_trades_proxy(
 | |
|                 pair=pair, is_open=is_open,
 | |
|                 open_date=open_date,
 | |
|                 close_date=close_date
 | |
|             )
 | |
| 
 | |
|     @staticmethod
 | |
|     def get_trades(trade_filter=None) -> Query:
 | |
|         """
 | |
|         Helper function to query Trades using filters.
 | |
|         NOTE: Not supported in Backtesting.
 | |
|         :param trade_filter: Optional filter to apply to trades
 | |
|                              Can be either a Filter object, or a List of filters
 | |
|                              e.g. `(trade_filter=[Trade.id == trade_id, Trade.is_open.is_(True),])`
 | |
|                              e.g. `(trade_filter=Trade.id == trade_id)`
 | |
|         :return: unsorted query object
 | |
|         """
 | |
|         if not Trade.use_db:
 | |
|             raise NotImplementedError('`Trade.get_trades()` not supported in backtesting mode.')
 | |
|         if trade_filter is not None:
 | |
|             if not isinstance(trade_filter, list):
 | |
|                 trade_filter = [trade_filter]
 | |
|             return Trade.query.filter(*trade_filter)
 | |
|         else:
 | |
|             return Trade.query
 | |
| 
 | |
|     @staticmethod
 | |
|     def get_open_order_trades():
 | |
|         """
 | |
|         Returns all open trades
 | |
|         NOTE: Not supported in Backtesting.
 | |
|         """
 | |
|         return Trade.get_trades(Trade.open_order_id.isnot(None)).all()
 | |
| 
 | |
|     @staticmethod
 | |
|     def get_open_trades_without_assigned_fees():
 | |
|         """
 | |
|         Returns all open trades which don't have open fees set correctly
 | |
|         NOTE: Not supported in Backtesting.
 | |
|         """
 | |
|         return Trade.get_trades([Trade.fee_open_currency.is_(None),
 | |
|                                  Trade.orders.any(),
 | |
|                                  Trade.is_open.is_(True),
 | |
|                                  ]).all()
 | |
| 
 | |
|     @staticmethod
 | |
|     def get_closed_trades_without_assigned_fees():
 | |
|         """
 | |
|         Returns all closed trades which don't have fees set correctly
 | |
|         NOTE: Not supported in Backtesting.
 | |
|         """
 | |
|         return Trade.get_trades([Trade.fee_close_currency.is_(None),
 | |
|                                  Trade.orders.any(),
 | |
|                                  Trade.is_open.is_(False),
 | |
|                                  ]).all()
 | |
| 
 | |
|     @staticmethod
 | |
|     def get_total_closed_profit() -> float:
 | |
|         """
 | |
|         Retrieves total realized profit
 | |
|         """
 | |
|         if Trade.use_db:
 | |
|             total_profit = Trade.query.with_entities(
 | |
|                 func.sum(Trade.close_profit_abs)).filter(Trade.is_open.is_(False)).scalar()
 | |
|         else:
 | |
|             total_profit = sum(
 | |
|                 t.close_profit_abs for t in LocalTrade.get_trades_proxy(is_open=False))
 | |
|         return total_profit or 0
 | |
| 
 | |
|     @staticmethod
 | |
|     def total_open_trades_stakes() -> float:
 | |
|         """
 | |
|         Calculates total invested amount in open trades
 | |
|         in stake currency
 | |
|         """
 | |
|         if Trade.use_db:
 | |
|             total_open_stake_amount = Trade.query.with_entities(
 | |
|                 func.sum(Trade.stake_amount)).filter(Trade.is_open.is_(True)).scalar()
 | |
|         else:
 | |
|             total_open_stake_amount = sum(
 | |
|                 t.stake_amount for t in LocalTrade.get_trades_proxy(is_open=True))
 | |
|         return total_open_stake_amount or 0
 | |
| 
 | |
|     @staticmethod
 | |
|     def get_overall_performance() -> List[Dict[str, Any]]:
 | |
|         """
 | |
|         Returns List of dicts containing all Trades, including profit and trade count
 | |
|         NOTE: Not supported in Backtesting.
 | |
|         """
 | |
|         pair_rates = Trade.query.with_entities(
 | |
|             Trade.pair,
 | |
|             func.sum(Trade.close_profit).label('profit_sum'),
 | |
|             func.sum(Trade.close_profit_abs).label('profit_sum_abs'),
 | |
|             func.count(Trade.pair).label('count')
 | |
|         ).filter(Trade.is_open.is_(False))\
 | |
|             .group_by(Trade.pair) \
 | |
|             .order_by(desc('profit_sum_abs')) \
 | |
|             .all()
 | |
|         return [
 | |
|             {
 | |
|                 'pair': pair,
 | |
|                 'profit': profit,
 | |
|                 'profit_abs': profit_abs,
 | |
|                 'count': count
 | |
|             }
 | |
|             for pair, profit, profit_abs, count in pair_rates
 | |
|         ]
 | |
| 
 | |
|     @staticmethod
 | |
|     def get_best_pair(start_date: datetime = datetime.fromtimestamp(0)):
 | |
|         """
 | |
|         Get best pair with closed trade.
 | |
|         NOTE: Not supported in Backtesting.
 | |
|         :returns: Tuple containing (pair, profit_sum)
 | |
|         """
 | |
|         best_pair = Trade.query.with_entities(
 | |
|             Trade.pair, func.sum(Trade.close_profit).label('profit_sum')
 | |
|         ).filter(Trade.is_open.is_(False) & (Trade.close_date >= start_date)) \
 | |
|             .group_by(Trade.pair) \
 | |
|             .order_by(desc('profit_sum')).first()
 | |
|         return best_pair
 | |
| 
 | |
| 
 | |
| class PairLock(_DECL_BASE):
 | |
|     """
 | |
|     Pair Locks database model.
 | |
|     """
 | |
|     __tablename__ = 'pairlocks'
 | |
| 
 | |
|     id = Column(Integer, primary_key=True)
 | |
| 
 | |
|     pair = Column(String(25), nullable=False, index=True)
 | |
|     reason = Column(String(255), nullable=True)
 | |
|     # Time the pair was locked (start time)
 | |
|     lock_time = Column(DateTime, nullable=False)
 | |
|     # Time until the pair is locked (end time)
 | |
|     lock_end_time = Column(DateTime, nullable=False, index=True)
 | |
| 
 | |
|     active = Column(Boolean, nullable=False, default=True, index=True)
 | |
| 
 | |
|     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})')
 | |
| 
 | |
|     @staticmethod
 | |
|     def query_pair_locks(pair: Optional[str], now: datetime) -> Query:
 | |
|         """
 | |
|         Get all currently active locks for this pair
 | |
|         :param pair: Pair to check for. Returns all current locks if pair is empty
 | |
|         :param now: Datetime object (generated via datetime.now(timezone.utc)).
 | |
|         """
 | |
| 
 | |
|         filters = [PairLock.lock_end_time > now,
 | |
|                    # Only active locks
 | |
|                    PairLock.active.is_(True), ]
 | |
|         if pair:
 | |
|             filters.append(PairLock.pair == pair)
 | |
|         return PairLock.query.filter(
 | |
|             *filters
 | |
|         )
 | |
| 
 | |
|     def to_json(self) -> Dict[str, Any]:
 | |
|         return {
 | |
|             'id': self.id,
 | |
|             'pair': self.pair,
 | |
|             'lock_time': self.lock_time.strftime(DATETIME_PRINT_FORMAT),
 | |
|             'lock_timestamp': int(self.lock_time.replace(tzinfo=timezone.utc).timestamp() * 1000),
 | |
|             'lock_end_time': self.lock_end_time.strftime(DATETIME_PRINT_FORMAT),
 | |
|             'lock_end_timestamp': int(self.lock_end_time.replace(tzinfo=timezone.utc
 | |
|                                                                  ).timestamp() * 1000),
 | |
|             'reason': self.reason,
 | |
|             'active': self.active,
 | |
|         }
 |