From 487833d2938e432745fb2c1f8dd596ce06fff2ff Mon Sep 17 00:00:00 2001 From: vic-en Date: Thu, 4 Feb 2021 12:45:09 +0100 Subject: [PATCH 01/24] (feat) initial part of perpetual protocol connector --- .../derivative/perpetual_finance/__init__.py | 0 .../derivative/perpetual_finance/dummy.pxd | 2 + .../derivative/perpetual_finance/dummy.pyx | 2 + .../perpetual_finance_derivative.py | 557 ++++++++++++++++++ .../perpetual_finance_in_flight_order.py | 58 ++ .../perpetual_finance_utils.py | 34 ++ 6 files changed, 653 insertions(+) create mode 100644 hummingbot/connector/derivative/perpetual_finance/__init__.py create mode 100644 hummingbot/connector/derivative/perpetual_finance/dummy.pxd create mode 100644 hummingbot/connector/derivative/perpetual_finance/dummy.pyx create mode 100644 hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py create mode 100644 hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py create mode 100644 hummingbot/connector/derivative/perpetual_finance/perpetual_finance_utils.py diff --git a/hummingbot/connector/derivative/perpetual_finance/__init__.py b/hummingbot/connector/derivative/perpetual_finance/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hummingbot/connector/derivative/perpetual_finance/dummy.pxd b/hummingbot/connector/derivative/perpetual_finance/dummy.pxd new file mode 100644 index 0000000000..4b098d6f59 --- /dev/null +++ b/hummingbot/connector/derivative/perpetual_finance/dummy.pxd @@ -0,0 +1,2 @@ +cdef class dummy(): + pass diff --git a/hummingbot/connector/derivative/perpetual_finance/dummy.pyx b/hummingbot/connector/derivative/perpetual_finance/dummy.pyx new file mode 100644 index 0000000000..4b098d6f59 --- /dev/null +++ b/hummingbot/connector/derivative/perpetual_finance/dummy.pyx @@ -0,0 +1,2 @@ +cdef class dummy(): + pass diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py new file mode 100644 index 0000000000..181b277da7 --- /dev/null +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -0,0 +1,557 @@ +import logging +from decimal import Decimal +import asyncio +import aiohttp +from typing import Dict, Any, List, Optional +import json +import time +import ssl +import copy +from hummingbot.logger.struct_logger import METRICS_LOG_LEVEL +from hummingbot.core.utils import async_ttl_cache +from hummingbot.core.network_iterator import NetworkStatus +from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather +from hummingbot.logger import HummingbotLogger +from hummingbot.core.utils.tracking_nonce import get_tracking_nonce +from hummingbot.core.data_type.limit_order import LimitOrder +from hummingbot.core.data_type.cancellation_result import CancellationResult +from hummingbot.core.event.events import ( + MarketEvent, + BuyOrderCreatedEvent, + SellOrderCreatedEvent, + BuyOrderCompletedEvent, + SellOrderCompletedEvent, + MarketOrderFailureEvent, + OrderFilledEvent, + OrderType, + TradeType, + TradeFee, + # PositionSide, PositionMode, + PositionAction +) +from hummingbot.connector.derivative_base import DerivativeBase +from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_in_flight_order import PerpetualFinanceInFlightOrder +from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_utils import convert_to_exchange_trading_pair # convert_from_exchange_trading_pair +from hummingbot.client.settings import GATEAWAY_CA_CERT_PATH, GATEAWAY_CLIENT_CERT_PATH, GATEAWAY_CLIENT_KEY_PATH +from hummingbot.core.utils.eth_gas_station_lookup import get_gas_price +from hummingbot.client.config.global_config_map import global_config_map +# from hummingbot.connector.derivative.position import Position + + +s_logger = None +s_decimal_0 = Decimal("0") +s_decimal_NaN = Decimal("nan") +logging.basicConfig(level=METRICS_LOG_LEVEL) + + +class PerpetualFinanceDerivative(DerivativeBase): + """ + PerpetualFinanceConnector connects with perpetual_finance gateway APIs and provides pricing, user account tracking and trading + functionality. + """ + API_CALL_TIMEOUT = 10.0 + POLL_INTERVAL = 1.0 + UPDATE_BALANCE_INTERVAL = 30.0 + + @classmethod + def logger(cls) -> HummingbotLogger: + global s_logger + if s_logger is None: + s_logger = logging.getLogger(__name__) + return s_logger + + def __init__(self, + trading_pairs: List[str], + wallet_private_key: str, + ethereum_rpc_url: str, + trading_required: bool = True + ): + """ + :param trading_pairs: a list of trading pairs + :param wallet_private_key: a private key for eth wallet + :param ethereum_rpc_url: this is usually infura RPC URL + :param trading_required: Whether actual trading is needed. + """ + super().__init__() + self._trading_pairs = trading_pairs + self._wallet_private_key = wallet_private_key + self._trading_required = trading_required + self._ev_loop = asyncio.get_event_loop() + self._shared_client = None + self._last_poll_timestamp = 0.0 + self._last_balance_poll_timestamp = time.time() + self._in_flight_orders = {} + self._allowances = {} + self._status_polling_task = None + self._auto_approve_task = None + self._real_time_balance_update = False + self._poll_notifier = None + + @property + def name(self): + return "perpetual_finance" + + """@staticmethod + async def fetch_trading_pairs() -> List[str]: + resp = await self._api_request("get", "perpfi/get-pairs") + pairs = resp.get("pairs", []) + if len(pairs) == 0: + await self.load_metadata() + trading_pairs = [] + for pair in pairs: + trading_pairs.append(convert_from_exchange_trading_pair(pair)) + return trading_pairs""" + + @property + def limit_orders(self) -> List[LimitOrder]: + return [ + in_flight_order.to_limit_order() + for in_flight_order in self._in_flight_orders.values() + ] + + async def load_metadata(self): + status = await self._api_request("get", "perpfi/") + loadedMetadata = status["loadedMetadata"] + while (not loadedMetadata): + resp = await self._api_request("get", "perpfi/load-metadata") + loadedMetadata = resp.get("loadedMetadata", False) + return + + async def auto_approve(self): + """ + Automatically approves PerpetualFinance contract as a spender for token in trading pairs. + It first checks if there are any already approved amount (allowance) + """ + self.logger().info("Checking for allowances...") + self._allowances = await self.get_allowances() + for token, amount in self._allowances.items(): + if amount <= s_decimal_0: + amount_approved = await self.approve_perpetual_finance_spender() + if amount_approved > 0: + self._allowances[token] = amount_approved + await asyncio.sleep(2) + else: + break + + async def approve_perpetual_finance_spender(self) -> Decimal: + """ + Approves PerpetualFinance contract as a spender for default USDC token. + """ + resp = await self._api_request("post", "perpfi/approve") + amount_approved = Decimal(str(resp["amount"])) + if amount_approved > 0: + self.logger().info("Approved PerpetualFinance spender contract.") + else: + self.logger().info("PerpetualFinance spender contract approval failed.") + return amount_approved + + async def get_allowances(self) -> Dict[str, Decimal]: + """ + Retrieves allowances for token in trading_pairs + :return: A dictionary of token and its allowance (how much PerpetualFinance can spend). + """ + ret_val = {} + resp = await self._api_request("post", "perpfi/allowances") + for asset, amount in resp["approvals"].items(): + ret_val[asset] = Decimal(str(amount)) + return ret_val + + @async_ttl_cache(ttl=5, maxsize=10) + async def get_quote_price(self, trading_pair: str, is_buy: bool, amount: Decimal) -> Optional[Decimal]: + """ + Retrieves a quote price. + :param trading_pair: The market trading pair + :param is_buy: True for an intention to buy, False for an intention to sell + :param amount: The amount required (in base token unit) + :return: The quote price. + """ + + try: + side = "buy" if is_buy else "sell" + resp = await self._api_request("post", + "perpfi/get-price", + {"side": side, + "pair": convert_to_exchange_trading_pair(trading_pair), + "amount": amount}) + if resp["price"] is not None: + return Decimal(str(resp["price"])) + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().network( + f"Error getting quote price for {trading_pair} {side} order for {amount} amount.", + exc_info=True, + app_warning_msg=str(e) + ) + + async def get_order_price(self, trading_pair: str, is_buy: bool, amount: Decimal) -> Decimal: + """ + This is simply the quote price + """ + return await self.get_quote_price(trading_pair, is_buy, amount) + + def buy(self, trading_pair: str, amount: Decimal, order_type: OrderType, price: Decimal, position_action: PositionAction) -> str: + """ + Buys an amount of base token for a given price (or cheaper). + :param trading_pair: The market trading pair + :param amount: The order amount (in base token unit) + :param order_type: Any order type is fine, not needed for this. + :param price: The maximum price for the order. + :param position_action: Either OPEN or CLOSE position action. + :return: A newly created order id (internal). + """ + return self.place_order(True, trading_pair, amount, price, position_action) + + def sell(self, trading_pair: str, amount: Decimal, order_type: OrderType, price: Decimal, position_action: PositionAction) -> str: + """ + Sells an amount of base token for a given price (or at a higher price). + :param trading_pair: The market trading pair + :param amount: The order amount (in base token unit) + :param order_type: Any order type is fine, not needed for this. + :param price: The minimum price for the order. + :param position_action: Either OPEN or CLOSE position action. + :return: A newly created order id (internal). + """ + return self.place_order(False, trading_pair, amount, price, position_action) + + def place_order(self, is_buy: bool, trading_pair: str, amount: Decimal, price: Decimal, position_action: PositionAction) -> str: + """ + Places an order. + :param is_buy: True for buy order + :param trading_pair: The market trading pair + :param amount: The order amount (in base token unit) + :param price: The minimum price for the order. + :param position_action: Either OPEN or CLOSE position action. + :return: A newly created order id (internal). + """ + side = TradeType.BUY if is_buy else TradeType.SELL + order_id = f"{side.name.lower()}-{trading_pair}-{get_tracking_nonce()}" + safe_ensure_future(self._create_order(side, order_id, trading_pair, amount, price, position_action)) + return order_id + + async def _create_order(self, + trade_type: TradeType, + order_id: str, + trading_pair: str, + amount: Decimal, + price: Decimal, + position_action: PositionAction): + """ + Calls buy or sell API end point to place an order, starts tracking the order and triggers relevant order events. + :param trade_type: BUY or SELL + :param order_id: Internal order id (also called client_order_id) + :param trading_pair: The market to place order + :param amount: The order amount (in base token value) + :param price: The order price + :param position_action: Either OPEN or CLOSE position action. + """ + + amount = self.quantize_order_amount(trading_pair, amount) + price = self.quantize_order_price(trading_pair, price) + base, quote = trading_pair.split("-") + gas_price = get_gas_price() + api_params = {"pair": convert_to_exchange_trading_pair(trading_pair)} + if position_action == PositionAction.OPEN: + api_params.update({"side": 0 if trade_type == TradeType.BUY else 1, + "margin": str(amount / self._leverage), + "leverage": self._leverage, + "minBaseAssetAmount": amount}) + else: + api_params.update({"minimalQuoteAsset": price * amount}) + self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, gas_price) + try: + order_result = await self._api_request("post", f"perpfi/{trade_type.name.lower()}", api_params) + hash = order_result.get("txHash") + tracked_order = self._in_flight_orders.get(order_id) + if tracked_order is not None: + self.logger().info(f"Created {trade_type.name} order {order_id} txHash: {hash} " + f"for {amount} {trading_pair}.") + tracked_order.update_exchange_order_id(hash) + tracked_order.gas_price = gas_price + if hash is not None: + tracked_order.fee_asset = "ETH" + tracked_order.executed_amount_base = amount + tracked_order.executed_amount_quote = amount * price + event_tag = MarketEvent.BuyOrderCreated if trade_type is TradeType.BUY else MarketEvent.SellOrderCreated + event_class = BuyOrderCreatedEvent if trade_type is TradeType.BUY else SellOrderCreatedEvent + self.trigger_event(event_tag, event_class(self.current_timestamp, OrderType.LIMIT, trading_pair, amount, + price, order_id, hash)) + else: + self.trigger_event(MarketEvent.OrderFailure, + MarketOrderFailureEvent(self.current_timestamp, order_id, OrderType.LIMIT)) + except asyncio.CancelledError: + raise + except Exception as e: + self.stop_tracking_order(order_id) + self.logger().network( + f"Error submitting {trade_type.name} order to PerpetualFinance for " + f"{amount} {trading_pair} " + f"{price}.", + exc_info=True, + app_warning_msg=str(e) + ) + self.trigger_event(MarketEvent.OrderFailure, + MarketOrderFailureEvent(self.current_timestamp, order_id, OrderType.LIMIT)) + + def start_tracking_order(self, + order_id: str, + exchange_order_id: str, + trading_pair: str, + trade_type: TradeType, + price: Decimal, + amount: Decimal, + gas_price: Decimal, + leverage: int, + position: str,): + """ + Starts tracking an order by simply adding it into _in_flight_orders dictionary. + """ + self._in_flight_orders[order_id] = PerpetualFinanceInFlightOrder( + client_order_id=order_id, + exchange_order_id=exchange_order_id, + trading_pair=trading_pair, + order_type=OrderType.LIMIT, + trade_type=trade_type, + price=price, + amount=amount, + gas_price=gas_price, + leverage=leverage, + position=position + ) + + def stop_tracking_order(self, order_id: str): + """ + Stops tracking an order by simply removing it from _in_flight_orders dictionary. + """ + if order_id in self._in_flight_orders: + del self._in_flight_orders[order_id] + + async def _update_order_status(self): + """ + Calls REST API to get status update for each in-flight order. + """ + if len(self._in_flight_orders) > 0: + tracked_orders = list(self._in_flight_orders.values()) + + tasks = [] + for tracked_order in tracked_orders: + order_id = await tracked_order.get_exchange_order_id() + tasks.append(self._api_request("post", + "eth/get-receipt", + {"txHash": order_id})) + update_results = await safe_gather(*tasks, return_exceptions=True) + for update_result in update_results: + self.logger().info(f"Polling for order status updates of {len(tasks)} orders.") + if isinstance(update_result, Exception): + raise update_result + if "txHash" not in update_result: + self.logger().info(f"_update_order_status txHash not in resp: {update_result}") + continue + if update_result["confirmed"] is True: + if update_result["receipt"]["status"] == 1: + gas_used = update_result["receipt"]["gasUsed"] + gas_price = tracked_order.gas_price + fee = Decimal(str(gas_used)) * Decimal(str(gas_price)) / Decimal(str(1e9)) + self.trigger_event( + MarketEvent.OrderFilled, + OrderFilledEvent( + self.current_timestamp, + tracked_order.client_order_id, + tracked_order.trading_pair, + tracked_order.trade_type, + tracked_order.order_type, + Decimal(str(tracked_order.price)), + Decimal(str(tracked_order.amount)), + TradeFee(0.0, [(tracked_order.fee_asset, Decimal(str(fee)))]), + exchange_trade_id=order_id + ) + ) + tracked_order.last_state = "FILLED" + self.logger().info(f"The {tracked_order.trade_type.name} order " + f"{tracked_order.client_order_id} has completed " + f"according to order status API.") + event_tag = MarketEvent.BuyOrderCompleted if tracked_order.trade_type is TradeType.BUY \ + else MarketEvent.SellOrderCompleted + event_class = BuyOrderCompletedEvent if tracked_order.trade_type is TradeType.BUY \ + else SellOrderCompletedEvent + self.trigger_event(event_tag, + event_class(self.current_timestamp, + tracked_order.client_order_id, + tracked_order.base_asset, + tracked_order.quote_asset, + tracked_order.fee_asset, + tracked_order.executed_amount_base, + tracked_order.executed_amount_quote, + float(fee), + tracked_order.order_type)) + self.stop_tracking_order(tracked_order.client_order_id) + else: + self.logger().info( + f"The market order {tracked_order.client_order_id} has failed according to order status API. ") + self.trigger_event(MarketEvent.OrderFailure, + MarketOrderFailureEvent( + self.current_timestamp, + tracked_order.client_order_id, + tracked_order.order_type + )) + self.stop_tracking_order(tracked_order.client_order_id) + + def get_taker_order_type(self): + return OrderType.LIMIT + + def get_order_price_quantum(self, trading_pair: str, price: Decimal) -> Decimal: + return Decimal("1e-15") + + def get_order_size_quantum(self, trading_pair: str, order_size: Decimal) -> Decimal: + return Decimal("1e-15") + + @property + def ready(self): + return all(self.status_dict.values()) + + def has_allowances(self) -> bool: + """ + Checks if all tokens have allowance (an amount approved) + """ + return len(self._allowances.values()) == len(self._token_addresses.values()) and \ + all(amount > s_decimal_0 for amount in self._allowances.values()) + + @property + def status_dict(self) -> Dict[str, bool]: + return { + "account_balance": len(self._account_balances) > 0 if self._trading_required else True, + "allowances": self.has_allowances() if self._trading_required else True + } + + async def start_network(self): + if self._trading_required: + self._status_polling_task = safe_ensure_future(self._status_polling_loop()) + self._auto_approve_task = safe_ensure_future(self.auto_approve()) + + async def stop_network(self): + if self._status_polling_task is not None: + self._status_polling_task.cancel() + self._status_polling_task = None + if self._auto_approve_task is not None: + self._auto_approve_task.cancel() + self._auto_approve_task = None + + async def check_network(self) -> NetworkStatus: + try: + response = await self._api_request("get", "api") + if response["status"] != "ok": + raise Exception(f"Error connecting to Gateway API. HTTP status is {response.status}.") + except asyncio.CancelledError: + raise + except Exception: + return NetworkStatus.NOT_CONNECTED + return NetworkStatus.CONNECTED + + def tick(self, timestamp: float): + """ + Is called automatically by the clock for each clock's tick (1 second by default). + It checks if status polling task is due for execution. + """ + if time.time() - self._last_poll_timestamp > self.POLL_INTERVAL: + if self._poll_notifier is not None and not self._poll_notifier.is_set(): + self._poll_notifier.set() + + async def _status_polling_loop(self): + while True: + try: + self._poll_notifier = asyncio.Event() + await self._poll_notifier.wait() + await safe_gather( + self._update_balances(), + self._update_order_status(), + ) + self._last_poll_timestamp = self.current_timestamp + except asyncio.CancelledError: + raise + except Exception as e: + self.logger().error(str(e), exc_info=True) + self.logger().network("Unexpected error while fetching account updates.", + exc_info=True, + app_warning_msg="Could not fetch balances from Gateway API.") + await asyncio.sleep(0.5) + + async def _update_balances(self): + """ + Calls Eth API to update total and available balances. + """ + last_tick = self._last_balance_poll_timestamp + current_tick = self.current_timestamp + if (current_tick - last_tick) > self.UPDATE_BALANCE_INTERVAL: + self._last_balance_poll_timestamp = current_tick + local_asset_names = set(self._account_balances.keys()) + remote_asset_names = set() + resp_json = await self._api_request("post", "perpfi/balances") + for token, bal in resp_json["balances"].items(): + if len(token) > 4: + token = self.get_token(token) + self._account_available_balances[token] = Decimal(str(bal)) + self._account_balances[token] = Decimal(str(bal)) + remote_asset_names.add(token) + + asset_names_to_remove = local_asset_names.difference(remote_asset_names) + for asset_name in asset_names_to_remove: + del self._account_available_balances[asset_name] + del self._account_balances[asset_name] + + self._in_flight_orders_snapshot = {k: copy.copy(v) for k, v in self._in_flight_orders.items()} + self._in_flight_orders_snapshot_timestamp = self.current_timestamp + + async def _http_client(self) -> aiohttp.ClientSession: + """ + :returns Shared client session instance + """ + if self._shared_client is None: + ssl_ctx = ssl.create_default_context(cafile=GATEAWAY_CA_CERT_PATH) + ssl_ctx.load_cert_chain(GATEAWAY_CLIENT_CERT_PATH, GATEAWAY_CLIENT_KEY_PATH) + conn = aiohttp.TCPConnector(ssl_context=ssl_ctx) + self._shared_client = aiohttp.ClientSession(connector=conn) + return self._shared_client + + async def _api_request(self, + method: str, + path_url: str, + params: Dict[str, Any] = {}) -> Dict[str, Any]: + """ + Sends an aiohttp request and waits for a response. + :param method: The HTTP method, e.g. get or post + :param path_url: The path url or the API end point + :param params: A dictionary of required params for the end point + :returns A response in json format. + """ + base_url = f"https://{global_config_map['gateway_api_host'].value}:" \ + f"{global_config_map['gateway_api_port'].value}" + url = f"{base_url}/{path_url}" + client = await self._http_client() + if method == "get": + if len(params) > 0: + response = await client.get(url, params=params) + else: + response = await client.get(url) + elif method == "post": + params["privateKey"] = self._wallet_private_key + if params["privateKey"][:2] != "0x": + params["privateKey"] = "0x" + params["privateKey"] + response = await client.post(url, data=params) + + parsed_response = json.loads(await response.text()) + if response.status != 200: + err_msg = "" + if "error" in parsed_response: + err_msg = f" Message: {parsed_response['error']}" + raise IOError(f"Error fetching data from {url}. HTTP status is {response.status}.{err_msg}") + if "error" in parsed_response: + raise Exception(f"Error: {parsed_response['error']}") + + return parsed_response + + async def cancel_all(self, timeout_seconds: float) -> List[CancellationResult]: + return [] + + @property + def in_flight_orders(self) -> Dict[str, PerpetualFinanceInFlightOrder]: + return self._in_flight_orders diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py new file mode 100644 index 0000000000..d7dcc0fc1f --- /dev/null +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py @@ -0,0 +1,58 @@ +from decimal import Decimal +from typing import ( + Optional, +) +from hummingbot.core.event.events import ( + OrderType, + TradeType +) +from hummingbot.connector.in_flight_order_base import InFlightOrderBase + + +class PerpetualFinanceInFlightOrder(InFlightOrderBase): + def __init__(self, + client_order_id: str, + exchange_order_id: Optional[str], + trading_pair: str, + order_type: OrderType, + trade_type: TradeType, + price: Decimal, + amount: Decimal, + gas_price: Decimal, + leverage: int, + position: str, + initial_state: str = "OPEN"): + super().__init__( + client_order_id, + exchange_order_id, + trading_pair, + order_type, + trade_type, + price, + amount, + initial_state, + ) + self.trade_id_set = set() + self._gas_price = gas_price + self.leverage = leverage + self.position = position + + @property + def is_done(self) -> bool: + return self.last_state in {"FILLED", "CANCELED", "REJECTED", "EXPIRED"} + + @property + def is_failure(self) -> bool: + return self.last_state in {"REJECTED"} + + @property + def is_cancelled(self) -> bool: + return self.last_state in {"CANCELED", "EXPIRED"} + + @property + def gas_price(self) -> Decimal: + return self._gas_price + + @gas_price.setter + def gas_price(self, gas_price) -> Decimal: + self._gas_price = gas_price diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_utils.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_utils.py new file mode 100644 index 0000000000..665a0a34e1 --- /dev/null +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_utils.py @@ -0,0 +1,34 @@ +import re +from typing import Optional, Tuple + +CENTRALIZED = False +EXAMPLE_PAIR = "ETH-USDC" +DEFAULT_FEES = [0.1, 0.1] + +USE_ETHEREUM_WALLET = True +FEE_TYPE = "FlatFee" +FEE_TOKEN = "XDAI" + +USE_ETH_GAS_LOOKUP = False + +QUOTE = re.compile(r"^(\w+)(USDC|USDT)$") + + +# Helper Functions --- +def split_trading_pair(trading_pair: str) -> Optional[Tuple[str, str]]: + try: + m = QUOTE.match(trading_pair) + return m.group(1), m.group(2) + except Exception as e: + raise e + + +def convert_from_exchange_trading_pair(exchange_trading_pair: str) -> Optional[str]: + if split_trading_pair(exchange_trading_pair) is None: + return None + base_asset, quote_asset = split_trading_pair(exchange_trading_pair) + return f"{base_asset}-{quote_asset}" + + +def convert_to_exchange_trading_pair(hb_trading_pair: str) -> str: + return hb_trading_pair.replace("-", "") From 16e03b3853c83ce087ac1a682b9eac24d95b60e9 Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 8 Feb 2021 12:55:16 +0100 Subject: [PATCH 02/24] (refactor) refactor existing derivative functions and populate DerivativeBase class --- .../binance_perpetual_derivative.py | 35 ++++++---------- hummingbot/connector/derivative_base.py | 42 +++++++++++++++++++ .../perpetual_market_making.pyx | 2 +- 3 files changed, 55 insertions(+), 24 deletions(-) diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py index 910f88b93d..5670407a0c 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py @@ -131,10 +131,6 @@ def __init__(self, self._trading_rules_polling_task = None self._last_poll_timestamp = 0 self._throttler = Throttler((10.0, 1.0)) - self._funding_rate = 0 - self._account_positions = {} - self._position_mode = None - self._leverage = 1 @property def name(self) -> str: @@ -408,10 +404,6 @@ async def execute_cancel(self, trading_pair: str, client_order_id: str): OrderCancelledEvent(self.current_timestamp, client_order_id)) return response - # TODO: Implement - async def close_position(self, trading_pair: str): - pass - def quantize_order_amount(self, trading_pair: str, amount: object, price: object = Decimal(0)): trading_rule: TradingRule = self._trading_rules[trading_pair] # current_price: object = self.get_price(trading_pair, False) @@ -883,7 +875,7 @@ async def _update_order_status(self): order_type)) self.stop_tracking_order(client_order_id) - async def _set_margin(self, trading_pair: str, leverage: int = 1): + async def _set_leverage(self, trading_pair: str, leverage: int = 1): params = { "symbol": convert_to_exchange_trading_pair(trading_pair), "leverage": leverage @@ -902,23 +894,16 @@ async def _set_margin(self, trading_pair: str, leverage: int = 1): self.logger().error("Unable to set leverage.") return leverage - def set_margin(self, trading_pair: str, leverage: int = 1): - safe_ensure_future(self._set_margin(trading_pair, leverage)) - - """ - async def get_position_pnl(self, trading_pair: str): - await self._update_positions() - return self._account_positions.get(trading_pair) - """ + def set_leverage(self, trading_pair: str, leverage: int = 1): + safe_ensure_future(self._set_leverage(trading_pair, leverage)) - async def _get_funding_rate(self, trading_pair): - # TODO: Note --- the "premiumIndex" endpoint can get markPrice, indexPrice, and nextFundingTime as well + async def _get_funding_info(self, trading_pair): prem_index = await self.request("/fapi/v1/premiumIndex", params={"symbol": convert_to_exchange_trading_pair(trading_pair)}) - self._funding_rate = Decimal(prem_index.get("lastFundingRate", "0")) + self._funding_info = Decimal(prem_index.get("lastFundingRate", "0")) - def get_funding_rate(self, trading_pair): - safe_ensure_future(self._get_funding_rate(trading_pair)) - return self._funding_rate + def get_funding_info(self, trading_pair): + safe_ensure_future(self._get_funding_info(trading_pair)) + return self._funding_info async def _set_position_mode(self, position_mode: PositionMode): initial_mode = await self._get_position_mode() @@ -941,6 +926,7 @@ async def _set_position_mode(self, position_mode: PositionMode): self.logger().info(f"Using {position_mode.name} position mode.") async def _get_position_mode(self): + # To-do: ensure there's no active order or contract before changing position mode if self._position_mode is None: mode = await self.request( path="/fapi/v1/positionSide/dual", @@ -955,6 +941,9 @@ async def _get_position_mode(self): def set_position_mode(self, position_mode: PositionMode): safe_ensure_future(self._set_position_mode(position_mode)) + def supported_position_modes(self): + return [PositionMode.ONEWAY, PositionMode.HEDGE] + async def request(self, path: str, params: Dict[str, Any] = {}, method: MethodType = MethodType.GET, add_timestamp: bool = False, is_signed: bool = False, request_weight: int = 1, return_err: bool = False): async with self._throttler.weighted_task(request_weight): diff --git a/hummingbot/connector/derivative_base.py b/hummingbot/connector/derivative_base.py index 668d568156..59e4d7b64c 100644 --- a/hummingbot/connector/derivative_base.py +++ b/hummingbot/connector/derivative_base.py @@ -1,5 +1,6 @@ from decimal import Decimal from hummingbot.connector.exchange_base import ExchangeBase +from hummingbot.core.event.events import PositionMode NaN = float("nan") @@ -14,3 +15,44 @@ class DerivativeBase(ExchangeBase): def __init__(self): super().__init__() + self._funding_info = {} + self._account_positions = {} + self._position_mode = None + self._leverage = 1 + + def set_position_mode(self, position_mode: PositionMode): + """ + Should set the _position_mode parameter. i.e self._position_mode = position_mode + This should also be overwritten if the derivative exchange requires interraction to set mode, + in addition to setting the _position_mode object. + :param position_mode: ONEWAY or HEDGE position mode + """ + self._position_mode = position_mode + return + + def set_leverage(self, trading_pair: str, leverage: int = 1): + """ + Should set the _leverage parameter. i.e self._leverage = leverage + This should also be overwritten if the derivative exchange requires interraction to set leverage, + in addition to setting the _leverage object. + :param _leverage: leverage to be used + """ + self._leverage = leverage + return + + def supported_position_modes(self): + """ + returns a list containing the modes supported by the derivative + ONEWAY and/or HEDGE modes + """ + return [PositionMode.ONEWAY] + + def get_funding_info(self, trading_pair): + """ + return a dictionary containing: + "indexPrice": (i.e "21.169488483519444444") + "markPrice": price used for both pnl on most derivatives (i.e "21.210103847902463671") + "nextFundingTime": next funding time in unix timestamp (i.e "1612780270") + "rate": next funding rate as a decimal and not percentage (i.e 0.00007994084744229488) + """ + raise NotImplementedError diff --git a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx index 5eea14483d..5f7b879d21 100644 --- a/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx +++ b/hummingbot/strategy/perpetual_market_making/perpetual_market_making.pyx @@ -555,7 +555,7 @@ cdef class PerpetualMarketMakingStrategy(StrategyBase): cdef c_apply_initial_settings(self, str trading_pair, object position, int64_t leverage): cdef: ExchangeBase market = self._market_info.market - market.set_margin(trading_pair, leverage) + market.set_leverage(trading_pair, leverage) market.set_position_mode(position) cdef c_tick(self, double timestamp): From cf3de6740e7501181cfc01f6bc1d893d2282280b Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 10 Feb 2021 15:13:23 +0100 Subject: [PATCH 03/24] (feat) add derivative features and fix fetch_trading_pair --- .../binance_perpetual_derivative.py | 4 - .../perpetual_finance_derivative.py | 105 ++++++++++++------ .../perpetual_finance_in_flight_order.py | 20 ++-- 3 files changed, 86 insertions(+), 43 deletions(-) diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py index 5670407a0c..b3b132693a 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py @@ -711,11 +711,7 @@ async def _update_balances(self): del self._account_available_balances[asset_name] del self._account_balances[asset_name] - # TODO: Note --- Data Structure Assumes One-way Position Mode [not hedge position mode] (see Binance Futures Docs) - # Note --- Hedge Mode allows for Both Long and Short Positions on a trading pair async def _update_positions(self): - # local_position_names = set(self._account_positions.keys()) - # remote_position_names = set() positions = await self.request(path="/fapi/v2/positionRisk", add_timestamp=True, is_signed=True) for position in positions: trading_pair = position.get("symbol") diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index 181b277da7..03626b6dd0 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -13,6 +13,7 @@ from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather from hummingbot.logger import HummingbotLogger from hummingbot.core.utils.tracking_nonce import get_tracking_nonce +from hummingbot.core.utils.estimate_fee import estimate_fee from hummingbot.core.data_type.limit_order import LimitOrder from hummingbot.core.data_type.cancellation_result import CancellationResult from hummingbot.core.event.events import ( @@ -25,17 +26,15 @@ OrderFilledEvent, OrderType, TradeType, - TradeFee, - # PositionSide, PositionMode, + PositionSide, PositionAction ) from hummingbot.connector.derivative_base import DerivativeBase from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_in_flight_order import PerpetualFinanceInFlightOrder -from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_utils import convert_to_exchange_trading_pair # convert_from_exchange_trading_pair +from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_utils import convert_to_exchange_trading_pair, convert_from_exchange_trading_pair from hummingbot.client.settings import GATEAWAY_CA_CERT_PATH, GATEAWAY_CLIENT_CERT_PATH, GATEAWAY_CLIENT_KEY_PATH -from hummingbot.core.utils.eth_gas_station_lookup import get_gas_price from hummingbot.client.config.global_config_map import global_config_map -# from hummingbot.connector.derivative.position import Position +from hummingbot.connector.derivative.position import Position s_logger = None @@ -51,7 +50,7 @@ class PerpetualFinanceDerivative(DerivativeBase): """ API_CALL_TIMEOUT = 10.0 POLL_INTERVAL = 1.0 - UPDATE_BALANCE_INTERVAL = 30.0 + UPDATE_BALANCE_INTERVAL = 5.0 @classmethod def logger(cls) -> HummingbotLogger: @@ -91,16 +90,38 @@ def __init__(self, def name(self): return "perpetual_finance" - """@staticmethod + @staticmethod async def fetch_trading_pairs() -> List[str]: - resp = await self._api_request("get", "perpfi/get-pairs") - pairs = resp.get("pairs", []) - if len(pairs) == 0: - await self.load_metadata() + ssl_ctx = ssl.create_default_context(cafile=GATEAWAY_CA_CERT_PATH) + ssl_ctx.load_cert_chain(GATEAWAY_CLIENT_CERT_PATH, GATEAWAY_CLIENT_KEY_PATH) + conn = aiohttp.TCPConnector(ssl_context=ssl_ctx) + client = aiohttp.ClientSession(connector=conn) + + base_url = f"https://{global_config_map['gateway_api_host'].value}:" \ + f"{global_config_map['gateway_api_port'].value}/perpfi/" + response = await client.get(base_url + "pairs") + parsed_response = json.loads(await response.text()) + if response.status != 200: + err_msg = "" + if "error" in parsed_response: + err_msg = f" Message: {parsed_response['error']}" + raise IOError(f"Error fetching pairs from gateway. HTTP status is {response.status}.{err_msg}") + pairs = parsed_response.get("pairs", []) + if "error" in parsed_response or len(pairs) == 0: + raise Exception(f"Error: {parsed_response['error']}") + else: + status = await client.get(base_url) + status = json.loads(await status.text()) + loadedMetadata = status["loadedMetadata"] + while (not loadedMetadata): + resp = await client.get(base_url + "load-metadata") + resp = json.loads(await resp.text()) + loadedMetadata = resp.get("loadedMetadata", False) + return PerpetualFinanceDerivative.fetch_trading_pairs() trading_pairs = [] for pair in pairs: trading_pairs.append(convert_from_exchange_trading_pair(pair)) - return trading_pairs""" + return trading_pairs @property def limit_orders(self) -> List[LimitOrder]: @@ -169,7 +190,7 @@ async def get_quote_price(self, trading_pair: str, is_buy: bool, amount: Decimal try: side = "buy" if is_buy else "sell" resp = await self._api_request("post", - "perpfi/get-price", + "perpfi/price", {"side": side, "pair": convert_to_exchange_trading_pair(trading_pair), "amount": amount}) @@ -249,7 +270,6 @@ async def _create_order(self, amount = self.quantize_order_amount(trading_pair, amount) price = self.quantize_order_price(trading_pair, price) base, quote = trading_pair.split("-") - gas_price = get_gas_price() api_params = {"pair": convert_to_exchange_trading_pair(trading_pair)} if position_action == PositionAction.OPEN: api_params.update({"side": 0 if trade_type == TradeType.BUY else 1, @@ -258,18 +278,17 @@ async def _create_order(self, "minBaseAssetAmount": amount}) else: api_params.update({"minimalQuoteAsset": price * amount}) - self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, gas_price) + self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, self._leverage, position_action.name) try: - order_result = await self._api_request("post", f"perpfi/{trade_type.name.lower()}", api_params) + order_result = await self._api_request("post", f"perpfi/{position_action.name.lower()}", api_params) hash = order_result.get("txHash") tracked_order = self._in_flight_orders.get(order_id) if tracked_order is not None: self.logger().info(f"Created {trade_type.name} order {order_id} txHash: {hash} " f"for {amount} {trading_pair}.") tracked_order.update_exchange_order_id(hash) - tracked_order.gas_price = gas_price if hash is not None: - tracked_order.fee_asset = "ETH" + tracked_order.fee_asset = "XDAI" tracked_order.executed_amount_base = amount tracked_order.executed_amount_quote = amount * price event_tag = MarketEvent.BuyOrderCreated if trade_type is TradeType.BUY else MarketEvent.SellOrderCreated @@ -300,7 +319,6 @@ def start_tracking_order(self, trade_type: TradeType, price: Decimal, amount: Decimal, - gas_price: Decimal, leverage: int, position: str,): """ @@ -314,7 +332,6 @@ def start_tracking_order(self, trade_type=trade_type, price=price, amount=amount, - gas_price=gas_price, leverage=leverage, position=position ) @@ -337,7 +354,7 @@ async def _update_order_status(self): for tracked_order in tracked_orders: order_id = await tracked_order.get_exchange_order_id() tasks.append(self._api_request("post", - "eth/get-receipt", + "perpfi/receipt", {"txHash": order_id})) update_results = await safe_gather(*tasks, return_exceptions=True) for update_result in update_results: @@ -349,9 +366,8 @@ async def _update_order_status(self): continue if update_result["confirmed"] is True: if update_result["receipt"]["status"] == 1: - gas_used = update_result["receipt"]["gasUsed"] - gas_price = tracked_order.gas_price - fee = Decimal(str(gas_used)) * Decimal(str(gas_price)) / Decimal(str(1e9)) + fee = estimate_fee("perpetual_finance", False) + fee.flat_fees = [(tracked_order.fee_asset, Decimal(str(update_result["receipt"]["gasUsed"])))] self.trigger_event( MarketEvent.OrderFilled, OrderFilledEvent( @@ -362,7 +378,7 @@ async def _update_order_status(self): tracked_order.order_type, Decimal(str(tracked_order.price)), Decimal(str(tracked_order.amount)), - TradeFee(0.0, [(tracked_order.fee_asset, Decimal(str(fee)))]), + fee, exchange_trade_id=order_id ) ) @@ -413,8 +429,7 @@ def has_allowances(self) -> bool: """ Checks if all tokens have allowance (an amount approved) """ - return len(self._allowances.values()) == len(self._token_addresses.values()) and \ - all(amount > s_decimal_0 for amount in self._allowances.values()) + return all(amount > s_decimal_0 for amount in self._allowances.values()) @property def status_dict(self) -> Dict[str, bool]: @@ -485,12 +500,12 @@ async def _update_balances(self): self._last_balance_poll_timestamp = current_tick local_asset_names = set(self._account_balances.keys()) remote_asset_names = set() - resp_json = await self._api_request("post", "perpfi/balances") - for token, bal in resp_json["balances"].items(): - if len(token) > 4: - token = self.get_token(token) + balances_resp = await self._api_request("post", "perpfi/balances") + margin_resp = await self._api_request("post", "perpfi/margin") + for token, bal in balances_resp["balances"].items(): self._account_available_balances[token] = Decimal(str(bal)) - self._account_balances[token] = Decimal(str(bal)) + self._account_balances[token] = Decimal(str(bal)) + Decimal(str(margin_resp["margin"])) if token == "USDC" \ + else Decimal(str(bal)) remote_asset_names.add(token) asset_names_to_remove = local_asset_names.difference(remote_asset_names) @@ -501,6 +516,32 @@ async def _update_balances(self): self._in_flight_orders_snapshot = {k: copy.copy(v) for k, v in self._in_flight_orders.items()} self._in_flight_orders_snapshot_timestamp = self.current_timestamp + async def _update_positions(self): + tasks = [] + for pair in self._trading_pairs: + tasks.append(self._api_request("post", + "perpfi/position", + {"pair": convert_to_exchange_trading_pair(pair)})) + positions = await safe_gather(*tasks, return_exceptions=True) + for trading_pair, position in zip(self._trading_pairs, positions.get("position", {})): + position_side = PositionSide.LONG if position.get("size", 0) > 0 else PositionSide.SHORT + unrealized_pnl = Decimal(position.get("pnl")) + entry_price = Decimal(position.get("entryPrice")) + amount = Decimal(position.get("size")) + leverage = self._leverage + if amount != 0: + self._account_positions[trading_pair + position_side.name] = Position( + trading_pair=trading_pair, + position_side=position_side, + unrealized_pnl=unrealized_pnl, + entry_price=entry_price, + amount=amount, + leverage=leverage + ) + else: + if (trading_pair + position_side.name) in self._account_positions: + del self._account_positions[trading_pair + position_side.name] + async def _http_client(self) -> aiohttp.ClientSession: """ :returns Shared client session instance diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py index d7dcc0fc1f..3dbaad3128 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py @@ -18,7 +18,6 @@ def __init__(self, trade_type: TradeType, price: Decimal, amount: Decimal, - gas_price: Decimal, leverage: int, position: str, initial_state: str = "OPEN"): @@ -33,7 +32,6 @@ def __init__(self, initial_state, ) self.trade_id_set = set() - self._gas_price = gas_price self.leverage = leverage self.position = position @@ -50,9 +48,17 @@ def is_cancelled(self) -> bool: return self.last_state in {"CANCELED", "EXPIRED"} @property - def gas_price(self) -> Decimal: - return self._gas_price + def leverage(self) -> Decimal: + return self.leverage - @gas_price.setter - def gas_price(self, gas_price) -> Decimal: - self._gas_price = gas_price + @leverage.setter + def leverage(self, leverage) -> Decimal: + self.leverage = leverage + + @property + def position(self) -> Decimal: + return self.position + + @position.setter + def position(self, position) -> Decimal: + self.position = position From d60a9b3831b9af621787436756e2c1a8af63e9c6 Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 15 Feb 2021 14:26:42 +0100 Subject: [PATCH 04/24] (feat) add _funding_payment_span to derivative connectors --- .../derivative/binance_perpetual/binance_perpetual_derivative.py | 1 + .../derivative/perpetual_finance/perpetual_finance_derivative.py | 1 + hummingbot/connector/derivative_base.py | 1 + 3 files changed, 3 insertions(+) diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py index b3b132693a..6e285a8d99 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py @@ -131,6 +131,7 @@ def __init__(self, self._trading_rules_polling_task = None self._last_poll_timestamp = 0 self._throttler = Throttler((10.0, 1.0)) + self._funding_payment_span = [0, 15] @property def name(self) -> str: diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index 03626b6dd0..7e86a486d5 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -85,6 +85,7 @@ def __init__(self, self._auto_approve_task = None self._real_time_balance_update = False self._poll_notifier = None + self._funding_payment_span = [1800, 0] @property def name(self): diff --git a/hummingbot/connector/derivative_base.py b/hummingbot/connector/derivative_base.py index 59e4d7b64c..ff4750017a 100644 --- a/hummingbot/connector/derivative_base.py +++ b/hummingbot/connector/derivative_base.py @@ -19,6 +19,7 @@ def __init__(self): self._account_positions = {} self._position_mode = None self._leverage = 1 + self._funding_payment_span = [0, 0] # time span(in seconds) before and after funding period when exchanges consider active positions eligible for funding payment def set_position_mode(self, position_mode: PositionMode): """ From 9801e416eb8b1c1da52114ccb825bef964604c14 Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 17 Feb 2021 15:39:41 +0100 Subject: [PATCH 05/24] (feat) add FundingPaymentCompletedEvent --- hummingbot/connector/markets_recorder.py | 30 ++++++++- hummingbot/core/event/events.py | 8 +++ hummingbot/model/funding_payment.py | 85 ++++++++++++++++++++++++ 3 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 hummingbot/model/funding_payment.py diff --git a/hummingbot/connector/markets_recorder.py b/hummingbot/connector/markets_recorder.py index 1b35fe333b..63fb3a1c93 100644 --- a/hummingbot/connector/markets_recorder.py +++ b/hummingbot/connector/markets_recorder.py @@ -27,6 +27,7 @@ MarketOrderFailureEvent, OrderCancelledEvent, OrderExpiredEvent, + FundingPaymentCompletedEvent, MarketEvent, TradeFee ) @@ -38,6 +39,7 @@ from hummingbot.model.order_status import OrderStatus from hummingbot.model.sql_connection_manager import SQLConnectionManager from hummingbot.model.trade_fill import TradeFill +from hummingbot.model.funding_payment import FundingPayment class MarketsRecorder: @@ -75,6 +77,7 @@ def __init__(self, self._fail_order_forwarder: SourceInfoEventForwarder = SourceInfoEventForwarder(self._did_fail_order) self._complete_order_forwarder: SourceInfoEventForwarder = SourceInfoEventForwarder(self._did_complete_order) self._expire_order_forwarder: SourceInfoEventForwarder = SourceInfoEventForwarder(self._did_expire_order) + self._funding_payment_forwarder: SourceInfoEventForwarder = SourceInfoEventForwarder(self._did_complete_funding_payment) self._event_pairs: List[Tuple[MarketEvent, SourceInfoEventForwarder]] = [ (MarketEvent.BuyOrderCreated, self._create_order_forwarder), @@ -84,7 +87,8 @@ def __init__(self, (MarketEvent.OrderFailure, self._fail_order_forwarder), (MarketEvent.BuyOrderCompleted, self._complete_order_forwarder), (MarketEvent.SellOrderCompleted, self._complete_order_forwarder), - (MarketEvent.OrderExpired, self._expire_order_forwarder) + (MarketEvent.OrderExpired, self._expire_order_forwarder), + (MarketEvent.FundingPaymentCompleted, self._funding_payment_forwarder) ] @property @@ -263,6 +267,30 @@ def _did_fill_order(self, market.add_trade_fills_from_market_recorder({TradeFillOrderDetails(trade_fill_record.market, trade_fill_record.exchange_trade_id, trade_fill_record.symbol)}) self.append_to_csv(trade_fill_record) + def _did_complete_funding_payment(self, + event_tag: int, + market: ConnectorBase, + evt: FundingPaymentCompletedEvent): + if threading.current_thread() != threading.main_thread(): + self._ev_loop.call_soon_threadsafe(self._did_complete_funding_payment, event_tag, market, evt) + return + + session: Session = self.session + timestamp: float = evt.timestamp + + # Try to find the funding payment has been recorded already. + payment_record: Optional[FundingPayment] = session.query(FundingPayment).filter(FundingPayment.timestamp == timestamp).one_or_none() + if payment_record is None: + funding_payment_record: FundingPayment = FundingPayment(timestamp=timestamp, + config_file_path=self.config_file_path, + market=market.display_name, + rate=evt.funding_rate, + symbol=evt.trading_pair, + amount=float(evt.amount)) + session.add(funding_payment_record) + session.commit() + # self.append_to_csv(funding_payment_record) + @staticmethod def _is_primitive_type(obj: object) -> bool: return not hasattr(obj, '__dict__') diff --git a/hummingbot/core/event/events.py b/hummingbot/core/event/events.py index 77a41a9689..a085e2ba32 100644 --- a/hummingbot/core/event/events.py +++ b/hummingbot/core/event/events.py @@ -203,6 +203,14 @@ class OrderExpiredEvent(NamedTuple): order_id: str +@dataclass +class FundingPaymentCompletedEvent: + timestamp: float + trading_pair: str + amount: Decimal + funding_rate: Decimal + + class MarketWithdrawAssetEvent(NamedTuple): timestamp: float tracking_id: str diff --git a/hummingbot/model/funding_payment.py b/hummingbot/model/funding_payment.py new file mode 100644 index 0000000000..99faa771a6 --- /dev/null +++ b/hummingbot/model/funding_payment.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python +import pandas as pd +from typing import ( + List, + Optional, +) +from sqlalchemy import ( + Column, + Text, + Index, + BigInteger, + Float, +) +from sqlalchemy.orm import ( + Session +) +from datetime import datetime + +from . import HummingbotBase + + +class FundingPayment(HummingbotBase): + __tablename__ = "FundingPayment" + __table_args__ = (Index("tf_config_timestamp_index", + "config_file_path", "timestamp"), + Index("tf_market_trading_pair_timestamp_index", + "market", "symbol", "timestamp") + ) + + timestamp = Column(BigInteger, primary_key=True, nullable=False) + config_file_path = Column(Text, nullable=False) + market = Column(Text, nullable=False) + rate = Column(Float, nullable=False) + symbol = Column(Text, nullable=False) + amount = Column(Float, nullable=False) + + def __repr__(self) -> str: + return f"FundingPayment(timestamp={self.timestamp}, config_file_path='{self.config_file_path}', " \ + f"market='{self.market}', rate='{self.rate}' symbol='{self.symbol}', amount={self.amount}" + + @staticmethod + def get_funding_payments(sql_session: Session, + timestamp: str = None, + market: str = None, + trading_pair: str = None, + ) -> Optional[List["FundingPayment"]]: + filters = [] + if timestamp is not None: + filters.append(FundingPayment.timestamp == timestamp) + if market is not None: + filters.append(FundingPayment.market == market) + if trading_pair is not None: + filters.append(FundingPayment.symbol == trading_pair) + + payments: Optional[List[FundingPayment]] = (sql_session + .query(FundingPayment) + .filter(*filters) + .order_by(FundingPayment.timestamp.asc()) + .all()) + return payments + + @classmethod + def to_pandas(cls, payments: List): + columns: List[str] = ["Index", + "Timestamp", + "Exchange", + "Market", + "Rate", + "Amount"] + data = [] + index = 0 + for payment in payments: + index += 1 + data.append([ + index, + datetime.fromtimestamp(int(payment.timestamp / 1e3)).strftime("%Y-%m-%d %H:%M:%S"), + payment.market, + payment.rate, + payment.symbol, + payment.amount + ]) + df = pd.DataFrame(data=data, columns=columns) + df.set_index('Index', inplace=True) + + return df From 012bb8ca52ac079479377b7acf4c088cbf27bdda Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 17 Feb 2021 18:49:30 +0100 Subject: [PATCH 06/24] (feat) add funding event listener in derivative connectors --- .../binance_perpetual_derivative.py | 19 +++--- .../perpetual_finance_derivative.py | 60 +++++++++++++++---- hummingbot/core/event/events.py | 1 + hummingbot/strategy/strategy_base.pxd | 1 + hummingbot/strategy/strategy_base.pyx | 12 ++++ hummingbot/strategy/strategy_py_base.pyx | 6 ++ 6 files changed, 77 insertions(+), 22 deletions(-) diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py index 6e285a8d99..ecb6d777a3 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py @@ -248,7 +248,7 @@ async def create_order(self, else: api_params["positionSide"] = "SHORT" if trade_type is TradeType.BUY else "LONG" - self.start_tracking_order(order_id, "", trading_pair, trade_type, price, amount, order_type, self._leverage, position_action.name) + self.start_tracking_order(order_id, "", trading_pair, trade_type, price, amount, order_type, self._leverage[trading_pair], position_action.name) try: order_result = await self.request(path="/fapi/v1/order", @@ -273,7 +273,7 @@ async def create_order(self, amount, price, order_id, - leverage=self._leverage, + leverage=self._leverage[trading_pair], position=position_action.name)) return order_result except asyncio.CancelledError: @@ -495,7 +495,7 @@ async def _user_stream_event_listener(self): order_type=OrderType.LIMIT if order_message.get("o") == "LIMIT" else OrderType.MARKET, price=Decimal(order_message.get("L")), amount=Decimal(order_message.get("l")), - leverage=self._leverage, + leverage=self._leverage[convert_from_exchange_trading_pair(order_message.get("s"))], trade_fee=self.get_fee( base_currency=tracked_order.base_asset, quote_currency=tracked_order.quote_asset, @@ -786,7 +786,7 @@ async def _update_order_fills_from_trades(self): Decimal(trade["price"]), Decimal(trade["qty"])), exchange_trade_id=trade["id"], - leverage=self._leverage, + leverage=self._leverage[tracked_order.trading_pair], position=tracked_order.position ) ) @@ -885,8 +885,8 @@ async def _set_leverage(self, trading_pair: str, leverage: int = 1): is_signed=True ) if set_leverage["leverage"] == leverage: - self._leverage = leverage - self.logger().info(f"Leverage Successfully set to {leverage}.") + self._leverage[trading_pair] = leverage + self.logger().info(f"Leverage Successfully set to {leverage} for {trading_pair}.") else: self.logger().error("Unable to set leverage.") return leverage @@ -894,13 +894,12 @@ async def _set_leverage(self, trading_pair: str, leverage: int = 1): def set_leverage(self, trading_pair: str, leverage: int = 1): safe_ensure_future(self._set_leverage(trading_pair, leverage)) - async def _get_funding_info(self, trading_pair): + """async def _get_funding_info(self): prem_index = await self.request("/fapi/v1/premiumIndex", params={"symbol": convert_to_exchange_trading_pair(trading_pair)}) - self._funding_info = Decimal(prem_index.get("lastFundingRate", "0")) + self._funding_info = Decimal(prem_index.get("lastFundingRate", "0"))""" def get_funding_info(self, trading_pair): - safe_ensure_future(self._get_funding_info(trading_pair)) - return self._funding_info + return self._funding_info[trading_pair] async def _set_position_mode(self, position_mode: PositionMode): initial_mode = await self._get_position_mode() diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index 7e86a486d5..e8b8716aac 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -23,6 +23,7 @@ BuyOrderCompletedEvent, SellOrderCompletedEvent, MarketOrderFailureEvent, + FundingPaymentCompletedEvent, OrderFilledEvent, OrderType, TradeType, @@ -274,12 +275,12 @@ async def _create_order(self, api_params = {"pair": convert_to_exchange_trading_pair(trading_pair)} if position_action == PositionAction.OPEN: api_params.update({"side": 0 if trade_type == TradeType.BUY else 1, - "margin": str(amount / self._leverage), - "leverage": self._leverage, + "margin": str(amount / self._leverage[trading_pair]), + "leverage": self._leverage[trading_pair], "minBaseAssetAmount": amount}) else: api_params.update({"minimalQuoteAsset": price * amount}) - self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, self._leverage, position_action.name) + self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, self._leverage[trading_pair], position_action.name) try: order_result = await self._api_request("post", f"perpfi/{position_action.name.lower()}", api_params) hash = order_result.get("txHash") @@ -295,7 +296,8 @@ async def _create_order(self, event_tag = MarketEvent.BuyOrderCreated if trade_type is TradeType.BUY else MarketEvent.SellOrderCreated event_class = BuyOrderCreatedEvent if trade_type is TradeType.BUY else SellOrderCreatedEvent self.trigger_event(event_tag, event_class(self.current_timestamp, OrderType.LIMIT, trading_pair, amount, - price, order_id, hash)) + price, order_id, hash, leverage=self._leverage[trading_pair], + position=position_action.name)) else: self.trigger_event(MarketEvent.OrderFailure, MarketOrderFailureEvent(self.current_timestamp, order_id, OrderType.LIMIT)) @@ -380,7 +382,9 @@ async def _update_order_status(self): Decimal(str(tracked_order.price)), Decimal(str(tracked_order.amount)), fee, - exchange_trade_id=order_id + exchange_trade_id=order_id, + leverage=self._leverage[tracked_order.trading_pair], + position=tracked_order.position ) ) tracked_order.last_state = "FILLED" @@ -518,18 +522,29 @@ async def _update_balances(self): self._in_flight_orders_snapshot_timestamp = self.current_timestamp async def _update_positions(self): - tasks = [] + position_tasks = [] + funding_payment_tasks = [] + funding_info_tasks = [] for pair in self._trading_pairs: - tasks.append(self._api_request("post", - "perpfi/position", - {"pair": convert_to_exchange_trading_pair(pair)})) - positions = await safe_gather(*tasks, return_exceptions=True) - for trading_pair, position in zip(self._trading_pairs, positions.get("position", {})): + position_tasks.append(self._api_request("post", + "perpfi/position", + {"pair": convert_to_exchange_trading_pair(pair)})) + funding_payment_tasks.append(self._api_request("get", + "perpfi/funding_payment", + {"pair": convert_to_exchange_trading_pair(pair)})) + funding_info_tasks.append(self._api_request("get", + "perpfi/funding", + {"pair": convert_to_exchange_trading_pair(pair)})) + positions = await safe_gather(*position_tasks, return_exceptions=True) + funding_payments = await safe_gather(*funding_payment_tasks, return_exceptions=True) + funding_infos = await safe_gather(*funding_info_tasks, return_exceptions=True) + for trading_pair, position in zip(self._trading_pairs, positions): + position = position.get("position", {}) position_side = PositionSide.LONG if position.get("size", 0) > 0 else PositionSide.SHORT unrealized_pnl = Decimal(position.get("pnl")) entry_price = Decimal(position.get("entryPrice")) amount = Decimal(position.get("size")) - leverage = self._leverage + leverage = self._leverage[trading_pair] if amount != 0: self._account_positions[trading_pair + position_side.name] = Position( trading_pair=trading_pair, @@ -543,6 +558,27 @@ async def _update_positions(self): if (trading_pair + position_side.name) in self._account_positions: del self._account_positions[trading_pair + position_side.name] + for trading_pair, funding_payment in zip(self._trading_pairs, funding_payments): + payment = Decimal(str(funding_payment.payment)) + action = "paid" if payment < 0 else "received" + if payment != Decimal("0"): + self.logger().info(f"Funding payment of {payment} {action} on {trading_pair} market.") + self.trigger_event(MarketEvent.FundingPaymentCompleted, + FundingPaymentCompletedEvent(timestamp=funding_payment.timestamp, + market=self.name, + rate=self._funding_info["rate"], + symbol=trading_pair, + amount=payment)) + + for trading_pair, funding_info in zip(self._trading_pairs, funding_infos): + self._funding_info[trading_pair] = funding_info["fr"] + + def get_funding_info(self, trading_pair): + return self._funding_info[trading_pair] + + def set_leverage(self, trading_pair: str, leverage: int = 1): + self._leverage[trading_pair] = leverage + async def _http_client(self) -> aiohttp.ClientSession: """ :returns Shared client session instance diff --git a/hummingbot/core/event/events.py b/hummingbot/core/event/events.py index a085e2ba32..c4a02d1e76 100644 --- a/hummingbot/core/event/events.py +++ b/hummingbot/core/event/events.py @@ -34,6 +34,7 @@ class MarketEvent(Enum): TransactionFailure = 199 BuyOrderCreated = 200 SellOrderCreated = 201 + FundingPaymentCompleted = 202 class NewBlocksWatcherEvent(Enum): diff --git a/hummingbot/strategy/strategy_base.pxd b/hummingbot/strategy/strategy_base.pxd index 2ed151b74e..efb35d8b09 100644 --- a/hummingbot/strategy/strategy_base.pxd +++ b/hummingbot/strategy/strategy_base.pxd @@ -29,6 +29,7 @@ cdef class StrategyBase(TimeIterator): cdef c_did_expire_order(self, object expired_event) cdef c_did_complete_buy_order(self, object order_completed_event) cdef c_did_complete_sell_order(self, object order_completed_event) + cdef c_did_complete_funding_payment(self, object funding_payment_completed_event) cdef c_did_fail_order_tracker(self, object order_failed_event) cdef c_did_cancel_order_tracker(self, object order_cancelled_event) diff --git a/hummingbot/strategy/strategy_base.pyx b/hummingbot/strategy/strategy_base.pyx index 4bc661693c..d214b288e8 100755 --- a/hummingbot/strategy/strategy_base.pyx +++ b/hummingbot/strategy/strategy_base.pyx @@ -46,6 +46,11 @@ cdef class SellOrderCompletedListener(BaseStrategyEventListener): self._owner.c_did_complete_sell_order_tracker(arg) +cdef class FundingPaymentCompletedListener(BaseStrategyEventListener): + cdef c_call(self, object arg): + self._owner.c_did_complete_funding_payment(arg) + + cdef class OrderFilledListener(BaseStrategyEventListener): cdef c_call(self, object arg): self._owner.c_did_fill_order(arg) @@ -83,6 +88,7 @@ cdef class SellOrderCreatedListener(BaseStrategyEventListener): cdef class StrategyBase(TimeIterator): BUY_ORDER_COMPLETED_EVENT_TAG = MarketEvent.BuyOrderCompleted.value SELL_ORDER_COMPLETED_EVENT_TAG = MarketEvent.SellOrderCompleted.value + FUNDING_PAYMENT_COMPLETED_EVENT_TAG = MarketEvent.FundingPaymentCompleted.value ORDER_FILLED_EVENT_TAG = MarketEvent.OrderFilled.value ORDER_CANCELLED_EVENT_TAG = MarketEvent.OrderCancelled.value ORDER_EXPIRED_EVENT_TAG = MarketEvent.OrderExpired.value @@ -105,6 +111,7 @@ cdef class StrategyBase(TimeIterator): self._sb_expire_order_listener = OrderExpiredListener(self) self._sb_complete_buy_order_listener = BuyOrderCompletedListener(self) self._sb_complete_sell_order_listener = SellOrderCompletedListener(self) + self._sb_complete_funding_payment_listener = FundingPaymentCompletedListener(self) self._sb_delegate_lock = False @@ -255,6 +262,7 @@ cdef class StrategyBase(TimeIterator): typed_market.c_add_listener(self.ORDER_EXPIRED_EVENT_TAG, self._sb_expire_order_listener) typed_market.c_add_listener(self.BUY_ORDER_COMPLETED_EVENT_TAG, self._sb_complete_buy_order_listener) typed_market.c_add_listener(self.SELL_ORDER_COMPLETED_EVENT_TAG, self._sb_complete_sell_order_listener) + typed_market.c_add_listener(self.FUNDING_PAYMENT_COMPLETED_EVENT_TAG, self._sb_complete_funding_payment_listener) self._sb_markets.add(typed_market) cdef c_remove_markets(self, list markets): @@ -273,6 +281,7 @@ cdef class StrategyBase(TimeIterator): typed_market.c_remove_listener(self.ORDER_EXPIRED_EVENT_TAG, self._sb_expire_order_listener) typed_market.c_remove_listener(self.BUY_ORDER_COMPLETED_EVENT_TAG, self._sb_complete_buy_order_listener) typed_market.c_remove_listener(self.SELL_ORDER_COMPLETED_EVENT_TAG, self._sb_complete_sell_order_listener) + typed_market.c_remove_listener(self.FUNDING_PAYMENT_COMPLETED_EVENT_TAG, self._sb_complete_funding_payment_listener) self._sb_markets.remove(typed_market) cdef object c_sum_flat_fees(self, str quote_asset, list flat_fees): @@ -317,6 +326,9 @@ cdef class StrategyBase(TimeIterator): cdef c_did_complete_sell_order(self, object order_completed_event): pass + + cdef c_did_complete_funding_payment(self, object funding_payment_completed_event): + pass # ---------------------------------------------------------------------------------------------------------- # diff --git a/hummingbot/strategy/strategy_py_base.pyx b/hummingbot/strategy/strategy_py_base.pyx index 6d846e5eab..9b8546a8cb 100644 --- a/hummingbot/strategy/strategy_py_base.pyx +++ b/hummingbot/strategy/strategy_py_base.pyx @@ -89,3 +89,9 @@ cdef class StrategyPyBase(StrategyBase): def did_complete_sell_order(self, order_completed_event): pass + + cdef c_did_complete_funding_payment(self, object funding_payment_completed_event): + self.did_complete_funding_payment(funding_payment_completed_event) + + def did_complete_funding_payment(self, funding_payment_completed_event): + pass From bf2302a2bc3bf9528bfd76148261283a08d78e9d Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 17 Feb 2021 20:30:24 +0100 Subject: [PATCH 07/24] (feat) update binance perp connector --- .../binance_perpetual_derivative.py | 102 ++++++++++++++---- .../perpetual_finance_derivative.py | 2 +- hummingbot/connector/derivative_base.py | 4 +- 3 files changed, 86 insertions(+), 22 deletions(-) diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py index ecb6d777a3..f2d4359d35 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py @@ -21,6 +21,9 @@ import hmac import time import logging +import ujson +import websockets +from websockets.exceptions import ConnectionClosed from decimal import Decimal from typing import Optional, List, Dict, Any, AsyncIterable from urllib.parse import urlencode @@ -37,6 +40,7 @@ BuyOrderCompletedEvent, BuyOrderCreatedEvent, SellOrderCreatedEvent, + FundingPaymentCompletedEvent, OrderFilledEvent, SellOrderCompletedEvent, PositionSide, PositionMode, PositionAction) from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather @@ -86,6 +90,7 @@ class BinancePerpetualDerivative(DerivativeBase): MARKET_ORDER_FILLED_EVENT_TAG = MarketEvent.OrderFilled MARKET_BUY_ORDER_CREATED_EVENT_TAG = MarketEvent.BuyOrderCreated MARKET_SELL_ORDER_CREATED_EVENT_TAG = MarketEvent.SellOrderCreated + MARKET_FUNDING_PAYMENT_COMPLETED_EVENT_TAG = MarketEvent.FundingPaymentCompleted API_CALL_TIMEOUT = 10.0 SHORT_POLL_INTERVAL = 5.0 @@ -129,6 +134,7 @@ def __init__(self, self._status_polling_task = None self._user_stream_event_listener_task = None self._trading_rules_polling_task = None + self._funding_info_polling_task = None self._last_poll_timestamp = 0 self._throttler = Throttler((10.0, 1.0)) self._funding_payment_span = [0, 15] @@ -173,6 +179,7 @@ def stop(self, clock: Clock): async def start_network(self): self._order_book_tracker.start() self._trading_rules_polling_task = safe_ensure_future(self._trading_rules_polling_loop()) + self._funding_info_polling_task = safe_ensure_future(self._funding_info_polling_loop()) if self._trading_required: self._status_polling_task = safe_ensure_future(self._status_polling_loop()) self._user_stream_tracker_task = safe_ensure_future(self._user_stream_tracker.start()) @@ -188,8 +195,10 @@ def _stop_network(self): self._user_stream_event_listener_task.cancel() if self._trading_rules_polling_task is not None: self._trading_rules_polling_task.cancel() + if self._funding_info_polling_task is not None: + self._funding_info_polling_task.cancel() self._status_polling_task = self._user_stream_tracker_task = \ - self._user_stream_event_listener_task = None + self._user_stream_event_listener_task = self._funding_info_polling_task = None async def stop_network(self): self._stop_network() @@ -548,22 +557,26 @@ async def _user_stream_event_listener(self): self.stop_tracking_order(tracked_order.client_order_id) elif event_type == "ACCOUNT_UPDATE": update_data = event_message.get("a", {}) - # update balances - for asset in update_data.get("B", []): - asset_name = asset["a"] - self._account_balances[asset_name] = Decimal(asset["wb"]) - self._account_available_balances[asset_name] = Decimal(asset["cw"]) - - # update position - for asset in update_data.get("P", []): - position = self._account_positions.get(f"{asset['s']}{asset['ps']}", None) - if position is not None: - position.update_position(position_side=PositionSide[asset["ps"]], - unrealized_pnl = Decimal(asset["up"]), - entry_price = Decimal(asset["ep"]), - amount = Decimal(asset["pa"])) - else: - await self._update_positions() + event_reason = update_data.get("m", {}) + if event_reason == "FUNDING_FEE": + await self.get_funding_payment(event_message.get("E", int(time.time()))) + else: + # update balances + for asset in update_data.get("B", []): + asset_name = asset["a"] + self._account_balances[asset_name] = Decimal(asset["wb"]) + self._account_available_balances[asset_name] = Decimal(asset["cw"]) + + # update position + for asset in update_data.get("P", []): + position = self._account_positions.get(f"{asset['s']}{asset['ps']}", None) + if position is not None: + position.update_position(position_side=PositionSide[asset["ps"]], + unrealized_pnl = Decimal(asset["up"]), + entry_price = Decimal(asset["ep"]), + amount = Decimal(asset["pa"])) + else: + await self._update_positions() elif event_type == "MARGIN_CALL": positions = event_message.get("p", []) total_maint_margin_required = 0 @@ -674,6 +687,37 @@ async def _trading_rules_polling_loop(self): "Check network connection.") await asyncio.sleep(0.5) + async def _funding_info_polling_loop(self): + while True: + try: + ws_subscription_path: str = "/".join([f"{convert_to_exchange_trading_pair(trading_pair).lower()}@markPrice" + for trading_pair in self._trading_pairs]) + stream_url: str = f"{self._stream_url}?streams={ws_subscription_path}" + async with websockets.connect(stream_url) as ws: + ws: websockets.WebSocketClientProtocol = ws + try: + while True: + try: + raw_msg: str = await asyncio.wait_for(ws.recv(), timeout=10.0) + msg = ujson.loads(raw_msg) + trading_pair = msg["s"] + self._funding_info[trading_pair] = {"indexPrice": msg["i"], + "markPrice": msg["p"], + "nextFundingTime": msg["T"], + "rate": msg["r"]} + except asyncio.TimeoutError: + await ws.pong(data=b'') + except ConnectionClosed: + continue + finally: + await ws.close() + except asyncio.CancelledError: + raise + except Exception: + self.logger().error("Unexpected error updating funding info. Retrying after 10 seconds... ", + exc_info=True) + await asyncio.sleep(10.0) + async def _status_polling_loop(self): while True: try: @@ -894,9 +938,27 @@ async def _set_leverage(self, trading_pair: str, leverage: int = 1): def set_leverage(self, trading_pair: str, leverage: int = 1): safe_ensure_future(self._set_leverage(trading_pair, leverage)) - """async def _get_funding_info(self): - prem_index = await self.request("/fapi/v1/premiumIndex", params={"symbol": convert_to_exchange_trading_pair(trading_pair)}) - self._funding_info = Decimal(prem_index.get("lastFundingRate", "0"))""" + async def get_funding_payment(self): + funding_payment_tasks = [] + for pair in self._trading_pairs: + funding_payment_tasks.append(self.request(path="/fapi/v1/income", + params={"symbol": convert_to_exchange_trading_pair(pair), "incomeType": "FUNDING_FEE", "limit": 1}, + method=MethodType.POST, + add_timestamp=True, + is_signed=True)) + funding_payments = await safe_gather(*funding_payment_tasks, return_exceptions=True) + for funding_payment in funding_payments: + payment = Decimal(funding_payment["income"]) + action = "paid" if payment < 0 else "received" + trading_pair = convert_to_exchange_trading_pair(funding_payment["symbol"]) + if payment != Decimal("0"): + self.logger().info(f"Funding payment of {payment} {action} on {trading_pair} market.") + self.trigger_event(self.MARKET_FUNDING_PAYMENT_COMPLETED_EVENT_TAG, + FundingPaymentCompletedEvent(timestamp=funding_payment["time"], + market=self.name, + rate=self._funding_info[trading_pair]["rate"], + symbol=trading_pair, + amount=payment)) def get_funding_info(self, trading_pair): return self._funding_info[trading_pair] diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index e8b8716aac..71bb2ffad7 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -566,7 +566,7 @@ async def _update_positions(self): self.trigger_event(MarketEvent.FundingPaymentCompleted, FundingPaymentCompletedEvent(timestamp=funding_payment.timestamp, market=self.name, - rate=self._funding_info["rate"], + rate=self._funding_info[trading_pair]["rate"], symbol=trading_pair, amount=payment)) diff --git a/hummingbot/connector/derivative_base.py b/hummingbot/connector/derivative_base.py index ff4750017a..5bd2667e33 100644 --- a/hummingbot/connector/derivative_base.py +++ b/hummingbot/connector/derivative_base.py @@ -50,10 +50,12 @@ def supported_position_modes(self): def get_funding_info(self, trading_pair): """ - return a dictionary containing: + return a dictionary as follows: + self._trading_info[trading_pair] = { "indexPrice": (i.e "21.169488483519444444") "markPrice": price used for both pnl on most derivatives (i.e "21.210103847902463671") "nextFundingTime": next funding time in unix timestamp (i.e "1612780270") "rate": next funding rate as a decimal and not percentage (i.e 0.00007994084744229488) + } """ raise NotImplementedError From 1c932e86de3324abdf80824496d74e710f61ba83 Mon Sep 17 00:00:00 2001 From: vic-en Date: Thu, 18 Feb 2021 19:54:01 +0100 Subject: [PATCH 08/24] (feat) refactor fetch_trading_pairs to *_api_order_book.py file --- ...tual_finance_api_order_book_data_source.py | 43 +++++++++++++++++++ .../perpetual_finance_derivative.py | 35 +-------------- 2 files changed, 44 insertions(+), 34 deletions(-) create mode 100644 hummingbot/connector/derivative/perpetual_finance/perpetual_finance_api_order_book_data_source.py diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_api_order_book_data_source.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_api_order_book_data_source.py new file mode 100644 index 0000000000..4abc57b74c --- /dev/null +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_api_order_book_data_source.py @@ -0,0 +1,43 @@ +import aiohttp +from typing import List +import json +import ssl + +from hummingbot.client.config.global_config_map import global_config_map +from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_utils import convert_from_exchange_trading_pair +from hummingbot.client.settings import GATEAWAY_CA_CERT_PATH, GATEAWAY_CLIENT_CERT_PATH, GATEAWAY_CLIENT_KEY_PATH + + +class PerpetualFinanceAPIOrderBookDataSource: + @staticmethod + async def fetch_trading_pairs() -> List[str]: + ssl_ctx = ssl.create_default_context(cafile=GATEAWAY_CA_CERT_PATH) + ssl_ctx.load_cert_chain(GATEAWAY_CLIENT_CERT_PATH, GATEAWAY_CLIENT_KEY_PATH) + conn = aiohttp.TCPConnector(ssl_context=ssl_ctx) + client = aiohttp.ClientSession(connector=conn) + + base_url = f"https://{global_config_map['gateway_api_host'].value}:" \ + f"{global_config_map['gateway_api_port'].value}/perpfi/" + response = await client.get(base_url + "pairs") + parsed_response = json.loads(await response.text()) + if response.status != 200: + err_msg = "" + if "error" in parsed_response: + err_msg = f" Message: {parsed_response['error']}" + raise IOError(f"Error fetching pairs from gateway. HTTP status is {response.status}.{err_msg}") + pairs = parsed_response.get("pairs", []) + if "error" in parsed_response or len(pairs) == 0: + raise Exception(f"Error: {parsed_response['error']}") + else: + status = await client.get(base_url) + status = json.loads(await status.text()) + loadedMetadata = status["loadedMetadata"] + while (not loadedMetadata): + resp = await client.get(base_url + "load-metadata") + resp = json.loads(await resp.text()) + loadedMetadata = resp.get("loadedMetadata", False) + return PerpetualFinanceAPIOrderBookDataSource.fetch_trading_pairs() + trading_pairs = [] + for pair in pairs: + trading_pairs.append(convert_from_exchange_trading_pair(pair)) + return trading_pairs diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index 71bb2ffad7..9323d76967 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -32,7 +32,7 @@ ) from hummingbot.connector.derivative_base import DerivativeBase from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_in_flight_order import PerpetualFinanceInFlightOrder -from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_utils import convert_to_exchange_trading_pair, convert_from_exchange_trading_pair +from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_utils import convert_to_exchange_trading_pair from hummingbot.client.settings import GATEAWAY_CA_CERT_PATH, GATEAWAY_CLIENT_CERT_PATH, GATEAWAY_CLIENT_KEY_PATH from hummingbot.client.config.global_config_map import global_config_map from hummingbot.connector.derivative.position import Position @@ -92,39 +92,6 @@ def __init__(self, def name(self): return "perpetual_finance" - @staticmethod - async def fetch_trading_pairs() -> List[str]: - ssl_ctx = ssl.create_default_context(cafile=GATEAWAY_CA_CERT_PATH) - ssl_ctx.load_cert_chain(GATEAWAY_CLIENT_CERT_PATH, GATEAWAY_CLIENT_KEY_PATH) - conn = aiohttp.TCPConnector(ssl_context=ssl_ctx) - client = aiohttp.ClientSession(connector=conn) - - base_url = f"https://{global_config_map['gateway_api_host'].value}:" \ - f"{global_config_map['gateway_api_port'].value}/perpfi/" - response = await client.get(base_url + "pairs") - parsed_response = json.loads(await response.text()) - if response.status != 200: - err_msg = "" - if "error" in parsed_response: - err_msg = f" Message: {parsed_response['error']}" - raise IOError(f"Error fetching pairs from gateway. HTTP status is {response.status}.{err_msg}") - pairs = parsed_response.get("pairs", []) - if "error" in parsed_response or len(pairs) == 0: - raise Exception(f"Error: {parsed_response['error']}") - else: - status = await client.get(base_url) - status = json.loads(await status.text()) - loadedMetadata = status["loadedMetadata"] - while (not loadedMetadata): - resp = await client.get(base_url + "load-metadata") - resp = json.loads(await resp.text()) - loadedMetadata = resp.get("loadedMetadata", False) - return PerpetualFinanceDerivative.fetch_trading_pairs() - trading_pairs = [] - for pair in pairs: - trading_pairs.append(convert_from_exchange_trading_pair(pair)) - return trading_pairs - @property def limit_orders(self) -> List[LimitOrder]: return [ From 57b48217eb37f99b08cb53c02ee936a4e25c9872 Mon Sep 17 00:00:00 2001 From: vic-en Date: Fri, 19 Feb 2021 12:24:41 +0100 Subject: [PATCH 09/24] (feat) refactor autocomplete filter for connector name selection for all strategies --- hummingbot/client/ui/completer.py | 49 ++++++++++++------- .../strategy/amm_arb/amm_arb_config_map.py | 4 +- .../arbitrage/arbitrage_config_map.py | 4 +- .../strategy/celo_arb/celo_arb_config_map.py | 2 +- ...cross_exchange_market_making_config_map.py | 4 +- .../liquidity_mining_config_map.py | 2 +- .../perpetual_market_making_config_map.py | 2 +- .../pure_market_making_config_map.py | 2 +- 8 files changed, 40 insertions(+), 29 deletions(-) diff --git a/hummingbot/client/ui/completer.py b/hummingbot/client/ui/completer.py index 527c442602..ef85af2ccb 100644 --- a/hummingbot/client/ui/completer.py +++ b/hummingbot/client/ui/completer.py @@ -14,7 +14,8 @@ DERIVATIVES, STRATEGIES, CONF_FILE_PATH, - SCRIPTS_PATH + SCRIPTS_PATH, + ConnectorType ) from hummingbot.client.ui.parser import ThrowingArgumentParser from hummingbot.core.utils.wallet_setup import list_wallets @@ -28,15 +29,21 @@ def file_name_list(path, file_extension): return sorted([f for f in listdir(path) if isfile(join(path, f)) and f.endswith(file_extension)]) +SPOT_PROTOCOL_CONNECTOR = {x.name for x in CONNECTOR_SETTINGS.values() if x.type == ConnectorType.Connector} +DERIVATIVE_PROTOCOL_CONNECTOR = {x.name for x in CONNECTOR_SETTINGS.values() if x.type == ConnectorType.Derivative and not x.centralised} + + class HummingbotCompleter(Completer): def __init__(self, hummingbot_application): super(HummingbotCompleter, self).__init__() self.hummingbot_application = hummingbot_application self._path_completer = WordCompleter(file_name_list(CONF_FILE_PATH, "yml")) self._command_completer = WordCompleter(self.parser.commands, ignore_case=True) - self._connector_completer = WordCompleter(CONNECTOR_SETTINGS.keys(), ignore_case=True) - self._exchange_completer = WordCompleter(EXCHANGES, ignore_case=True) + self._exchange_completer = WordCompleter(CONNECTOR_SETTINGS.keys(), ignore_case=True) + self._spot_completer = WordCompleter(EXCHANGES.union(SPOT_PROTOCOL_CONNECTOR), ignore_case=True) + self._spot_exchange_completer = WordCompleter(EXCHANGES, ignore_case=True) self._derivative_completer = WordCompleter(DERIVATIVES, ignore_case=True) + self._derivative_exchange_completer = WordCompleter(DERIVATIVES.difference(DERIVATIVE_PROTOCOL_CONNECTOR), ignore_case=True) self._connect_option_completer = WordCompleter(CONNECT_OPTIONS, ignore_case=True) self._export_completer = WordCompleter(["keys", "trades"], ignore_case=True) self._balance_completer = WordCompleter(["limit", "paper"], ignore_case=True) @@ -96,25 +103,21 @@ def _complete_options(self, document: Document) -> bool: return "(" in self.prompt_text and ")" in self.prompt_text and "/" in self.prompt_text def _complete_exchanges(self, document: Document) -> bool: - text_before_cursor: str = document.text_before_cursor - return "-e" in text_before_cursor or \ - "--exchange" in text_before_cursor or \ - any(x for x in ("exchange name", "name of exchange", "name of the exchange") + return any(x for x in ("exchange name", "name of exchange", "name of the exchange") if x in self.prompt_text.lower()) def _complete_derivatives(self, document: Document) -> bool: text_before_cursor: str = document.text_before_cursor - return "--exchange" in text_before_cursor or \ - "perpetual" in text_before_cursor or \ - any(x for x in ("derivative name", "name of derivative", "name of the derivative") + return "perpetual" in text_before_cursor or \ + any(x for x in ("derivative connector", "derivative name", "name of derivative", "name of the derivative") if x in self.prompt_text.lower()) def _complete_connect_options(self, document: Document) -> bool: text_before_cursor: str = document.text_before_cursor return text_before_cursor.startswith("connect ") - def _complete_connectors(self, document: Document) -> bool: - return "connector" in self.prompt_text + def _complete_spot_connectors(self, document: Document) -> bool: + return "spot" in self.prompt_text def _complete_export_options(self, document: Document) -> bool: text_before_cursor: str = document.text_before_cursor @@ -175,9 +178,13 @@ def get_completions(self, document: Document, complete_event: CompleteEvent): for c in self._wallet_address_completer.get_completions(document, complete_event): yield c - elif self._complete_connectors(document): - for c in self._connector_completer.get_completions(document, complete_event): - yield c + elif self._complete_spot_connectors(document): + if "(Exchange/AMM)" in self.prompt_text: + for c in self._spot_completer.get_completions(document, complete_event): + yield c + else: + for c in self._spot_exchange_completer.get_completions(document, complete_event): + yield c elif self._complete_connect_options(document): for c in self._connect_option_completer.get_completions(document, complete_event): @@ -199,14 +206,18 @@ def get_completions(self, document: Document, complete_event: CompleteEvent): for c in self._history_completer.get_completions(document, complete_event): yield c + elif self._complete_derivatives(document): + if "(Exchange/AMM)" in self.prompt_text: + for c in self._derivative_completer.get_completions(document, complete_event): + yield c + else: + for c in self._derivative_exchange_completer.get_completions(document, complete_event): + yield c + elif self._complete_exchanges(document): for c in self._exchange_completer.get_completions(document, complete_event): yield c - elif self._complete_derivatives(document): - for c in self._derivative_completer.get_completions(document, complete_event): - yield c - elif self._complete_trading_pairs(document): for c in self._trading_pair_completer.get_completions(document, complete_event): yield c diff --git a/hummingbot/strategy/amm_arb/amm_arb_config_map.py b/hummingbot/strategy/amm_arb/amm_arb_config_map.py index 16e15e90c7..d85d87951c 100644 --- a/hummingbot/strategy/amm_arb/amm_arb_config_map.py +++ b/hummingbot/strategy/amm_arb/amm_arb_config_map.py @@ -51,7 +51,7 @@ def order_amount_prompt() -> str: default="amm_arb"), "connector_1": ConfigVar( key="connector_1", - prompt="Enter your first connector (exchange/AMM) >>> ", + prompt="Enter your first spot connector (Exchange/AMM) >>> ", prompt_on_new=True, validator=validate_connector, on_validated=exchange_on_validated), @@ -62,7 +62,7 @@ def order_amount_prompt() -> str: on_validated=market_1_on_validated), "connector_2": ConfigVar( key="connector_2", - prompt="Enter your second connector (exchange/AMM) >>> ", + prompt="Enter your second spot connector (Exchange/AMM) >>> ", prompt_on_new=True, validator=validate_connector, on_validated=exchange_on_validated), diff --git a/hummingbot/strategy/arbitrage/arbitrage_config_map.py b/hummingbot/strategy/arbitrage/arbitrage_config_map.py index 278f71cdde..96313098ae 100644 --- a/hummingbot/strategy/arbitrage/arbitrage_config_map.py +++ b/hummingbot/strategy/arbitrage/arbitrage_config_map.py @@ -47,13 +47,13 @@ def secondary_market_on_validated(value: str): default="arbitrage"), "primary_market": ConfigVar( key="primary_market", - prompt="Enter your primary exchange name >>> ", + prompt="Enter your primary spot connector >>> ", prompt_on_new=True, validator=validate_exchange, on_validated=lambda value: required_exchanges.append(value)), "secondary_market": ConfigVar( key="secondary_market", - prompt="Enter your secondary exchange name >>> ", + prompt="Enter your secondary spot connector >>> ", prompt_on_new=True, validator=validate_exchange, on_validated=secondary_market_on_validated), diff --git a/hummingbot/strategy/celo_arb/celo_arb_config_map.py b/hummingbot/strategy/celo_arb/celo_arb_config_map.py index c92f5b56b0..3e99ca39b3 100644 --- a/hummingbot/strategy/celo_arb/celo_arb_config_map.py +++ b/hummingbot/strategy/celo_arb/celo_arb_config_map.py @@ -35,7 +35,7 @@ def order_amount_prompt() -> str: default="celo_arb"), "secondary_exchange": ConfigVar( key="secondary_exchange", - prompt="Enter your secondary exchange name >>> ", + prompt="Enter your secondary spot connector >>> ", prompt_on_new=True, validator=validate_exchange, on_validated=exchange_on_validated), diff --git a/hummingbot/strategy/cross_exchange_market_making/cross_exchange_market_making_config_map.py b/hummingbot/strategy/cross_exchange_market_making/cross_exchange_market_making_config_map.py index a87eb8f8f6..2d8f2438a9 100644 --- a/hummingbot/strategy/cross_exchange_market_making/cross_exchange_market_making_config_map.py +++ b/hummingbot/strategy/cross_exchange_market_making/cross_exchange_market_making_config_map.py @@ -78,14 +78,14 @@ def taker_market_on_validated(value: str): ), "maker_market": ConfigVar( key="maker_market", - prompt="Enter your maker exchange name >>> ", + prompt="Enter your maker spot connector >>> ", prompt_on_new=True, validator=validate_exchange, on_validated=lambda value: required_exchanges.append(value), ), "taker_market": ConfigVar( key="taker_market", - prompt="Enter your taker exchange name >>> ", + prompt="Enter your taker spot connector >>> ", prompt_on_new=True, validator=validate_exchange, on_validated=taker_market_on_validated, diff --git a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py index 891733234f..ff421ead7b 100644 --- a/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py +++ b/hummingbot/strategy/liquidity_mining/liquidity_mining_config_map.py @@ -37,7 +37,7 @@ def order_size_prompt() -> str: default="liquidity_mining"), "exchange": ConfigVar(key="exchange", - prompt="Enter your liquidity mining exchange name >>> ", + prompt="Enter the spot connector to use for liquidity mining >>> ", validator=validate_exchange, on_validated=exchange_on_validated, prompt_on_new=True), diff --git a/hummingbot/strategy/perpetual_market_making/perpetual_market_making_config_map.py b/hummingbot/strategy/perpetual_market_making/perpetual_market_making_config_map.py index b18277dc9d..1040ef9c53 100644 --- a/hummingbot/strategy/perpetual_market_making/perpetual_market_making_config_map.py +++ b/hummingbot/strategy/perpetual_market_making/perpetual_market_making_config_map.py @@ -126,7 +126,7 @@ def derivative_on_validated(value: str): default="perpetual_market_making"), "derivative": ConfigVar(key="derivative", - prompt="Enter your maker derivative name >>> ", + prompt="Enter your maker derivative connector >>> ", validator=validate_derivative, on_validated=derivative_on_validated, prompt_on_new=True), diff --git a/hummingbot/strategy/pure_market_making/pure_market_making_config_map.py b/hummingbot/strategy/pure_market_making/pure_market_making_config_map.py index a8d69eec0b..7f8065b698 100644 --- a/hummingbot/strategy/pure_market_making/pure_market_making_config_map.py +++ b/hummingbot/strategy/pure_market_making/pure_market_making_config_map.py @@ -111,7 +111,7 @@ def exchange_on_validated(value: str): default="pure_market_making"), "exchange": ConfigVar(key="exchange", - prompt="Enter your maker exchange name >>> ", + prompt="Enter your maker spot connector >>> ", validator=validate_exchange, on_validated=exchange_on_validated, prompt_on_new=True), From 747df25340b065b032ecb2b64c5c94634de8b4d5 Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 22 Feb 2021 13:28:32 +0100 Subject: [PATCH 10/24] (feat) hardcode perpetual finance along other gateway connectors --- hummingbot/client/hummingbot_application.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/client/hummingbot_application.py b/hummingbot/client/hummingbot_application.py index c64d0b3ccb..3566917232 100644 --- a/hummingbot/client/hummingbot_application.py +++ b/hummingbot/client/hummingbot_application.py @@ -241,7 +241,7 @@ def _initialize_markets(self, market_names: List[Tuple[str, List[str]]]): if conn_setting.use_ethereum_wallet: ethereum_rpc_url = global_config_map.get("ethereum_rpc_url").value # Todo: Hard coded this execption for now until we figure out how to handle all ethereum connectors. - if connector_name in ["balancer", "uniswap"]: + if connector_name in ["balancer", "uniswap", "perpetual_finance"]: private_key = get_eth_wallet_private_key() init_params.update(wallet_private_key=private_key, ethereum_rpc_url=ethereum_rpc_url) else: From c3b895b1b71bf8ed349d1d29722be296c0da1b39 Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 22 Feb 2021 13:30:57 +0100 Subject: [PATCH 11/24] (feat) add XDAI to balance command --- hummingbot/client/command/balance_command.py | 20 ++++++++++++++++++-- hummingbot/strategy/strategy_base.pyx | 3 ++- hummingbot/user/user_balances.py | 10 ++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/hummingbot/client/command/balance_command.py b/hummingbot/client/command/balance_command.py index cd4a1c0508..171485ee0a 100644 --- a/hummingbot/client/command/balance_command.py +++ b/hummingbot/client/command/balance_command.py @@ -110,11 +110,17 @@ async def show_balances(self): eth_address = global_config_map["ethereum_wallet"].value if eth_address is not None: - df = await self.ethereum_balances_df() - lines = [" " + line for line in df.to_string(index=False).split("\n")] + eth_df = await self.ethereum_balances_df() + lines = [" " + line for line in eth_df.to_string(index=False).split("\n")] self._notify("\nethereum:") self._notify("\n".join(lines)) + # XDAI balances + xdai_df = await self.xdai_balances_df() + lines = [" " + line for line in xdai_df.to_string(index=False).split("\n")] + self._notify("\nxdai:") + self._notify("\n".join(lines)) + async def exchange_balances_df(self, # type: HummingbotApplication exchange_balances: Dict[str, Decimal], exchange_limits: Dict[str, str]): @@ -177,6 +183,16 @@ async def ethereum_balances_df(self, # type: HummingbotApplication df.sort_values(by=["Asset"], inplace=True) return df + async def xdai_balances_df(self, # type: HummingbotApplication + ): + rows = [] + bals = await UserBalances.xdai_balances() + for token, bal in bals.items(): + rows.append({"Asset": token, "Amount": round(bal, 4)}) + df = pd.DataFrame(data=rows, columns=["Asset", "Amount"]) + df.sort_values(by=["Asset"], inplace=True) + return df + async def asset_limits_df(self, asset_limit_conf: Dict[str, str]): rows = [] diff --git a/hummingbot/strategy/strategy_base.pyx b/hummingbot/strategy/strategy_base.pyx index d214b288e8..610cb5eb80 100755 --- a/hummingbot/strategy/strategy_base.pyx +++ b/hummingbot/strategy/strategy_base.pyx @@ -19,6 +19,7 @@ from hummingbot.core.event.events import ( ) from .order_tracker import OrderTracker +from hummingbot.connector.derivative_base import DerivativeBase NaN = float("nan") s_decimal_nan = Decimal("NaN") @@ -213,7 +214,7 @@ cdef class StrategyBase(TimeIterator): for market_trading_pair_tuple in market_trading_pair_tuples: base_balance = market_trading_pair_tuple.market.get_balance(market_trading_pair_tuple.base_asset) quote_balance = market_trading_pair_tuple.market.get_balance(market_trading_pair_tuple.quote_asset) - if base_balance <= Decimal("0.0001"): + if base_balance <= Decimal("0.0001") and not isinstance(market_trading_pair_tuple.market, DerivativeBase): warning_lines.append(f" {market_trading_pair_tuple.market.name} market " f"{market_trading_pair_tuple.base_asset} balance is too low. Cannot place order.") if quote_balance <= Decimal("0.0001"): diff --git a/hummingbot/user/user_balances.py b/hummingbot/user/user_balances.py index 326a80549e..b9e6e313c2 100644 --- a/hummingbot/user/user_balances.py +++ b/hummingbot/user/user_balances.py @@ -5,6 +5,7 @@ from hummingbot.core.utils.async_utils import safe_gather from hummingbot.client.config.global_config_map import global_config_map from hummingbot.connector.connector.balancer.balancer_connector import BalancerConnector +from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_derivative import PerpetualFinanceDerivative from hummingbot.client.settings import ethereum_required_trading_pairs from typing import Optional, Dict, List from decimal import Decimal @@ -122,6 +123,15 @@ async def eth_n_erc20_balances() -> Dict[str, Decimal]: await connector._update_balances() return connector.get_all_balances() + @staticmethod + async def xdai_balances() -> Dict[str, Decimal]: + connector = PerpetualFinanceDerivative("", + get_eth_wallet_private_key(), + "", + True) + await connector._update_balances() + return connector.get_all_balances() + @staticmethod def validate_ethereum_wallet() -> Optional[str]: if global_config_map.get("ethereum_wallet").value is None: From 26c51ff2d66d208b452679e9aac7a100218e9f8a Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 22 Feb 2021 13:32:47 +0100 Subject: [PATCH 12/24] (fix) fix errors related to funding_info in derivative connectors --- .../binance_perpetual_derivative.py | 47 ++++++++----------- .../perpetual_finance_derivative.py | 33 +++++++++---- hummingbot/connector/derivative_base.py | 2 +- hummingbot/model/funding_payment.py | 4 +- 4 files changed, 45 insertions(+), 41 deletions(-) diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py index f2d4359d35..8a612e7890 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py @@ -129,8 +129,7 @@ def __init__(self, self._order_not_found_records = {} self._last_timestamp = 0 self._trading_rules = {} - # self._trade_fees = {} - # self._last_update_trade_fees_timestamp = 0 + self._trading_pairs = trading_pairs self._status_polling_task = None self._user_stream_event_listener_task = None self._trading_rules_polling_task = None @@ -161,9 +160,7 @@ def status_dict(self): "order_books_initialized": self._order_book_tracker.ready, "account_balance": len(self._account_balances) > 0 if self._trading_required else True, "trading_rule_initialized": len(self._trading_rules) > 0, - - # TODO: Uncomment when figured out trade fees - # "trade_fees_initialized": len(self._trade_fees) > 0 + "funding_info": len(self._funding_info) > 0 } @property @@ -630,8 +627,8 @@ def get_order_book(self, trading_pair: str) -> OrderBook: return order_books[trading_pair] async def _update_trading_rules(self): - last_tick = self._last_timestamp / 60.0 - current_tick = self.current_timestamp / 60.0 + last_tick = int(self._last_timestamp / 60.0) + current_tick = int(self.current_timestamp / 60.0) if current_tick > last_tick or len(self._trading_rules) < 1: exchange_info = await self.request(path="/fapi/v1/exchangeInfo", method=MethodType.GET, is_signed=False) trading_rules_list = self._format_trading_rules(exchange_info) @@ -674,11 +671,8 @@ async def _trading_rules_polling_loop(self): try: await safe_gather( self._update_trading_rules() - - # TODO: Uncomment when implemented - # self._update_trade_fees() ) - await asyncio.sleep(60) + await asyncio.sleep(3600) except asyncio.CancelledError: raise except Exception: @@ -692,25 +686,22 @@ async def _funding_info_polling_loop(self): try: ws_subscription_path: str = "/".join([f"{convert_to_exchange_trading_pair(trading_pair).lower()}@markPrice" for trading_pair in self._trading_pairs]) - stream_url: str = f"{self._stream_url}?streams={ws_subscription_path}" + stream_url: str = f"{self._stream_url}/stream?streams={ws_subscription_path}" async with websockets.connect(stream_url) as ws: ws: websockets.WebSocketClientProtocol = ws - try: - while True: - try: - raw_msg: str = await asyncio.wait_for(ws.recv(), timeout=10.0) - msg = ujson.loads(raw_msg) - trading_pair = msg["s"] - self._funding_info[trading_pair] = {"indexPrice": msg["i"], - "markPrice": msg["p"], - "nextFundingTime": msg["T"], - "rate": msg["r"]} - except asyncio.TimeoutError: - await ws.pong(data=b'') - except ConnectionClosed: - continue - finally: - await ws.close() + while True: + try: + raw_msg: str = await asyncio.wait_for(ws.recv(), timeout=10.0) + msg = ujson.loads(raw_msg) + trading_pair = convert_from_exchange_trading_pair(msg["data"]["s"]) + self._funding_info[trading_pair] = {"indexPrice": msg["data"]["i"], + "markPrice": msg["data"]["p"], + "nextFundingTime": msg["data"]["T"], + "rate": msg["data"]["r"]} + except asyncio.TimeoutError: + await ws.pong(data=b'') + except ConnectionClosed: + raise except asyncio.CancelledError: raise except Exception: diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index 9323d76967..48b86ea17f 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -63,13 +63,12 @@ def logger(cls) -> HummingbotLogger: def __init__(self, trading_pairs: List[str], wallet_private_key: str, - ethereum_rpc_url: str, + ethereum_rpc_url: str, # not used, but left in place to be consistent with other gateway connectors trading_required: bool = True ): """ :param trading_pairs: a list of trading pairs :param wallet_private_key: a private key for eth wallet - :param ethereum_rpc_url: this is usually infura RPC URL :param trading_required: Whether actual trading is needed. """ super().__init__() @@ -407,13 +406,15 @@ def has_allowances(self) -> bool: def status_dict(self) -> Dict[str, bool]: return { "account_balance": len(self._account_balances) > 0 if self._trading_required else True, - "allowances": self.has_allowances() if self._trading_required else True + "allowances": self.has_allowances() if self._trading_required else True, + "funding_info": len(self._funding_info) > 0 } async def start_network(self): if self._trading_required: self._status_polling_task = safe_ensure_future(self._status_polling_loop()) self._auto_approve_task = safe_ensure_future(self.auto_approve()) + self._funding_info_polling_task = safe_ensure_future(self._funding_info_polling_loop()) async def stop_network(self): if self._status_polling_task is not None: @@ -422,6 +423,9 @@ async def stop_network(self): if self._auto_approve_task is not None: self._auto_approve_task.cancel() self._auto_approve_task = None + if self._funding_info_polling_task is not None: + self._funding_info_polling_task.cancel() + self._funding_info_polling_task = None async def check_network(self) -> NetworkStatus: try: @@ -491,7 +495,6 @@ async def _update_balances(self): async def _update_positions(self): position_tasks = [] funding_payment_tasks = [] - funding_info_tasks = [] for pair in self._trading_pairs: position_tasks.append(self._api_request("post", "perpfi/position", @@ -499,12 +502,8 @@ async def _update_positions(self): funding_payment_tasks.append(self._api_request("get", "perpfi/funding_payment", {"pair": convert_to_exchange_trading_pair(pair)})) - funding_info_tasks.append(self._api_request("get", - "perpfi/funding", - {"pair": convert_to_exchange_trading_pair(pair)})) positions = await safe_gather(*position_tasks, return_exceptions=True) funding_payments = await safe_gather(*funding_payment_tasks, return_exceptions=True) - funding_infos = await safe_gather(*funding_info_tasks, return_exceptions=True) for trading_pair, position in zip(self._trading_pairs, positions): position = position.get("position", {}) position_side = PositionSide.LONG if position.get("size", 0) > 0 else PositionSide.SHORT @@ -537,8 +536,22 @@ async def _update_positions(self): symbol=trading_pair, amount=payment)) - for trading_pair, funding_info in zip(self._trading_pairs, funding_infos): - self._funding_info[trading_pair] = funding_info["fr"] + async def _funding_info_polling_loop(self): + while True: + try: + funding_info_tasks = [] + for pair in self._trading_pairs: + funding_info_tasks.append(self._api_request("post", + "perpfi/funding", + {"pair": convert_to_exchange_trading_pair(pair)})) + funding_infos = await safe_gather(*funding_info_tasks, return_exceptions=True) + for trading_pair, funding_info in zip(self._trading_pairs, funding_infos): + self._funding_info[trading_pair] = funding_info["fr"] + except Exception: + self.logger().network("Unexpected error while fetching funding info.", exc_info=True, + app_warning_msg="Could not fetch new funding info from Perpetual Finance protocol. " + "Check network connection on gateway.") + await asyncio.sleep(30) def get_funding_info(self, trading_pair): return self._funding_info[trading_pair] diff --git a/hummingbot/connector/derivative_base.py b/hummingbot/connector/derivative_base.py index 5bd2667e33..cd8800f073 100644 --- a/hummingbot/connector/derivative_base.py +++ b/hummingbot/connector/derivative_base.py @@ -18,7 +18,7 @@ def __init__(self): self._funding_info = {} self._account_positions = {} self._position_mode = None - self._leverage = 1 + self._leverage = {} self._funding_payment_span = [0, 0] # time span(in seconds) before and after funding period when exchanges consider active positions eligible for funding payment def set_position_mode(self, position_mode: PositionMode): diff --git a/hummingbot/model/funding_payment.py b/hummingbot/model/funding_payment.py index 99faa771a6..cd11bf1dec 100644 --- a/hummingbot/model/funding_payment.py +++ b/hummingbot/model/funding_payment.py @@ -21,9 +21,9 @@ class FundingPayment(HummingbotBase): __tablename__ = "FundingPayment" - __table_args__ = (Index("tf_config_timestamp_index", + __table_args__ = (Index("fp_config_timestamp_index", "config_file_path", "timestamp"), - Index("tf_market_trading_pair_timestamp_index", + Index("fp_market_trading_pair_timestamp_index", "market", "symbol", "timestamp") ) From 7e7a3e95350e63d8ce8270e1de43790398fd30a6 Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 22 Feb 2021 13:52:48 +0100 Subject: [PATCH 13/24] (feat) add spot-perp arbitrage strategy --- .../spot_perpetual_arbitrage/__init__.py | 0 .../spot_perpetual_arbitrage/arb_proposal.py | 125 +++++ .../spot_perpetual_arbitrage/dummy.pxd | 2 + .../spot_perpetual_arbitrage/dummy.pyx | 2 + .../spot_perpetual_arbitrage.py | 497 ++++++++++++++++++ .../spot_perpetual_arbitrage_config_map.py | 125 +++++ .../spot_perpetual_arbitrage/start.py | 37 ++ .../spot_perpetual_arbitrage/utils.py | 44 ++ ..._perpetual_arbitrage_strategy_TEMPLATE.yml | 37 ++ 9 files changed, 869 insertions(+) create mode 100644 hummingbot/strategy/spot_perpetual_arbitrage/__init__.py create mode 100644 hummingbot/strategy/spot_perpetual_arbitrage/arb_proposal.py create mode 100644 hummingbot/strategy/spot_perpetual_arbitrage/dummy.pxd create mode 100644 hummingbot/strategy/spot_perpetual_arbitrage/dummy.pyx create mode 100644 hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py create mode 100644 hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py create mode 100644 hummingbot/strategy/spot_perpetual_arbitrage/start.py create mode 100644 hummingbot/strategy/spot_perpetual_arbitrage/utils.py create mode 100644 hummingbot/templates/conf_spot_perpetual_arbitrage_strategy_TEMPLATE.yml diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/__init__.py b/hummingbot/strategy/spot_perpetual_arbitrage/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/arb_proposal.py b/hummingbot/strategy/spot_perpetual_arbitrage/arb_proposal.py new file mode 100644 index 0000000000..d6c78cffff --- /dev/null +++ b/hummingbot/strategy/spot_perpetual_arbitrage/arb_proposal.py @@ -0,0 +1,125 @@ +from decimal import Decimal +from hummingbot.core.utils.async_utils import safe_gather +from hummingbot.strategy.market_trading_pair_tuple import MarketTradingPairTuple + +s_decimal_nan = Decimal("NaN") +s_decimal_0 = Decimal("0") + + +class ArbProposalSide: + """ + An arbitrage proposal side which contains info needed for order submission. + """ + def __init__(self, + market_info: MarketTradingPairTuple, + is_buy: bool, + order_price: Decimal, + amount: Decimal + ): + """ + :param market_info: The market where to submit the order + :param is_buy: True if buy order + :param quote_price: The quote price (for an order amount) from the market + :param order_price: The price required for order submission, this could differ from the quote price + :param amount: The order amount + """ + self.market_info: MarketTradingPairTuple = market_info + self.is_buy: bool = is_buy + self.order_price: Decimal = order_price + self.amount: Decimal = amount + + def __repr__(self): + side = "Buy" if self.is_buy else "Sell" + base, quote = self.market_info.trading_pair.split("-") + return f"{self.market_info.market.display_name.capitalize()}: {side} {self.amount} {base}" \ + f" at {self.order_price} {quote}." + + +class ArbProposal: + """ + An arbitrage proposal which contains 2 sides of the proposal - one buy and one sell. + """ + def __init__(self, + spot_market_info: MarketTradingPairTuple, + derivative_market_info: MarketTradingPairTuple, + order_amount: Decimal, + timestamp: float): + self.spot_market_info: MarketTradingPairTuple = spot_market_info + self.derivative_market_info: MarketTradingPairTuple = derivative_market_info + self.spot_side: ArbProposalSide = None + self.derivative_side: ArbProposalSide = None + self.amount: Decimal = order_amount + self.timestamp: float = timestamp + self.spot_buy_sell_prices = [0, 0] + self.deriv_buy_sell_prices = [0, 0] + + async def update_prices(self): + """ + Update the buy and sell prices for both spot and deriv connectors. + """ + tasks = [self.spot_market_info.market.get_order_price(self.spot_market_info.trading_pair, True, self.amount), + self.spot_market_info.market.get_order_price(self.spot_market_info.trading_pair, False, self.amount), + self.derivative_market_info.market.get_order_price(self.derivative_market_info.trading_pair, True, self.amount), + self.derivative_market_info.market.get_order_price(self.derivative_market_info.trading_pair, False, self.amount)] + + prices = await safe_gather(*tasks, return_exceptions=True) + self.spot_buy_sell_prices = [prices[0], prices[1]] + self.deriv_buy_sell_prices = [prices[2], prices[3]] + + def is_funding_payment_time(self): + """ + Check if it's time for funding payment. + Return True if it's time for funding payment else False. + """ + funding_info = self.derivative_market_info.market.get_funding_info(self.derivative_market_info.trading_pair) + funding_payment_span = self.derivative_market_info.market._funding_payment_span + if self.timestamp > (funding_info["nextFundingTime"] - funding_payment_span[0]) and \ + self.timestamp < (funding_info["nextFundingTime"] + funding_payment_span[1]): + return True + else: + return False + + async def proposed_spot_deriv_arb(self): + """ + Determine if the current situation is contango or backwardation and return a pair of buy and sell prices accordingly. + """ + await self.update_prices() + if (sum(self.spot_buy_sell_prices) / 2) > (sum(self.deriv_buy_sell_prices) / 2): # Backwardation + self.spot_side = ArbProposalSide(self.spot_market_info, False, + self.spot_buy_sell_prices[1], + self.amount) + self.derivative_side = ArbProposalSide(self.derivative_market_info, True, + self.deriv_buy_sell_prices[0], + self.amount) + return (self.spot_side, self.derivative_side) + else: # Contango + self.spot_side = ArbProposalSide(self.spot_market_info, True, + self.spot_buy_sell_prices[0], + self.amount) + self.derivative_side = ArbProposalSide(self.derivative_market_info, False, + self.deriv_buy_sell_prices[1], + self.amount) + return (self.spot_side, self.derivative_side) + + def alternate_proposal_sides(self): + """ + Alternate the sides and prices of proposed spot and derivative arb. + """ + if self.spot_side.is_buy: + self.spot_side.is_buy = False + self.spot_side.order_price = self.spot_buy_sell_prices[1] + self.derivative_side.is_buy = True + self.derivative_side.order_price = self.deriv_buy_sell_prices[0] + else: + self.spot_side.is_buy = True + self.spot_side.order_price = self.spot_buy_sell_prices[0] + self.derivative_side.is_buy = False + self.derivative_side.order_price = self.deriv_buy_sell_prices[1] + return (self.spot_side, self.derivative_side) + + def spread(self): + spread = abs(self.spot_side.order_price - self.derivative_side.order_price) / min(self.spot_side.order_price, self.derivative_side.order_price) + return Decimal(str(spread)) + + def __repr__(self): + return f"Spot - {self.spot_market_info.market}\nDerivative - {self.derivative_market_info.market}" diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/dummy.pxd b/hummingbot/strategy/spot_perpetual_arbitrage/dummy.pxd new file mode 100644 index 0000000000..4b098d6f59 --- /dev/null +++ b/hummingbot/strategy/spot_perpetual_arbitrage/dummy.pxd @@ -0,0 +1,2 @@ +cdef class dummy(): + pass diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/dummy.pyx b/hummingbot/strategy/spot_perpetual_arbitrage/dummy.pyx new file mode 100644 index 0000000000..4b098d6f59 --- /dev/null +++ b/hummingbot/strategy/spot_perpetual_arbitrage/dummy.pyx @@ -0,0 +1,2 @@ +cdef class dummy(): + pass diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py new file mode 100644 index 0000000000..3ae784fe62 --- /dev/null +++ b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py @@ -0,0 +1,497 @@ +from decimal import Decimal +import time +import logging +import asyncio +import pandas as pd +from typing import List, Dict, Tuple +from hummingbot.core.utils.async_utils import safe_ensure_future +from hummingbot.core.clock import Clock +from hummingbot.core.data_type.limit_order import LimitOrder +from hummingbot.core.data_type.market_order import MarketOrder +from hummingbot.logger import HummingbotLogger +from hummingbot.strategy.market_trading_pair_tuple import MarketTradingPairTuple +from hummingbot.strategy.strategy_py_base import StrategyPyBase +from hummingbot.connector.connector_base import ConnectorBase + +from hummingbot.core.event.events import ( + PositionAction, + PositionSide, + PositionMode +) +from hummingbot.connector.derivative.position import Position + +from .arb_proposal import ArbProposalSide, ArbProposal + + +NaN = float("nan") +s_decimal_zero = Decimal(0) +spa_logger = None + + +class SpotPerpetualArbitrageStrategy(StrategyPyBase): + """ + This strategy arbitrages between a spot and a perpetual exchange connector. + For a given order amount, the strategy checks the divergence and convergence in prices that could occur + before and during funding payment on the perpetual exchange. + If presents, the strategy submits taker orders to both market. + """ + + @classmethod + def logger(cls) -> HummingbotLogger: + global spa_logger + if spa_logger is None: + spa_logger = logging.getLogger(__name__) + return spa_logger + + def __init__(self, + spot_market_info: MarketTradingPairTuple, + derivative_market_info: MarketTradingPairTuple, + order_amount: Decimal, + derivative_leverage: int, + min_divergence: Decimal, + min_convergence: Decimal, + spot_market_slippage_buffer: Decimal = Decimal("0"), + derivative_market_slippage_buffer: Decimal = Decimal("0"), + maximize_funding_rate: bool = True, + status_report_interval: float = 10): + """ + :param spot_market_info: The first market + :param derivative_market_info: The second market + :param order_amount: The order amount + :param min_divergence: The minimum spread to start arbitrage (e.g. 0.0003 for 0.3%) + :param min_convergence: The minimum spread to close arbitrage (e.g. 0.0003 for 0.3%) + :param spot_market_slippage_buffer: The buffer for which to adjust order price for higher chance of + the order getting filled. This is quite important for AMM which transaction takes a long time where a slippage + is acceptable rather having the transaction get rejected. The submitted order price will be adjust higher + for buy order and lower for sell order. + :param derivative_market_slippage_buffer: The slipper buffer for market_2 + :param maximize_funding_rate: whether to submit both arbitrage taker orders (buy and sell) simultaneously + If false, the bot will wait for first exchange order filled before submitting the other order. + """ + super().__init__() + self._spot_market_info = spot_market_info + self._derivative_market_info = derivative_market_info + self._min_divergence = min_divergence + self._min_convergence = min_convergence + self._order_amount = order_amount + self._derivative_leverage = derivative_leverage + self._spot_market_slippage_buffer = spot_market_slippage_buffer + self._derivative_market_slippage_buffer = derivative_market_slippage_buffer + self._maximize_funding_rate = maximize_funding_rate + self._all_markets_ready = False + + self._ev_loop = asyncio.get_event_loop() + + self._last_timestamp = 0 + self._status_report_interval = status_report_interval + self.add_markets([spot_market_info.market, derivative_market_info.market]) + + self._current_proposal = None + self._main_task = None + self._spot_done = True + self._deriv_done = True + self._spot_order_ids = [] + self._deriv_order_ids = [] + + @property + def current_proposal(self) -> ArbProposal: + return self._current_proposal + + @current_proposal.setter + def current_proposal(self, value): + self._current_proposal = value + + @property + def min_divergence(self) -> Decimal: + return self._min_divergence + + @property + def min_convergence(self) -> Decimal: + return self._min_convergence + + @property + def order_amount(self) -> Decimal: + return self._order_amount + + @order_amount.setter + def order_amount(self, value): + self._order_amount = value + + @property + def market_info_to_active_orders(self) -> Dict[MarketTradingPairTuple, List[LimitOrder]]: + return self._sb_order_tracker.market_pair_to_active_orders + + @property + def deriv_position(self) -> List[Position]: + return [s for s in self._derivative_market_info.market._account_positions.values() if + s.trading_pair == self._derivative_market_info.trading_pair] + + def tick(self, timestamp: float): + """ + Clock tick entry point, is run every second (on normal tick setting). + :param timestamp: current tick timestamp + """ + if not self._all_markets_ready: + self._all_markets_ready = all([market.ready for market in self.active_markets]) + if not self._all_markets_ready: + # self.logger().warning("Markets are not ready. Please wait...") + return + else: + self.logger().info("Markets are ready. Trading started.") + if len(self.deriv_position) > 0: + self.logger().info("Active position detected, bot assumes first arbitrage was done and would scan for second arbitrage.") + if self.ready_for_new_arb_trades(): + if self._main_task is None or self._main_task.done(): + self.current_proposal = ArbProposal(self._spot_market_info, self._derivative_market_info, self.order_amount, timestamp) + self._main_task = safe_ensure_future(self.main(timestamp)) + + async def main(self, timestamp): + """ + The main procedure for the arbitrage strategy. It first check if it's time for funding payment, decide if to compare with either + min_convergence or min_divergence, applies the slippage buffer, applies budget constraint, then finally execute the + arbitrage. + """ + execute_arb = False + funding_msg = "" + await self.current_proposal.proposed_spot_deriv_arb() + if len(self.deriv_position) > 0 and self.should_alternate_proposal_sides(self.current_proposal, self.deriv_position): + self.current_proposal.alternate_proposal_sides() + + if self.current_proposal.is_funding_payment_time(): + if len(self.deriv_position) > 0: + if self._maximize_funding_rate: + execute_arb = not self.would_receive_funding_payment(self.deriv_position) + if execute_arb: + self.timed_logger(timestamp, "Waiting for funding payment.") + else: + funding_msg = "Time for funding payment, executing second arbitrage to prevent paying funding fee" + else: + funding_msg = "Time for funding payment, executing second arbitrage " \ + "immediately since we don't intend to maximize funding rate" + execute_arb = True + else: + if len(self.deriv_position) > 0: + execute_arb = self.ready_for_execution(self.current_proposal, False) + else: + execute_arb = self.ready_for_execution(self.current_proposal, True) + + if execute_arb: + self.logger().info(self.spread_msg()) + self.apply_slippage_buffers(self.current_proposal) + self.apply_budget_constraint(self.current_proposal) + await self.execute_arb_proposals(self.current_proposal, funding_msg) + else: + self.timed_logger(timestamp, self.spread_msg()) + return + + def timed_logger(self, timestamp, msg): + """ + Displays log at specific intervals. + :param timestamp: current timestamp + :param msg: message to display at next interval + """ + if timestamp - self._last_timestamp > self._status_report_interval: + self.logger().info(msg) + self._last_timestamp = timestamp + + def ready_for_execution(self, proposal: ArbProposal, first: bool): + """ + Check if the spread meets the required spread requirement for the right arbitrage. + :param proposal: current proposal object + :param first: True, if scanning for opportunity for first arbitrage, else, False + :return: True if ready, else, False + """ + spread = self.current_proposal.spread() + if first and spread >= self.min_divergence: + return True + elif not first and spread <= self.min_convergence: + return True + return False + + def should_alternate_proposal_sides(self, proposal: ArbProposal, active_position: List[Position]): + """ + Checks if there's need to alternate the sides of a proposed arbitrage. + :param proposal: current proposal object + :param active_position: information about active position for the derivative connector + :return: True if sides need to be alternated, else, False + """ + deriv_proposal_side = PositionSide.LONG if proposal.derivative_side.is_buy else PositionSide.SHORT + position_side = PositionSide.LONG if active_position[0].amount > 0 else PositionSide.SHORT + if deriv_proposal_side == position_side: + return True + return False + + def would_receive_funding_payment(self, active_position: List[Position]): + """ + Checks if an active position would receive funding payment. + :param active_position: information about active position for the derivative connector + :return: True if funding payment would be received, else, False + """ + funding_info = self._derivative_market_info.market.get_funding_info(self._derivative_market_info.trading_pair) + if (active_position[0].amount > 0 and funding_info["rate"] < 0) or \ + (active_position[0].amount < 0 and funding_info["rate"] > 0): + return True + return False + + def apply_slippage_buffers(self, arb_proposal: ArbProposal): + """ + Updates arb_proposals by adjusting order price for slipper buffer percentage. + E.g. if it is a buy order, for an order price of 100 and 1% slipper buffer, the new order price is 101, + for a sell order, the new order price is 99. + :param arb_proposals: the arbitrage proposal + """ + for arb_side in (arb_proposal.spot_side, arb_proposal.derivative_side): + market = arb_side.market_info.market + arb_side.amount = market.quantize_order_amount(arb_side.market_info.trading_pair, arb_side.amount) + s_buffer = self._spot_market_slippage_buffer if market == self._spot_market_info.market \ + else self._derivative_market_slippage_buffer + if not arb_side.is_buy: + s_buffer *= Decimal("-1") + arb_side.order_price *= Decimal("1") + s_buffer + arb_side.order_price = market.quantize_order_price(arb_side.market_info.trading_pair, + arb_side.order_price) + + def apply_budget_constraint(self, arb_proposal: ArbProposal): + """ + Updates arb_proposals by setting proposal amount to 0 if there is not enough balance to submit order with + required order amount. + :param arb_proposals: the arbitrage proposal + """ + spot_market = self._spot_market_info.market + deriv_market = self._derivative_market_info.market + spot_token = self._spot_market_info.quote_asset if arb_proposal.spot_side.is_buy else self._spot_market_info.base_asset + deriv_token = self._derivative_market_info.quote_asset + spot_token_balance = spot_market.get_available_balance(spot_token) + deriv_token_balance = deriv_market.get_available_balance(deriv_token) + required_spot_balance = arb_proposal.amount * arb_proposal.spot_side.order_price if arb_proposal.spot_side.is_buy else arb_proposal.amount + required_deriv_balance = arb_proposal.amount * arb_proposal.derivative_side.order_price + if spot_token_balance < required_spot_balance: + arb_proposal.amount = s_decimal_zero + self.logger().info(f"Can't arbitrage, {spot_market.display_name} " + f"{spot_token} balance " + f"({spot_token_balance}) is below required order amount ({required_spot_balance}).") + elif deriv_token_balance < required_deriv_balance: + arb_proposal.amount = s_decimal_zero + self.logger().info(f"Can't arbitrage, {deriv_market.display_name} " + f"{deriv_token} balance " + f"({deriv_token_balance}) is below required order amount ({required_deriv_balance}).") + return + + async def execute_arb_proposals(self, arb_proposal: ArbProposal, is_funding_msg: str = ""): + """ + Execute both sides of the arbitrage trades concurrently. + :param arb_proposals: the arbitrage proposal + :param is_funding_msg: message pertaining to funding payment + """ + if arb_proposal.amount == s_decimal_zero: + return + self._spot_done = False + self._deriv_done = False + proposal = self.short_proposal_msg(False) + if is_funding_msg: + opportunity_msg = is_funding_msg + else: + first_arbitage = not bool(len(self.deriv_position)) + opportunity_msg = "Spread wide enough to execute first arbitrage" if first_arbitage else \ + "Spread low enough to execute second arbitrage" + self.logger().info(f"{opportunity_msg}!: \n" + f"{proposal[0]} \n" + f"{proposal[1]} \n") + safe_ensure_future(self.execute_spot_side(arb_proposal.spot_side)) + safe_ensure_future(self.execute_derivative_side(arb_proposal.derivative_side)) + + async def execute_spot_side(self, arb_side: ArbProposalSide): + side = "BUY" if arb_side.is_buy else "SELL" + place_order_fn = self.buy_with_specific_market if arb_side.is_buy else self.sell_with_specific_market + self.log_with_clock(logging.INFO, + f"Placing {side} order for {arb_side.amount} {arb_side.market_info.base_asset} " + f"at {arb_side.market_info.market.display_name} at {arb_side.order_price} price") + order_id = place_order_fn(arb_side.market_info, + arb_side.amount, + arb_side.market_info.market.get_taker_order_type(), + arb_side.order_price, + ) + self._spot_order_ids.append(order_id) + + async def execute_derivative_side(self, arb_side: ArbProposalSide): + side = "BUY" if arb_side.is_buy else "SELL" + place_order_fn = self.buy_with_specific_market if arb_side.is_buy else self.sell_with_specific_market + position_action = PositionAction.OPEN if len(self.deriv_position) == 0 else PositionAction.CLOSE + self.log_with_clock(logging.INFO, + f"Placing {side} order for {arb_side.amount} {arb_side.market_info.base_asset} " + f"at {arb_side.market_info.market.display_name} at {arb_side.order_price} price to {position_action.name} position.") + order_id = place_order_fn(arb_side.market_info, + arb_side.amount, + arb_side.market_info.market.get_taker_order_type(), + arb_side.order_price, + position_action=position_action + ) + self._deriv_order_ids.append(order_id) + + def ready_for_new_arb_trades(self) -> bool: + """ + Returns True if there is no outstanding unfilled order. + """ + for market_info in [self._spot_market_info, self._derivative_market_info]: + if len(self.market_info_to_active_orders.get(market_info, [])) > 0: + return False + if not self._spot_done or not self._deriv_done: + return False + return True + + def short_proposal_msg(self, indented: bool = True) -> List[str]: + """ + Composes a short proposal message. + :param indented: If the message should be indented (by 4 spaces) + :return A list of info on both sides of an arbitrage + """ + lines = [] + proposal = self.current_proposal + lines.append(f"{' ' if indented else ''}{proposal.spot_side}") + lines.append(f"{' ' if indented else ''}{proposal.derivative_side}") + return lines + + def spread_msg(self): + """ + Composes a short spread message. + :return Info about current spread of an arbitrage + """ + spread = self.current_proposal.spread() + first = not bool(len(self.deriv_position)) + target_spread_str = "minimum divergence spread" if first else "minimum convergence spread" + target_spread = self.min_divergence if first else self.min_convergence + msg = f"Current spread: {spread:.2%}, {target_spread_str}: {target_spread:.2%}." + return msg + + def active_positions_df(self) -> pd.DataFrame: + columns = ["Symbol", "Type", "Entry Price", "Amount", "Leverage", "Unrealized PnL"] + data = [] + market, trading_pair = self._derivative_market_info.market, self._derivative_market_info.trading_pair + for idx in self.deriv_position: + is_buy = True if idx.amount > 0 else False + unrealized_profit = ((market.get_price(trading_pair, is_buy) - idx.entry_price) * idx.amount) + data.append([ + idx.trading_pair, + idx.position_side.name, + idx.entry_price, + idx.amount, + idx.leverage, + unrealized_profit + ]) + + return pd.DataFrame(data=data, columns=columns) + + async def format_status(self) -> str: + """ + Returns a status string formatted to display nicely on terminal. The strings composes of 4 parts: markets, + assets, spread and warnings(if any). + """ + columns = ["Exchange", "Market", "Sell Price", "Buy Price", "Mid Price"] + data = [] + for market_info in [self._spot_market_info, self._derivative_market_info]: + market, trading_pair, base_asset, quote_asset = market_info + buy_price = await market.get_quote_price(trading_pair, True, self._order_amount) + sell_price = await market.get_quote_price(trading_pair, False, self._order_amount) + mid_price = (buy_price + sell_price) / 2 + data.append([ + market.display_name, + trading_pair, + float(sell_price), + float(buy_price), + float(mid_price) + ]) + markets_df = pd.DataFrame(data=data, columns=columns) + lines = [] + lines.extend(["", " Markets:"] + [" " + line for line in markets_df.to_string(index=False).split("\n")]) + + # See if there're any active positions. + if len(self.deriv_position) > 0: + df = self.active_positions_df() + lines.extend(["", " Positions:"] + [" " + line for line in df.to_string(index=False).split("\n")]) + else: + lines.extend(["", " No active positions."]) + + assets_df = self.wallet_balance_data_frame([self._spot_market_info, self._derivative_market_info]) + lines.extend(["", " Assets:"] + + [" " + line for line in str(assets_df).split("\n")]) + + lines.extend(["", " Spread details:"] + [" " + self.spread_msg()] + + self.short_proposal_msg()) + + warning_lines = self.network_warning([self._spot_market_info]) + warning_lines.extend(self.network_warning([self._derivative_market_info])) + warning_lines.extend(self.balance_warning([self._spot_market_info])) + warning_lines.extend(self.balance_warning([self._derivative_market_info])) + if len(warning_lines) > 0: + lines.extend(["", "*** WARNINGS ***"] + warning_lines) + + return "\n".join(lines) + + def did_complete_buy_order(self, order_completed_event): + self.update_status(order_completed_event) + + def did_complete_sell_order(self, order_completed_event): + self.update_status(order_completed_event) + + def did_fail_order(self, order_failed_event): + self.retry_order(order_failed_event) + + def did_cancel_order(self, cancelled_event): + self.retry_order(cancelled_event) + + def did_expire_order(self, expired_event): + self.retry_order(expired_event) + + def did_complete_funding_payment(self, funding_payment_completed_event): + # Excute second arbitrage if necessary (even spread hasn't reached min convergence) + if len(self.deriv_position) > 0: + self.apply_slippage_buffers(self.current_proposal) + self.apply_budget_constraint(self.current_proposal) + funding_msg = "Executing second arbitrage after funding payment is received" + safe_ensure_future(self.execute_arb_proposals(self.current_proposal, funding_msg)) + return + + def update_status(self, event): + order_id = event.order_id + if order_id in self._spot_order_ids: + self._spot_done = True + self._spot_order_ids.remove(order_id) + elif order_id in self._deriv_order_ids: + self._deriv_done = True + self._deriv_order_ids.remove(order_id) + + def retry_order(self, event): + order_id = event.order_id + # To-do: Should be updated to do counted retry rather than time base retry. i.e mark as done after retrying 3 times + if event.timestamp > (time.time() - 5): # retry if order failed less than 5 secs ago + if order_id in self._spot_order_ids: + self.logger().info("Retrying failed order on spot exchange.") + safe_ensure_future(self.execute_spot_side(self.current_proposal.spot_side)) + self._spot_order_ids.remove(order_id) + elif order_id in self._deriv_order_ids: + self.logger().info("Retrying failed order on derivative exchange.") + safe_ensure_future(self.execute_derivative_side(self.current_proposal.derivative_side)) + self._deriv_order_ids.remove(order_id) + else: # mark as done + self.update_status(event) + + @property + def tracked_limit_orders(self) -> List[Tuple[ConnectorBase, LimitOrder]]: + return self._sb_order_tracker.tracked_limit_orders + + @property + def tracked_market_orders(self) -> List[Tuple[ConnectorBase, MarketOrder]]: + return self._sb_order_tracker.tracked_market_orders + + def apply_initial_settings(self, trading_pair, leverage): + deriv_market = self._derivative_market_info.market + deriv_market.set_leverage(trading_pair, leverage) + deriv_market.set_position_mode(PositionMode.ONEWAY) + + def start(self, clock: Clock, timestamp: float): + self.apply_initial_settings(self._derivative_market_info.trading_pair, self._derivative_leverage) + + def stop(self, clock: Clock): + if self._main_task is not None: + self._main_task.cancel() + self._main_task = None diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py new file mode 100644 index 0000000000..8be3cd9653 --- /dev/null +++ b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py @@ -0,0 +1,125 @@ +from hummingbot.client.config.config_var import ConfigVar +from hummingbot.client.config.config_validators import ( + validate_connector, + validate_derivative, + validate_decimal, + validate_bool, + validate_int +) +from hummingbot.client.settings import ( + required_exchanges, + requried_connector_trading_pairs, + EXAMPLE_PAIRS, +) +from decimal import Decimal + + +def exchange_on_validated(value: str) -> None: + required_exchanges.append(value) + + +def spot_market_on_validated(value: str) -> None: + requried_connector_trading_pairs[spot_perpetual_arbitrage_config_map["spot_connector"].value] = [value] + + +def derivative_market_on_validated(value: str) -> None: + requried_connector_trading_pairs[spot_perpetual_arbitrage_config_map["derivative_connector"].value] = [value] + + +def spot_market_prompt() -> str: + connector = spot_perpetual_arbitrage_config_map.get("spot_connector").value + example = EXAMPLE_PAIRS.get(connector) + return "Enter the token trading pair you would like to trade on %s%s >>> " \ + % (connector, f" (e.g. {example})" if example else "") + + +def derivative_market_prompt() -> str: + connector = spot_perpetual_arbitrage_config_map.get("derivative_connector").value + example = EXAMPLE_PAIRS.get(connector) + return "Enter the token trading pair you would like to trade on %s%s >>> " \ + % (connector, f" (e.g. {example})" if example else "") + + +def order_amount_prompt() -> str: + trading_pair = spot_perpetual_arbitrage_config_map["spot_market"].value + base_asset, quote_asset = trading_pair.split("-") + return f"What is the amount of {base_asset} per order? >>> " + + +spot_perpetual_arbitrage_config_map = { + "strategy": ConfigVar( + key="strategy", + prompt="", + default="spot_perpetual_arbitrage"), + "spot_connector": ConfigVar( + key="connector_1", + prompt="Enter a spot connector (Exchange/AMM) >>> ", + prompt_on_new=True, + validator=validate_connector, + on_validated=exchange_on_validated), + "spot_market": ConfigVar( + key="spot_market", + prompt=spot_market_prompt, + prompt_on_new=True, + on_validated=spot_market_on_validated), + "derivative_connector": ConfigVar( + key="derivative_connector", + prompt="Enter a derivative name (Exchange/AMM) >>> ", + prompt_on_new=True, + validator=validate_derivative, + on_validated=exchange_on_validated), + "derivative_market": ConfigVar( + key="derivative_market", + prompt=derivative_market_prompt, + prompt_on_new=True, + on_validated=derivative_market_on_validated), + "order_amount": ConfigVar( + key="order_amount", + prompt=order_amount_prompt, + type_str="decimal", + prompt_on_new=True), + "derivative_leverage": ConfigVar( + key="derivative_leverage", + prompt="How much leverage would you like to use on the derivative exchange? (Enter 1 to indicate 1X) ", + type_str="int", + default=1, + validator= lambda v: validate_int(v), + prompt_on_new=True), + "min_divergence": ConfigVar( + key="min_divergence", + prompt="What is the minimum spread between the spot and derivative market price before starting an arbitrage? (Enter 1 to indicate 1%) >>> ", + prompt_on_new=True, + default=Decimal("1"), + validator=lambda v: validate_decimal(v, 0, 100, inclusive=False), + type_str="decimal"), + "min_convergence": ConfigVar( + key="min_convergence", + prompt="What is the minimum spread between the spot and derivative market price before closing an existing arbitrage? (Enter 1 to indicate 1%) >>> ", + prompt_on_new=True, + default=Decimal("1"), + validator=lambda v: validate_decimal(v, 0, spot_perpetual_arbitrage_config_map["min_divergence"].value), + type_str="decimal"), + "maximize_funding_rate": ConfigVar( + key="maximize_funding_rate", + prompt="Would you like to take advantage of the funding rate on the derivative exchange, even if min convergence is reached during funding time? (True/False) >>> ", + prompt_on_new=True, + default=False, + validator=validate_bool, + type_str="bool"), + "spot_market_slippage_buffer": ConfigVar( + key="spot_market_slippage_buffer", + prompt="How much buffer do you want to add to the price to account for slippage for orders on the spot market " + "(Enter 1 for 1%)? >>> ", + prompt_on_new=True, + default=Decimal("0.05"), + validator=lambda v: validate_decimal(v), + type_str="decimal"), + "derivative_market_slippage_buffer": ConfigVar( + key="derivative_market_slippage_buffer", + prompt="How much buffer do you want to add to the price to account for slippage for orders on the derivative market" + " (Enter 1 for 1%)? >>> ", + prompt_on_new=True, + default=Decimal("0.05"), + validator=lambda v: validate_decimal(v), + type_str="decimal"), +} diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/start.py b/hummingbot/strategy/spot_perpetual_arbitrage/start.py new file mode 100644 index 0000000000..2f145f7dde --- /dev/null +++ b/hummingbot/strategy/spot_perpetual_arbitrage/start.py @@ -0,0 +1,37 @@ +from decimal import Decimal +from hummingbot.strategy.market_trading_pair_tuple import MarketTradingPairTuple +from hummingbot.strategy.spot_perpetual_arbitrage.spot_perpetual_arbitrage import SpotPerpetualArbitrageStrategy +from hummingbot.strategy.spot_perpetual_arbitrage.spot_perpetual_arbitrage_config_map import spot_perpetual_arbitrage_config_map + + +def start(self): + spot_connector = spot_perpetual_arbitrage_config_map.get("spot_connector").value.lower() + spot_market = spot_perpetual_arbitrage_config_map.get("spot_market").value + derivative_connector = spot_perpetual_arbitrage_config_map.get("derivative_connector").value.lower() + derivative_market = spot_perpetual_arbitrage_config_map.get("derivative_market").value + order_amount = spot_perpetual_arbitrage_config_map.get("order_amount").value + derivative_leverage = spot_perpetual_arbitrage_config_map.get("derivative_leverage").value + min_divergence = spot_perpetual_arbitrage_config_map.get("min_divergence").value / Decimal("100") + min_convergence = spot_perpetual_arbitrage_config_map.get("min_convergence").value / Decimal("100") + spot_market_slippage_buffer = spot_perpetual_arbitrage_config_map.get("spot_market_slippage_buffer").value / Decimal("100") + derivative_market_slippage_buffer = spot_perpetual_arbitrage_config_map.get("derivative_market_slippage_buffer").value / Decimal("100") + maximize_funding_rate = spot_perpetual_arbitrage_config_map.get("maximize_funding_rate").value + + self._initialize_markets([(spot_connector, [spot_market]), (derivative_connector, [derivative_market])]) + base_1, quote_1 = spot_market.split("-") + base_2, quote_2 = derivative_market.split("-") + self.assets = set([base_1, quote_1, base_2, quote_2]) + + spot_market_info = MarketTradingPairTuple(self.markets[spot_connector], spot_market, base_1, quote_1) + derivative_market_info = MarketTradingPairTuple(self.markets[derivative_connector], derivative_market, base_2, quote_2) + + self.market_trading_pair_tuples = [spot_market_info, derivative_market_info] + self.strategy = SpotPerpetualArbitrageStrategy(spot_market_info, + derivative_market_info, + order_amount, + derivative_leverage, + min_divergence, + min_convergence, + spot_market_slippage_buffer, + derivative_market_slippage_buffer, + maximize_funding_rate) diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/utils.py b/hummingbot/strategy/spot_perpetual_arbitrage/utils.py new file mode 100644 index 0000000000..f5b2e39936 --- /dev/null +++ b/hummingbot/strategy/spot_perpetual_arbitrage/utils.py @@ -0,0 +1,44 @@ +from decimal import Decimal +from typing import List +from hummingbot.strategy.market_trading_pair_tuple import MarketTradingPairTuple +from .data_types import ArbProposal, ArbProposalSide + +s_decimal_nan = Decimal("NaN") + + +async def create_arb_proposals(market_info_1: MarketTradingPairTuple, + market_info_2: MarketTradingPairTuple, + order_amount: Decimal) -> List[ArbProposal]: + """ + Creates base arbitrage proposals for given markets without any filtering. + :param market_info_1: The first market + :param market_info_2: The second market + :param order_amount: The required order amount. + :return A list of 2 proposal - (market_1 buy, market_2 sell) and (market_1 sell, market_2 buy) + """ + order_amount = Decimal(str(order_amount)) + results = [] + for index in range(0, 2): + is_buy = not bool(index) # bool(0) is False, so start with buy first + m_1_q_price = await market_info_1.market.get_quote_price(market_info_1.trading_pair, is_buy, order_amount) + m_1_o_price = await market_info_1.market.get_order_price(market_info_1.trading_pair, is_buy, order_amount) + m_2_q_price = await market_info_2.market.get_quote_price(market_info_2.trading_pair, not is_buy, order_amount) + m_2_o_price = await market_info_2.market.get_order_price(market_info_2.trading_pair, not is_buy, order_amount) + if any(p is None for p in (m_1_o_price, m_1_q_price, m_2_o_price, m_2_q_price)): + continue + first_side = ArbProposalSide( + market_info_1, + is_buy, + m_1_q_price, + m_1_o_price, + order_amount + ) + second_side = ArbProposalSide( + market_info_2, + not is_buy, + m_2_q_price, + m_2_o_price, + order_amount + ) + results.append(ArbProposal(first_side, second_side)) + return results diff --git a/hummingbot/templates/conf_spot_perpetual_arbitrage_strategy_TEMPLATE.yml b/hummingbot/templates/conf_spot_perpetual_arbitrage_strategy_TEMPLATE.yml new file mode 100644 index 0000000000..7945f553c4 --- /dev/null +++ b/hummingbot/templates/conf_spot_perpetual_arbitrage_strategy_TEMPLATE.yml @@ -0,0 +1,37 @@ +########################################## +### Spot-Perpetual Arbitrage strategy config ### +########################################## + +template_version: 1 +strategy: null + +# The following configuations are only required for the AMM arbitrage trading strategy + +# Connectors and markets parameters +spot_connector: null +spot_market: null +derivative_connector: null +derivative_market: null + +order_amount: null + +derivative_leverage: null + +# Spread required before first arbitrage can take place, expressed in percentage value, e.g. 1 = 1% +min_divergence: null + +# Spread required after first arbitrage before second arbitreage can take place, expressed in percentage value, e.g. 1 = 1% +min_convergence: null + +# A buffer for which to adjust order price for higher chance of the order getting filled. +# This is important for AMM which transaction takes a long time where a slippage is acceptable rather having +# the transaction get rejected. The submitted order price will be adjust higher (by percentage value) for buy order +# and lower for sell order. (Enter 1 for 1%) +spot_market_slippage_buffer: null + +# A buffer to add to the price to account for slippage when buying/selling on second connector market +# (Enter 1 for 1%) +derivative_market_slippage_buffer: null + +# A flag (true/false), if true the bot would only execute second arbitrage once funding payment is received provided second arbitrage wasn't executed before funding period. +maximize_funding_rate: null From f633fd471fe32b15824b8cc3bd67a5f0213e7586 Mon Sep 17 00:00:00 2001 From: vic-en Date: Tue, 23 Feb 2021 15:03:11 +0100 Subject: [PATCH 14/24] (feat) fix failed jenkins tests --- hummingbot/strategy/strategy_base.pxd | 1 + 1 file changed, 1 insertion(+) diff --git a/hummingbot/strategy/strategy_base.pxd b/hummingbot/strategy/strategy_base.pxd index efb35d8b09..0d5683434e 100644 --- a/hummingbot/strategy/strategy_base.pxd +++ b/hummingbot/strategy/strategy_base.pxd @@ -16,6 +16,7 @@ cdef class StrategyBase(TimeIterator): EventListener _sb_expire_order_listener EventListener _sb_complete_buy_order_listener EventListener _sb_complete_sell_order_listener + EventListener _sb_complete_funding_payment_listener bint _sb_delegate_lock public OrderTracker _sb_order_tracker From 009dfcab385d13272f61ea6d99cdb836909cb614 Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 24 Feb 2021 15:14:20 +0100 Subject: [PATCH 15/24] (fix) factor in leverage when checking balance requirement for derivatives --- .../spot_perpetual_arbitrage/spot_perpetual_arbitrage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py index 3ae784fe62..f3413e2505 100644 --- a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py +++ b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py @@ -264,7 +264,7 @@ def apply_budget_constraint(self, arb_proposal: ArbProposal): spot_token_balance = spot_market.get_available_balance(spot_token) deriv_token_balance = deriv_market.get_available_balance(deriv_token) required_spot_balance = arb_proposal.amount * arb_proposal.spot_side.order_price if arb_proposal.spot_side.is_buy else arb_proposal.amount - required_deriv_balance = arb_proposal.amount * arb_proposal.derivative_side.order_price + required_deriv_balance = (arb_proposal.amount * arb_proposal.derivative_side.order_price) / self._derivative_leverage if spot_token_balance < required_spot_balance: arb_proposal.amount = s_decimal_zero self.logger().info(f"Can't arbitrage, {spot_market.display_name} " From 98e06471ed874a72de58a5bbc764fdc25f858bc6 Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 24 Feb 2021 15:42:58 +0100 Subject: [PATCH 16/24] (feat) add trading pair validators --- .../spot_perpetual_arbitrage_config_map.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py index 8be3cd9653..f20b4403a1 100644 --- a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py +++ b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py @@ -1,5 +1,6 @@ from hummingbot.client.config.config_var import ConfigVar from hummingbot.client.config.config_validators import ( + validate_market_trading_pair, validate_connector, validate_derivative, validate_decimal, @@ -18,10 +19,20 @@ def exchange_on_validated(value: str) -> None: required_exchanges.append(value) +def spot_market_validator(value: str) -> None: + exchange = spot_perpetual_arbitrage_config_map["spot_connector"].value + return validate_market_trading_pair(exchange, value) + + def spot_market_on_validated(value: str) -> None: requried_connector_trading_pairs[spot_perpetual_arbitrage_config_map["spot_connector"].value] = [value] +def derivative_market_validator(value: str) -> None: + exchange = spot_perpetual_arbitrage_config_map["derivative_connector"].value + return validate_market_trading_pair(exchange, value) + + def derivative_market_on_validated(value: str) -> None: requried_connector_trading_pairs[spot_perpetual_arbitrage_config_map["derivative_connector"].value] = [value] @@ -61,6 +72,7 @@ def order_amount_prompt() -> str: key="spot_market", prompt=spot_market_prompt, prompt_on_new=True, + validator=spot_market_validator, on_validated=spot_market_on_validated), "derivative_connector": ConfigVar( key="derivative_connector", @@ -72,6 +84,7 @@ def order_amount_prompt() -> str: key="derivative_market", prompt=derivative_market_prompt, prompt_on_new=True, + validator=derivative_market_validator, on_validated=derivative_market_on_validated), "order_amount": ConfigVar( key="order_amount", From 7fca6387bc474f2811bb48c7469d9aa7c3fe7344 Mon Sep 17 00:00:00 2001 From: vic-en Date: Wed, 24 Feb 2021 20:10:39 +0100 Subject: [PATCH 17/24] (feat) refactor funding payment --- .../perpetual_finance_derivative.py | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index 48b86ea17f..75980610c1 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -85,7 +85,8 @@ def __init__(self, self._auto_approve_task = None self._real_time_balance_update = False self._poll_notifier = None - self._funding_payment_span = [1800, 0] + self._funding_payment_span = [120, 120] + self._fundingPayment = {} @property def name(self): @@ -494,16 +495,11 @@ async def _update_balances(self): async def _update_positions(self): position_tasks = [] - funding_payment_tasks = [] for pair in self._trading_pairs: position_tasks.append(self._api_request("post", "perpfi/position", {"pair": convert_to_exchange_trading_pair(pair)})) - funding_payment_tasks.append(self._api_request("get", - "perpfi/funding_payment", - {"pair": convert_to_exchange_trading_pair(pair)})) positions = await safe_gather(*position_tasks, return_exceptions=True) - funding_payments = await safe_gather(*funding_payment_tasks, return_exceptions=True) for trading_pair, position in zip(self._trading_pairs, positions): position = position.get("position", {}) position_side = PositionSide.LONG if position.get("size", 0) > 0 else PositionSide.SHORT @@ -524,17 +520,19 @@ async def _update_positions(self): if (trading_pair + position_side.name) in self._account_positions: del self._account_positions[trading_pair + position_side.name] - for trading_pair, funding_payment in zip(self._trading_pairs, funding_payments): - payment = Decimal(str(funding_payment.payment)) - action = "paid" if payment < 0 else "received" - if payment != Decimal("0"): - self.logger().info(f"Funding payment of {payment} {action} on {trading_pair} market.") - self.trigger_event(MarketEvent.FundingPaymentCompleted, - FundingPaymentCompletedEvent(timestamp=funding_payment.timestamp, - market=self.name, - rate=self._funding_info[trading_pair]["rate"], - symbol=trading_pair, - amount=payment)) + payment = Decimal(str(position.fundingPayment)) + oldPayment = self._fundingPayment.get(trading_pair, 0) + if payment != oldPayment: + self._fundingPayment = oldPayment + action = "paid" if payment < 0 else "received" + if payment != Decimal("0"): + self.logger().info(f"Funding payment of {payment} {action} on {trading_pair} market.") + self.trigger_event(MarketEvent.FundingPaymentCompleted, + FundingPaymentCompletedEvent(timestamp=time.time(), + market=self.name, + rate=self._funding_info[trading_pair]["rate"], + symbol=trading_pair, + amount=payment)) async def _funding_info_polling_loop(self): while True: From f80aa356483c97274ff28e02ce34a8620171492f Mon Sep 17 00:00:00 2001 From: vic-en Date: Thu, 25 Feb 2021 13:16:04 +0100 Subject: [PATCH 18/24] (feat) fix incorrect key for spot connector in config map --- .../spot_perpetual_arbitrage_config_map.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py index f20b4403a1..ae5083d55b 100644 --- a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py +++ b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage_config_map.py @@ -63,7 +63,7 @@ def order_amount_prompt() -> str: prompt="", default="spot_perpetual_arbitrage"), "spot_connector": ConfigVar( - key="connector_1", + key="spot_connector", prompt="Enter a spot connector (Exchange/AMM) >>> ", prompt_on_new=True, validator=validate_connector, From 5dfd53e481bfa928b5c3372a2de6648805408341 Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 1 Mar 2021 01:26:32 +0100 Subject: [PATCH 19/24] (fix) made request changes and fixed some bugs related to funding payment completion --- .../binance_perpetual_derivative.py | 4 +-- .../perpetual_finance_derivative.py | 34 +++++++++++-------- .../perpetual_finance_in_flight_order.py | 17 ---------- .../perpetual_finance_utils.py | 4 +-- hummingbot/core/event/events.py | 1 + .../spot_perpetual_arbitrage/arb_proposal.py | 1 - .../spot_perpetual_arbitrage.py | 15 ++++---- 7 files changed, 33 insertions(+), 43 deletions(-) diff --git a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py index 8a612e7890..94a4bbb2e5 100644 --- a/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py +++ b/hummingbot/connector/derivative/binance_perpetual/binance_perpetual_derivative.py @@ -947,8 +947,8 @@ async def get_funding_payment(self): self.trigger_event(self.MARKET_FUNDING_PAYMENT_COMPLETED_EVENT_TAG, FundingPaymentCompletedEvent(timestamp=funding_payment["time"], market=self.name, - rate=self._funding_info[trading_pair]["rate"], - symbol=trading_pair, + funding_rate=self._funding_info[trading_pair]["rate"], + trading_pair=trading_pair, amount=payment)) def get_funding_info(self, trading_pair): diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index 75980610c1..17388e23e9 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -8,6 +8,7 @@ import ssl import copy from hummingbot.logger.struct_logger import METRICS_LOG_LEVEL +from hummingbot.core.event.events import TradeFee from hummingbot.core.utils import async_ttl_cache from hummingbot.core.network_iterator import NetworkStatus from hummingbot.core.utils.async_utils import safe_ensure_future, safe_gather @@ -180,7 +181,7 @@ async def get_order_price(self, trading_pair: str, is_buy: bool, amount: Decimal """ return await self.get_quote_price(trading_pair, is_buy, amount) - def buy(self, trading_pair: str, amount: Decimal, order_type: OrderType, price: Decimal, position_action: PositionAction) -> str: + def buy(self, trading_pair: str, amount: Decimal, order_type: OrderType, price: Decimal, **kwargs) -> str: """ Buys an amount of base token for a given price (or cheaper). :param trading_pair: The market trading pair @@ -190,9 +191,9 @@ def buy(self, trading_pair: str, amount: Decimal, order_type: OrderType, price: :param position_action: Either OPEN or CLOSE position action. :return: A newly created order id (internal). """ - return self.place_order(True, trading_pair, amount, price, position_action) + return self.place_order(True, trading_pair, amount, price, kwargs["position_action"]) - def sell(self, trading_pair: str, amount: Decimal, order_type: OrderType, price: Decimal, position_action: PositionAction) -> str: + def sell(self, trading_pair: str, amount: Decimal, order_type: OrderType, price: Decimal, **kwargs) -> str: """ Sells an amount of base token for a given price (or at a higher price). :param trading_pair: The market trading pair @@ -202,7 +203,7 @@ def sell(self, trading_pair: str, amount: Decimal, order_type: OrderType, price: :param position_action: Either OPEN or CLOSE position action. :return: A newly created order id (internal). """ - return self.place_order(False, trading_pair, amount, price, position_action) + return self.place_order(False, trading_pair, amount, price, kwargs["position_action"]) def place_order(self, is_buy: bool, trading_pair: str, amount: Decimal, price: Decimal, position_action: PositionAction) -> str: """ @@ -242,9 +243,9 @@ async def _create_order(self, api_params = {"pair": convert_to_exchange_trading_pair(trading_pair)} if position_action == PositionAction.OPEN: api_params.update({"side": 0 if trade_type == TradeType.BUY else 1, - "margin": str(amount / self._leverage[trading_pair]), + "margin": self.quantize_order_amount(trading_pair, (amount / self._leverage[trading_pair])), "leverage": self._leverage[trading_pair], - "minBaseAssetAmount": amount}) + "minBaseAssetAmount": Decimal("0")}) else: api_params.update({"minimalQuoteAsset": price * amount}) self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, self._leverage[trading_pair], position_action.name) @@ -337,7 +338,7 @@ async def _update_order_status(self): if update_result["confirmed"] is True: if update_result["receipt"]["status"] == 1: fee = estimate_fee("perpetual_finance", False) - fee.flat_fees = [(tracked_order.fee_asset, Decimal(str(update_result["receipt"]["gasUsed"])))] + fee = TradeFee(fee.percent, [("XDAI", Decimal(str(update_result["receipt"]["gasUsed"])))]) self.trigger_event( MarketEvent.OrderFilled, OrderFilledEvent( @@ -370,7 +371,9 @@ async def _update_order_status(self): tracked_order.fee_asset, tracked_order.executed_amount_base, tracked_order.executed_amount_quote, - float(fee), + float(fee.fee_amount_in_quote(tracked_order.trading_pair, + Decimal(str(tracked_order.price)), + Decimal(str(tracked_order.amount)))), # this ignores the gas fee, which is fine for now tracked_order.order_type)) self.stop_tracking_order(tracked_order.client_order_id) else: @@ -388,10 +391,10 @@ def get_taker_order_type(self): return OrderType.LIMIT def get_order_price_quantum(self, trading_pair: str, price: Decimal) -> Decimal: - return Decimal("1e-15") + return Decimal("1e-6") def get_order_size_quantum(self, trading_pair: str, order_size: Decimal) -> Decimal: - return Decimal("1e-15") + return Decimal("1e-6") @property def ready(self): @@ -454,6 +457,7 @@ async def _status_polling_loop(self): self._poll_notifier = asyncio.Event() await self._poll_notifier.wait() await safe_gather( + self._update_positions(), self._update_balances(), self._update_order_status(), ) @@ -502,7 +506,7 @@ async def _update_positions(self): positions = await safe_gather(*position_tasks, return_exceptions=True) for trading_pair, position in zip(self._trading_pairs, positions): position = position.get("position", {}) - position_side = PositionSide.LONG if position.get("size", 0) > 0 else PositionSide.SHORT + position_side = PositionSide.LONG if Decimal(position.get("size", "0")) > 0 else PositionSide.SHORT unrealized_pnl = Decimal(position.get("pnl")) entry_price = Decimal(position.get("entryPrice")) amount = Decimal(position.get("size")) @@ -520,18 +524,18 @@ async def _update_positions(self): if (trading_pair + position_side.name) in self._account_positions: del self._account_positions[trading_pair + position_side.name] - payment = Decimal(str(position.fundingPayment)) + payment = Decimal(str(position.get("fundingPayment"))) oldPayment = self._fundingPayment.get(trading_pair, 0) if payment != oldPayment: - self._fundingPayment = oldPayment + self._fundingPayment[trading_pair] = oldPayment action = "paid" if payment < 0 else "received" if payment != Decimal("0"): self.logger().info(f"Funding payment of {payment} {action} on {trading_pair} market.") self.trigger_event(MarketEvent.FundingPaymentCompleted, FundingPaymentCompletedEvent(timestamp=time.time(), market=self.name, - rate=self._funding_info[trading_pair]["rate"], - symbol=trading_pair, + funding_rate=self._funding_info[trading_pair]["rate"], + trading_pair=trading_pair, amount=payment)) async def _funding_info_polling_loop(self): diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py index 3dbaad3128..ff2df9b255 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_in_flight_order.py @@ -31,7 +31,6 @@ def __init__(self, amount, initial_state, ) - self.trade_id_set = set() self.leverage = leverage self.position = position @@ -46,19 +45,3 @@ def is_failure(self) -> bool: @property def is_cancelled(self) -> bool: return self.last_state in {"CANCELED", "EXPIRED"} - - @property - def leverage(self) -> Decimal: - return self.leverage - - @leverage.setter - def leverage(self, leverage) -> Decimal: - self.leverage = leverage - - @property - def position(self) -> Decimal: - return self.position - - @position.setter - def position(self, position) -> Decimal: - self.position = position diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_utils.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_utils.py index 665a0a34e1..d7f1b5f60a 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_utils.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_utils.py @@ -6,8 +6,8 @@ DEFAULT_FEES = [0.1, 0.1] USE_ETHEREUM_WALLET = True -FEE_TYPE = "FlatFee" -FEE_TOKEN = "XDAI" +# FEE_TYPE = "FlatFee" +# FEE_TOKEN = "XDAI" USE_ETH_GAS_LOOKUP = False diff --git a/hummingbot/core/event/events.py b/hummingbot/core/event/events.py index c4a02d1e76..055f7d2cac 100644 --- a/hummingbot/core/event/events.py +++ b/hummingbot/core/event/events.py @@ -207,6 +207,7 @@ class OrderExpiredEvent(NamedTuple): @dataclass class FundingPaymentCompletedEvent: timestamp: float + market: str trading_pair: str amount: Decimal funding_rate: Decimal diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/arb_proposal.py b/hummingbot/strategy/spot_perpetual_arbitrage/arb_proposal.py index d6c78cffff..c24a85eec0 100644 --- a/hummingbot/strategy/spot_perpetual_arbitrage/arb_proposal.py +++ b/hummingbot/strategy/spot_perpetual_arbitrage/arb_proposal.py @@ -19,7 +19,6 @@ def __init__(self, """ :param market_info: The market where to submit the order :param is_buy: True if buy order - :param quote_price: The quote price (for an order amount) from the market :param order_price: The price required for order submission, this could differ from the quote price :param amount: The order amount """ diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py index f3413e2505..824280d0f6 100644 --- a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py +++ b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py @@ -169,6 +169,8 @@ async def main(self, timestamp): funding_msg = "Time for funding payment, executing second arbitrage " \ "immediately since we don't intend to maximize funding rate" execute_arb = True + else: + funding_msg = "Funding payment time, not looking for arbitrage opportunity because prices should be converging now!" else: if len(self.deriv_position) > 0: execute_arb = self.ready_for_execution(self.current_proposal, False) @@ -181,8 +183,10 @@ async def main(self, timestamp): self.apply_budget_constraint(self.current_proposal) await self.execute_arb_proposals(self.current_proposal, funding_msg) else: - self.timed_logger(timestamp, self.spread_msg()) - return + if funding_msg: + self.timed_logger(timestamp, funding_msg) + else: + self.timed_logger(timestamp, self.spread_msg()) def timed_logger(self, timestamp, msg): """ @@ -238,7 +242,7 @@ def apply_slippage_buffers(self, arb_proposal: ArbProposal): Updates arb_proposals by adjusting order price for slipper buffer percentage. E.g. if it is a buy order, for an order price of 100 and 1% slipper buffer, the new order price is 101, for a sell order, the new order price is 99. - :param arb_proposals: the arbitrage proposal + :param arb_proposal: the arbitrage proposal """ for arb_side in (arb_proposal.spot_side, arb_proposal.derivative_side): market = arb_side.market_info.market @@ -255,7 +259,7 @@ def apply_budget_constraint(self, arb_proposal: ArbProposal): """ Updates arb_proposals by setting proposal amount to 0 if there is not enough balance to submit order with required order amount. - :param arb_proposals: the arbitrage proposal + :param arb_proposal: the arbitrage proposal """ spot_market = self._spot_market_info.market deriv_market = self._derivative_market_info.market @@ -275,7 +279,6 @@ def apply_budget_constraint(self, arb_proposal: ArbProposal): self.logger().info(f"Can't arbitrage, {deriv_market.display_name} " f"{deriv_token} balance " f"({deriv_token_balance}) is below required order amount ({required_deriv_balance}).") - return async def execute_arb_proposals(self, arb_proposal: ArbProposal, is_funding_msg: str = ""): """ @@ -444,7 +447,7 @@ def did_expire_order(self, expired_event): def did_complete_funding_payment(self, funding_payment_completed_event): # Excute second arbitrage if necessary (even spread hasn't reached min convergence) - if len(self.deriv_position) > 0: + if len(self.deriv_position) > 0 and self.ready_for_new_arb_trades(): self.apply_slippage_buffers(self.current_proposal) self.apply_budget_constraint(self.current_proposal) funding_msg = "Executing second arbitrage after funding payment is received" From a2f4ee5b2b07ed97aeae4ab216e8d6118e41cdd3 Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 1 Mar 2021 10:47:23 +0100 Subject: [PATCH 20/24] (fix) fix perp protocol update_position_function --- .../perpetual_finance_derivative.py | 61 ++++++++++--------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index 17388e23e9..b752a5bf35 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -506,37 +506,38 @@ async def _update_positions(self): positions = await safe_gather(*position_tasks, return_exceptions=True) for trading_pair, position in zip(self._trading_pairs, positions): position = position.get("position", {}) - position_side = PositionSide.LONG if Decimal(position.get("size", "0")) > 0 else PositionSide.SHORT - unrealized_pnl = Decimal(position.get("pnl")) - entry_price = Decimal(position.get("entryPrice")) amount = Decimal(position.get("size")) - leverage = self._leverage[trading_pair] - if amount != 0: - self._account_positions[trading_pair + position_side.name] = Position( - trading_pair=trading_pair, - position_side=position_side, - unrealized_pnl=unrealized_pnl, - entry_price=entry_price, - amount=amount, - leverage=leverage - ) - else: - if (trading_pair + position_side.name) in self._account_positions: - del self._account_positions[trading_pair + position_side.name] - - payment = Decimal(str(position.get("fundingPayment"))) - oldPayment = self._fundingPayment.get(trading_pair, 0) - if payment != oldPayment: - self._fundingPayment[trading_pair] = oldPayment - action = "paid" if payment < 0 else "received" - if payment != Decimal("0"): - self.logger().info(f"Funding payment of {payment} {action} on {trading_pair} market.") - self.trigger_event(MarketEvent.FundingPaymentCompleted, - FundingPaymentCompletedEvent(timestamp=time.time(), - market=self.name, - funding_rate=self._funding_info[trading_pair]["rate"], - trading_pair=trading_pair, - amount=payment)) + if amount != Decimal("0"): + position_side = PositionSide.LONG if amount > 0 else PositionSide.SHORT + unrealized_pnl = Decimal(position.get("pnl")) + entry_price = Decimal(position.get("entryPrice")) + leverage = self._leverage[trading_pair] + if amount != 0: + self._account_positions[trading_pair + position_side.name] = Position( + trading_pair=trading_pair, + position_side=position_side, + unrealized_pnl=unrealized_pnl, + entry_price=entry_price, + amount=amount, + leverage=leverage + ) + else: + if (trading_pair + position_side.name) in self._account_positions: + del self._account_positions[trading_pair + position_side.name] + + payment = Decimal(str(position.get("fundingPayment"))) + oldPayment = self._fundingPayment.get(trading_pair, 0) + if payment != oldPayment: + self._fundingPayment[trading_pair] = oldPayment + action = "paid" if payment < 0 else "received" + if payment != Decimal("0"): + self.logger().info(f"Funding payment of {payment} {action} on {trading_pair} market.") + self.trigger_event(MarketEvent.FundingPaymentCompleted, + FundingPaymentCompletedEvent(timestamp=time.time(), + market=self.name, + funding_rate=self._funding_info[trading_pair]["rate"], + trading_pair=trading_pair, + amount=payment)) async def _funding_info_polling_loop(self): while True: From ae7ddc9b06591e2beeaa2848f2ec52bfd8680ba9 Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 1 Mar 2021 14:46:16 +0100 Subject: [PATCH 21/24] (fix) alternate logs when waiting for funding payment --- .../spot_perpetual_arbitrage/spot_perpetual_arbitrage.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py index 824280d0f6..852502859e 100644 --- a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py +++ b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py @@ -162,9 +162,9 @@ async def main(self, timestamp): if self._maximize_funding_rate: execute_arb = not self.would_receive_funding_payment(self.deriv_position) if execute_arb: - self.timed_logger(timestamp, "Waiting for funding payment.") - else: funding_msg = "Time for funding payment, executing second arbitrage to prevent paying funding fee" + else: + funding_msg = "Waiting for funding payment." else: funding_msg = "Time for funding payment, executing second arbitrage " \ "immediately since we don't intend to maximize funding rate" From 28f4dd1e8c92548a7e448e138b4edc81f31ccd0e Mon Sep 17 00:00:00 2001 From: vic-en Date: Mon, 1 Mar 2021 22:09:03 +0100 Subject: [PATCH 22/24] (fix) fixed some errors related to perpetual protocol --- ...tual_finance_api_order_book_data_source.py | 10 +++++++ .../perpetual_finance_derivative.py | 28 +++++++++---------- .../spot_perpetual_arbitrage.py | 16 +++++++---- 3 files changed, 34 insertions(+), 20 deletions(-) diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_api_order_book_data_source.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_api_order_book_data_source.py index 4abc57b74c..2df75a53be 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_api_order_book_data_source.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_api_order_book_data_source.py @@ -2,6 +2,7 @@ from typing import List import json import ssl +from typing import Dict from hummingbot.client.config.global_config_map import global_config_map from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_utils import convert_from_exchange_trading_pair @@ -41,3 +42,12 @@ async def fetch_trading_pairs() -> List[str]: for pair in pairs: trading_pairs.append(convert_from_exchange_trading_pair(pair)) return trading_pairs + + @classmethod + async def get_last_traded_prices(cls, trading_pairs: List[str]) -> Dict[str, float]: + """ + This function doesn't really need to return a value. + It is only currently used for performance calculation which will in turn use the last price of the last trades + if None is returned. + """ + pass diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index b752a5bf35..e8ea75a72a 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -243,11 +243,12 @@ async def _create_order(self, api_params = {"pair": convert_to_exchange_trading_pair(trading_pair)} if position_action == PositionAction.OPEN: api_params.update({"side": 0 if trade_type == TradeType.BUY else 1, - "margin": self.quantize_order_amount(trading_pair, (amount / self._leverage[trading_pair])), + "margin": self.quantize_order_amount(trading_pair, (amount / self._leverage[trading_pair] * price)), "leverage": self._leverage[trading_pair], "minBaseAssetAmount": Decimal("0")}) else: - api_params.update({"minimalQuoteAsset": price * amount}) + # api_params.update({"minimalQuoteAsset": price * amount}) + api_params.update({"minimalQuoteAsset": Decimal("0")}) self.start_tracking_order(order_id, None, trading_pair, trade_type, price, amount, self._leverage[trading_pair], position_action.name) try: order_result = await self._api_request("post", f"perpfi/{position_action.name.lower()}", api_params) @@ -512,18 +513,17 @@ async def _update_positions(self): unrealized_pnl = Decimal(position.get("pnl")) entry_price = Decimal(position.get("entryPrice")) leverage = self._leverage[trading_pair] - if amount != 0: - self._account_positions[trading_pair + position_side.name] = Position( - trading_pair=trading_pair, - position_side=position_side, - unrealized_pnl=unrealized_pnl, - entry_price=entry_price, - amount=amount, - leverage=leverage - ) - else: - if (trading_pair + position_side.name) in self._account_positions: - del self._account_positions[trading_pair + position_side.name] + self._account_positions[trading_pair] = Position( + trading_pair=trading_pair, + position_side=position_side, + unrealized_pnl=unrealized_pnl, + entry_price=entry_price, + amount=amount, + leverage=leverage + ) + else: + if trading_pair in self._account_positions: + del self._account_positions[trading_pair] payment = Decimal(str(position.get("fundingPayment"))) oldPayment = self._fundingPayment.get(trading_pair, 0) diff --git a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py index 852502859e..6838e5d20e 100644 --- a/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py +++ b/hummingbot/strategy/spot_perpetual_arbitrage/spot_perpetual_arbitrage.py @@ -369,10 +369,8 @@ def spread_msg(self): def active_positions_df(self) -> pd.DataFrame: columns = ["Symbol", "Type", "Entry Price", "Amount", "Leverage", "Unrealized PnL"] data = [] - market, trading_pair = self._derivative_market_info.market, self._derivative_market_info.trading_pair for idx in self.deriv_position: - is_buy = True if idx.amount > 0 else False - unrealized_profit = ((market.get_price(trading_pair, is_buy) - idx.entry_price) * idx.amount) + unrealized_profit = ((self.current_proposal.derivative_side.order_price - idx.entry_price) * idx.amount) data.append([ idx.trading_pair, idx.position_side.name, @@ -418,8 +416,11 @@ async def format_status(self) -> str: lines.extend(["", " Assets:"] + [" " + line for line in str(assets_df).split("\n")]) - lines.extend(["", " Spread details:"] + [" " + self.spread_msg()] + - self.short_proposal_msg()) + try: + lines.extend(["", " Spread details:"] + [" " + self.spread_msg()] + + self.short_proposal_msg()) + except Exception: + pass warning_lines = self.network_warning([self._spot_market_info]) warning_lines.extend(self.network_warning([self._derivative_market_info])) @@ -447,7 +448,10 @@ def did_expire_order(self, expired_event): def did_complete_funding_payment(self, funding_payment_completed_event): # Excute second arbitrage if necessary (even spread hasn't reached min convergence) - if len(self.deriv_position) > 0 and self.ready_for_new_arb_trades(): + if len(self.deriv_position) > 0 and \ + self._all_markets_ready and \ + self.current_proposal and \ + self.ready_for_new_arb_trades(): self.apply_slippage_buffers(self.current_proposal) self.apply_budget_constraint(self.current_proposal) funding_msg = "Executing second arbitrage after funding payment is received" From 8770d2018287cc8a1a714b4409425cd4ff5c6190 Mon Sep 17 00:00:00 2001 From: vic-en Date: Thu, 4 Mar 2021 10:33:19 +0100 Subject: [PATCH 23/24] (feat) ajusted precision of parameters of an active position --- .../perpetual_finance/perpetual_finance_derivative.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py index e8ea75a72a..73eea1569f 100644 --- a/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py +++ b/hummingbot/connector/derivative/perpetual_finance/perpetual_finance_derivative.py @@ -507,11 +507,11 @@ async def _update_positions(self): positions = await safe_gather(*position_tasks, return_exceptions=True) for trading_pair, position in zip(self._trading_pairs, positions): position = position.get("position", {}) - amount = Decimal(position.get("size")) + amount = self.quantize_order_amount(trading_pair, Decimal(position.get("size"))) if amount != Decimal("0"): position_side = PositionSide.LONG if amount > 0 else PositionSide.SHORT - unrealized_pnl = Decimal(position.get("pnl")) - entry_price = Decimal(position.get("entryPrice")) + unrealized_pnl = self.quantize_order_amount(trading_pair, Decimal(position.get("pnl"))) + entry_price = self.quantize_order_price(trading_pair, Decimal(position.get("entryPrice"))) leverage = self._leverage[trading_pair] self._account_positions[trading_pair] = Position( trading_pair=trading_pair, From f0c402497ada0c1feccafc725b9388165fc2e960 Mon Sep 17 00:00:00 2001 From: vic-en Date: Thu, 4 Mar 2021 11:40:15 +0100 Subject: [PATCH 24/24] (feat) add test cases for perpfi connector --- .../connector/perpfi/test_perfi_connector.py | 209 ++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 test/connector/connector/perpfi/test_perfi_connector.py diff --git a/test/connector/connector/perpfi/test_perfi_connector.py b/test/connector/connector/perpfi/test_perfi_connector.py new file mode 100644 index 0000000000..dc0ec26c16 --- /dev/null +++ b/test/connector/connector/perpfi/test_perfi_connector.py @@ -0,0 +1,209 @@ +from os.path import join, realpath +import sys; sys.path.insert(0, realpath(join(__file__, "../../../../../"))) +import unittest +import unittest.mock +import asyncio +import os +from decimal import Decimal +from typing import List +import contextlib +import time +from hummingbot.core.clock import Clock, ClockMode +from hummingbot.core.event.event_logger import EventLogger +from hummingbot.connector.derivative.perpetual_finance.perpetual_finance_derivative import PerpetualFinanceDerivative +from hummingbot.core.event.events import ( + BuyOrderCompletedEvent, + MarketEvent, + OrderType, + SellOrderCompletedEvent, + MarketOrderFailureEvent, + PositionAction, PositionMode +) +from hummingbot.model.sql_connection_manager import ( + SQLConnectionManager, + SQLConnectionType +) +from hummingbot.model.trade_fill import TradeFill +from hummingbot.connector.markets_recorder import MarketsRecorder +from hummingbot.client.config.global_config_map import global_config_map + +global_config_map['gateway_api_host'].value = "localhost" +global_config_map['gateway_api_port'].value = 5000 +global_config_map.get("ethereum_chain_name").value = "mainnet" + +trading_pair = "SNX-USDC" +leverage = 3 +base, quote = trading_pair.split("-") + + +class PerpetualFinanceDerivativeUnitTest(unittest.TestCase): + event_logger: EventLogger + events: List[MarketEvent] = [ + MarketEvent.BuyOrderCompleted, + MarketEvent.SellOrderCompleted, + MarketEvent.OrderFilled, + MarketEvent.TransactionFailure, + MarketEvent.BuyOrderCreated, + MarketEvent.SellOrderCreated, + MarketEvent.OrderCancelled, + MarketEvent.OrderFailure, + MarketEvent.FundingPaymentCompleted + ] + connector: PerpetualFinanceDerivative + stack: contextlib.ExitStack + + @classmethod + def setUpClass(cls): + cls.ev_loop = asyncio.get_event_loop() + cls.clock: Clock = Clock(ClockMode.REALTIME) + cls.connector: PerpetualFinanceDerivative = PerpetualFinanceDerivative( + [trading_pair], + "PRIVATE_KEY_HERE", + "") + print("Initializing PerpetualFinanceDerivative market... this will take about a minute.") + cls.connector.set_leverage(trading_pair, leverage) + cls.connector.set_position_mode(PositionMode.ONEWAY) + cls.clock.add_iterator(cls.connector) + cls.stack: contextlib.ExitStack = contextlib.ExitStack() + cls._clock = cls.stack.enter_context(cls.clock) + cls.ev_loop.run_until_complete(cls.wait_til_ready()) + print("Ready.") + + @classmethod + def tearDownClass(cls) -> None: + cls.stack.close() + + @classmethod + async def wait_til_ready(cls): + while True: + now = time.time() + next_iteration = now // 1.0 + 1 + if cls.connector.ready: + break + else: + await cls._clock.run_til(next_iteration) + await asyncio.sleep(1.0) + + def setUp(self): + self.db_path: str = realpath(join(__file__, "../connector_test.sqlite")) + try: + os.unlink(self.db_path) + except FileNotFoundError: + pass + self.event_logger = EventLogger() + for event_tag in self.events: + self.connector.add_listener(event_tag, self.event_logger) + + def test_update_balances(self): + all_bals = self.connector.get_all_balances() + for token, bal in all_bals.items(): + print(f"{token}: {bal}") + self.assertIn(quote, all_bals) + self.assertTrue(all_bals["XDAI"] > 0) + + def test_allowances(self): + asyncio.get_event_loop().run_until_complete(self._test_allowances()) + + async def _test_allowances(self): + perfi = self.connector + allowances = await perfi.get_allowances() + print(allowances) + + def test_approve(self): + asyncio.get_event_loop().run_until_complete(self._test_approve()) + + async def _test_approve(self): + perfi = self.connector + ret_val = await perfi.approve_perpetual_finance_spender() + print(ret_val) + + def test_get_quote_price(self): + asyncio.get_event_loop().run_until_complete(self._test_get_quote_price()) + + async def _test_get_quote_price(self): + perfi = self.connector + buy_price = await perfi.get_quote_price(trading_pair, True, Decimal("1")) + self.assertTrue(buy_price > 0) + print(f"buy_price: {buy_price}") + sell_price = await perfi.get_quote_price(trading_pair, False, Decimal("1")) + self.assertTrue(sell_price > 0) + print(f"sell_price: {sell_price}") + self.assertTrue(buy_price != sell_price) + + def test_open_and_close_long_position(self): + perfi = self.connector + amount = Decimal("0.1") + price = Decimal("10") + order_id = perfi.buy(trading_pair, amount, OrderType.LIMIT, price, position_action=PositionAction.OPEN) + event = self.ev_loop.run_until_complete(self.event_logger.wait_for(BuyOrderCompletedEvent)) + self.assertTrue(event.order_id is not None) + self.assertEqual(order_id, event.order_id) + self.assertEqual(event.base_asset_amount, amount) + print(event.order_id) + + order_id = perfi.sell(trading_pair, amount, OrderType.LIMIT, price, position_action=PositionAction.CLOSE) + event = self.ev_loop.run_until_complete(self.event_logger.wait_for(SellOrderCompletedEvent)) + self.assertTrue(event.order_id is not None) + self.assertEqual(order_id, event.order_id) + self.assertEqual(event.base_asset_amount, amount) + print(event.order_id) + + def test_open_position_failure(self): + perfi = self.connector + # Since we don't have 1000000 xUSDC, this should trigger order failure + amount = Decimal("1000000") + price = Decimal("10") + order_id = perfi.sell(trading_pair, amount, OrderType.LIMIT, price, position_action=PositionAction.OPEN) + event = self.ev_loop.run_until_complete(self.event_logger.wait_for(MarketOrderFailureEvent)) + self.assertEqual(order_id, event.order_id) + + def test_filled_orders_recorded(self): + config_path = "test_config" + strategy_name = "test_strategy" + sql = SQLConnectionManager(SQLConnectionType.TRADE_FILLS, db_path=self.db_path) + recorder = MarketsRecorder(sql, [self.connector], config_path, strategy_name) + recorder.start() + try: + self.connector._in_flight_orders.clear() + self.assertEqual(0, len(self.connector.tracking_states)) + + price: Decimal = Decimal("10") # quote_price * Decimal("0.8") + price = self.connector.quantize_order_price(trading_pair, price) + + amount: Decimal = Decimal("0.1") + amount = self.connector.quantize_order_amount(trading_pair, amount) + + sell_order_id = self.connector.sell(trading_pair, amount, OrderType.LIMIT, price, position_action=PositionAction.OPEN) + self.ev_loop.run_until_complete(self.event_logger.wait_for(SellOrderCompletedEvent)) + self.ev_loop.run_until_complete(asyncio.sleep(1)) + + price: Decimal = Decimal("10") # quote_price * Decimal("0.8") + price = self.connector.quantize_order_price(trading_pair, price) + + buy_order_id = self.connector.buy(trading_pair, amount, OrderType.LIMIT, price, position_action=PositionAction.CLOSE) + self.ev_loop.run_until_complete(self.event_logger.wait_for(BuyOrderCompletedEvent)) + self.ev_loop.run_until_complete(asyncio.sleep(1)) + + # Query the persisted trade logs + trade_fills: List[TradeFill] = recorder.get_trades_for_config(config_path) + # self.assertGreaterEqual(len(trade_fills), 2) + fills: List[TradeFill] = [t for t in trade_fills if t.trade_type == "SELL"] + self.assertGreaterEqual(len(fills), 1) + self.assertEqual(amount, Decimal(str(fills[0].amount))) + # self.assertEqual(price, Decimal(str(fills[0].price))) + self.assertEqual(base, fills[0].base_asset) + self.assertEqual(quote, fills[0].quote_asset) + self.assertEqual(sell_order_id, fills[0].order_id) + self.assertEqual(trading_pair, fills[0].symbol) + fills: List[TradeFill] = [t for t in trade_fills if t.trade_type == "BUY"] + self.assertGreaterEqual(len(fills), 1) + self.assertEqual(amount, Decimal(str(fills[0].amount))) + # self.assertEqual(price, Decimal(str(fills[0].price))) + self.assertEqual(base, fills[0].base_asset) + self.assertEqual(quote, fills[0].quote_asset) + self.assertEqual(buy_order_id, fills[0].order_id) + self.assertEqual(trading_pair, fills[0].symbol) + + finally: + recorder.stop() + os.unlink(self.db_path)