from datetime import datetime from decimal import Decimal as D from core.exchanges import GenericAPIError from core.models import Account, Strategy, Trade from core.util import logs log = logs.get_logger(__name__) def get_pair(account, base, quote, invert=False): """ Get the pair for the given account and currencies. :param account: Account object :param base: Base currency :param quote: Quote currency :param invert: Invert the pair :return: currency symbol, e.g. BTC_USD, BTC/USD, etc. """ # Currently we only have two exchanges with different pair separators if account.exchange == "alpaca": separator = "/" elif account.exchange == "oanda": separator = "_" # Flip the pair if needed if invert: symbol = f"{quote.upper()}{separator}{base.upper()}" else: symbol = f"{base.upper()}{separator}{quote.upper()}" # Check it exists if symbol not in account.supported_symbols: return False return symbol def to_currency(direction, account, amount, from_currency, to_currency): """ Convert an amount from one currency to another. :param direction: Direction of the trade :param account: Account object :param amount: Amount to convert :param from_currency: Currency to convert from :param to_currency: Currency to convert to :return: Converted amount """ inverted = False # This is needed because OANDA has different values for bid and ask if direction == "buy": price_index = "bids" elif direction == "sell": price_index = "asks" symbol = get_pair(account, from_currency, to_currency) if not symbol: symbol = get_pair(account, from_currency, to_currency, invert=True) inverted = True # Bit of a hack but it works if not symbol: log.error(f"Could not find symbol for {from_currency} -> {to_currency}") raise Exception("Could not find symbol") try: prices = account.client.get_currencies([symbol]) except GenericAPIError as e: log.error(f"Error getting currencies and inverted currencies: {e}") return None price = D(prices["prices"][0][price_index][0]["price"]) # If we had to flip base and quote, we need to use the reciprocal of the price if inverted: price = D(1.0) / price # Convert the amount to the destination currency converted = D(amount) * price return converted def get_price(account, direction, symbol): """ Get the price for a given symbol. :param account: Account object :param direction: direction of the trade :param symbol: symbol :return: price of bid for buys, price of ask for sells """ if direction == "buy": price_index = "bids" elif direction == "sell": price_index = "asks" try: prices = account.client.get_currencies([symbol]) except GenericAPIError as e: log.error(f"Error getting currencies: {e}") return None price = D(prices["prices"][0][price_index][0]["price"]) return price def get_trade_size_in_base(direction, account, strategy, cash_balance, base): """ Get the trade size in the base currency. :param direction: Direction of the trade :param account: Account object :param strategy: Strategy object :param cash_balance: Cash balance in the Account's base currency :param base: Base currency :return: Trade size in the base currency """ # Convert the trade size in percent to a ratio trade_size_as_ratio = D(strategy.trade_size_percent) / D(100) log.debug(f"Trade size as ratio: {trade_size_as_ratio}") # Multiply with cash balance to get the trade size in the account's # base currency amount_fiat = D(trade_size_as_ratio) * D(cash_balance) log.debug(f"Trade size: {amount_fiat}") # Convert the trade size to the base currency if account.currency.lower() == base.lower(): trade_size_in_base = amount_fiat else: trade_size_in_base = to_currency( direction, account, amount_fiat, account.currency, base ) log.debug(f"Trade size in base: {trade_size_in_base}") return trade_size_in_base def get_tp(direction, take_profit_percent, price): """ Get the take profit price. :param direction: Direction of the trade :param strategy: Strategy object :param price: Entry price """ # Convert to ratio take_profit_as_ratio = D(take_profit_percent) / D(100) log.debug(f"Take profit as ratio: {take_profit_as_ratio}") take_profit_var = D(price) * D(take_profit_as_ratio) log.debug(f"Take profit var: {take_profit_var}") if direction == "buy": take_profit = D(price) + D(take_profit_var) elif direction == "sell": take_profit = D(price) - D(take_profit_var) log.debug(f"Take profit: {take_profit}") return take_profit def get_sl(direction, stop_loss_percent, price, return_var=False): """ Get the stop loss price. Also used for trailing stop loss. :param direction: Direction of the trade :param strategy: Strategy object :param price: Entry price """ # Convert to ratio stop_loss_as_ratio = D(stop_loss_percent) / D(100) log.debug(f"Stop loss as ratio: {stop_loss_as_ratio}") stop_loss_var = D(price) * D(stop_loss_as_ratio) log.debug(f"Stop loss var: {stop_loss_var}") if return_var: return stop_loss_var if direction == "buy": stop_loss = D(price) - D(stop_loss_var) elif direction == "sell": stop_loss = D(price) + D(stop_loss_var) log.debug(f"Stop loss: {stop_loss}") return stop_loss def get_tp_sl(direction, strategy, price): """ Get the take profit and stop loss prices. :param direction: Direction of the trade :param strategy: Strategy object :param price: Price of the trade :return: Take profit and stop loss prices """ take_profit = get_tp(direction, strategy.take_profit_percent, price) stop_loss = get_sl(direction, strategy.stop_loss_percent, price) cast = {"tp": take_profit, "sl": stop_loss} # Look up the TSL if required by the strategy if strategy.trailing_stop_loss_percent: trailing_stop_loss = get_sl( direction, strategy.trailing_stop_loss_percent, price, return_var=True ) cast["tsl"] = trailing_stop_loss return cast def get_price_bound(direction, strategy, price, current_price): """ Get the price bound for a given price using the slippage from the strategy. * Check that the price of the callback is within the callback price deviation of the current price * Calculate the price bounds such that the maximum slippage should be within the price slippage relative to the current price. Note that the maximum actual slippage may be as high as the sum of these two values. :param direction: Direction of the trade :param strategy: Strategy object :param price: Price of the trade :param current_price: current price from the exchange :return: Price bound """ # Convert the callback price deviation to a ratio callback_price_deviation_as_ratio = D( strategy.callback_price_deviation_percent ) / D(100) log.debug(f"Callback price deviation as ratio: {callback_price_deviation_as_ratio}") maximum_price_deviation = D(current_price) * D(callback_price_deviation_as_ratio) # Ensure the current price is within price_slippage_as_ratio of the callback price if abs(current_price - price) <= maximum_price_deviation: log.debug("Current price is within price deviation of callback price") else: log.error("Current price is not within price deviation of callback price") log.debug(f"Difference: {abs(current_price - price)}") return None # Convert the maximum price slippage to a ratio price_slippage_as_ratio = D(strategy.price_slippage_percent) / D(100) log.debug(f"Maximum price slippage as ratio: {price_slippage_as_ratio}") # Calculate the price bound by multiplying with the price # The price bound is the worst price we are willing to pay for the trade price_slippage = D(current_price) * D(price_slippage_as_ratio) log.debug(f"Maximum deviation from callback price: {price_slippage}") current_price_slippage = D(current_price) * D(price_slippage_as_ratio) log.debug(f"Maximum deviation from current price: {current_price_slippage}") # Price bound is the worst price we are willing to pay for the trade # For buys, a higher price is worse if direction == "buy": price_bound = D(current_price) + D(price_slippage) # For sells, a lower price is worse elif direction == "sell": price_bound = D(current_price) - D(price_slippage) log.debug(f"Price bound: {price_bound}") return price_bound def execute_strategy(callback, strategy): """ Execute a strategy. :param callback: Callback object :param strategy: Strategy object """ # Check if we can trade now! now_utc = datetime.utcnow() trading_times = strategy.trading_times.all() if not trading_times: log.error("No trading times set for strategy") return matches = [x.within_range(now_utc) for x in trading_times] if not any(matches): log.debug("Not within trading time range") return # Get the account's balance in the native account currency cash_balance = strategy.account.client.get_balance() log.debug(f"Cash balance: {cash_balance}") # Instruments supported by the account if not strategy.account.instruments: strategy.account.update_info() # Refresh account object strategy.account = Account.objects.get(id=strategy.account.id) instruments = strategy.account.instruments if not instruments: log.error("No instruments found") return # Shorten some hook, strategy and callback vars for convenience user = strategy.user account = strategy.account hook = callback.hook base = callback.base quote = callback.quote direction = hook.direction # Don't be silly if callback.exchange != account.exchange: log.error("Market exchange differs from account exchange.") return # Get the pair we are trading symbol = get_pair(account, base, quote) if not symbol: log.error(f"Symbol not supported by account: {symbol}") return # Extract the information for the symbol instrument = strategy.account.client.extract_instrument(instruments, symbol) if not instrument: log.error(f"Symbol not found: {symbol}") return # Get the required precision try: trade_precision = instrument["tradeUnitsPrecision"] display_precision = instrument["displayPrecision"] except KeyError: log.error(f"Precision not found for {symbol}") return # Round the received price to the display precision price = round(D(callback.price), display_precision) log.debug(f"Extracted price of quote: {price}") type = strategy.order_type current_price = get_price(account, direction, symbol) log.debug(f"Callback price: {price}") log.debug(f"Current price: {current_price}") # Convert the trade size, which is currently in the account's base currency, # to the base currency of the pair we are trading trade_size_in_base = get_trade_size_in_base( direction, account, strategy, cash_balance, base ) # Calculate TP/SL/TSL protection = get_tp_sl(direction, strategy, current_price) stop_loss = protection["sl"] take_profit = protection["tp"] trailing_stop_loss = None if "tsl" in protection: trailing_stop_loss = protection["tsl"] # Calculate price bound and round to the display precision price_bound = get_price_bound(direction, strategy, price, current_price) if not price_bound: return price_bound = round(price_bound, display_precision) # Create object, note that the amount is rounded to the trade precision new_trade = Trade.objects.create( user=user, account=account, hook=hook, symbol=symbol, type=type, time_in_force=strategy.time_in_force, # amount_fiat=amount_fiat, amount=float(round(trade_size_in_base, trade_precision)), # price=price_bound, price=price_bound, stop_loss=float(round(stop_loss, display_precision)), take_profit=float(round(take_profit, display_precision)), direction=direction, ) # Add TSL if applicable if trailing_stop_loss: new_trade.trailing_stop_loss = float( round(trailing_stop_loss, display_precision) ) new_trade.save() info = new_trade.post() log.debug(f"Posted trade: {info}") def process_callback(callback): log.info(f"Received callback for {callback.hook}") strategies = Strategy.objects.filter(hooks=callback.hook, enabled=True) log.debug(f"Matched strategies: {strategies}") for strategy in strategies: log.debug(f"Executing strategy {strategy}") if callback.hook.user != strategy.user: log.error("Ownership differs between callback and strategy.") continue execute_strategy(callback, strategy)