diff --git a/setup.py b/setup.py index 0ff4f10..a349c5d 100644 --- a/setup.py +++ b/setup.py @@ -11,7 +11,7 @@ setup( name='vcx_py', - version='1.0.6', + version='1.1.0', packages=['vcx_py'], license='MIT', author='Aaron Janeiro Stone', diff --git a/vcx_py/client.py b/vcx_py/client.py index 4deded2..765f3e3 100644 --- a/vcx_py/client.py +++ b/vcx_py/client.py @@ -8,15 +8,23 @@ # SOFTWARE. from typing import Optional +from threading import Lock +from warnings import warn import requests as rq from .constants import ROOT_ADDRESS, VERIFICATION, KLineType, OrderStatus, \ OrderType, OrderDirection -from .utils import vcx_sign, result_formatter +from .utils import VirgoCXWarning, VirgoCXException, vcx_sign, result_formatter class VirgoCXClient: + """ + A simple python client for the VirgoCX API. + """ + FMT_DATA = None # created by first instance + STATIC_LOCK = Lock() # in case of multithreading, locks the formatting cache + def __init__(self, api_key: str = None, api_secret: str = None): # Prevents the api key and secret from being visible as class attributes def _api_key(): @@ -28,6 +36,13 @@ def signer(dct: dict): self.signer = signer self._api_key = _api_key + with VirgoCXClient.STATIC_LOCK: + if VirgoCXClient.FMT_DATA is None: + VirgoCXClient.FMT_DATA = {v["symbol"]: {"price_decimals": v["priceDecimals"], + "qty_decimals": v["qtyDecimals"], + "min_total": v["minTotal"]} for v in + self.tickers()} + @result_formatter() def kline(self, symbol: str, period: KLineType): """ @@ -96,7 +111,8 @@ def query_trades(self, symbol: str): @result_formatter() def place_order(self, symbol: str, category: OrderType, direction: OrderDirection, price: Optional[float] = None, qty: Optional[float] = None, - total: Optional[float] = None): + total: Optional[float] = None, handle_conversions: bool = True, + **kwargs): """ Places an order. @@ -106,35 +122,86 @@ def place_order(self, symbol: str, category: OrderType, direction: OrderDirectio :param price: The price of the order (optional). :param qty: The quantity of the order in terms of the cryptocurrency (optional). :param total: The total value of the order in terms of the fiat currency (optional). + :param handle_conversions: Whether to handle conversions between `qty`, and `total` (optional, default True). + Might require additional API calls in order to determine the market price. - Note that `price` is required for limit orders and `total` is required for non-limit buy orders - (otherwise `qty` is required). + Note that without `handle_conversions`, `price` is required for limit orders and `total` is required for + non-limit buy orders (otherwise `qty` is required). """ if isinstance(category, OrderType): category = category.value if isinstance(direction, OrderDirection): direction = direction.value + # Handle conversions if category == OrderType.LIMIT: if price is None: raise ValueError("Price is required for limit orders") if qty is None: - raise ValueError("Quantity is required for limit orders") - else: + if not handle_conversions: + raise ValueError("Quantity is required for limit orders") + else: + qty = total / price + total = None + else: # i.e., quick trade or market order if total is None and direction == OrderDirection.BUY: - raise ValueError("Total is required for non-limit buy orders") + if not handle_conversions: + raise ValueError("Total is required for non-limit buy orders") + else: + market_price = kwargs.get("market_price", None) + if market_price is None: + market_price = self.__extract_market_price__(direction, symbol) + total = qty * market_price + qty = None payload = {"apiKey": self._api_key(), "symbol": symbol, "category": category, "type": direction, "country": 1} + + with VirgoCXClient.STATIC_LOCK: + if symbol in VirgoCXClient.FMT_DATA: + fmt_data = VirgoCXClient.FMT_DATA[symbol] + else: + raise VirgoCXException(f"Symbol {symbol} not found in formatting cache") + + # Format and check values if price is not None: + price = float(price) # can be int which breaks decimal places check + if len(str(price).split(".")[1]) > fmt_data["price_decimals"]: + warn(f"Price {price} has more than {fmt_data['price_decimals']} decimal places. Correcting...", + VirgoCXWarning) + price = round(price, fmt_data["price_decimals"]) payload["price"] = price + if qty is not None: + qty = float(qty) + if len(str(qty).split(".")[1]) > fmt_data["qty_decimals"]: + warn(f"Quantity {qty} has more than {fmt_data['qty_decimals']} decimal places. Correcting...", + VirgoCXWarning) + qty = round(qty, fmt_data["qty_decimals"]) payload["qty"] = qty + if total is not None: + total = float(total) + if total < fmt_data["min_total"]: + raise ValueError(f"Total {total} is below the minimum allowed {fmt_data['min_total']}") + if len(str(total).split(".")[1]) > fmt_data["price_decimals"]: + warn(f"Total {total} has more than {fmt_data['price_decimals']} decimal places. Correcting...", + VirgoCXWarning) + total = round(total, fmt_data["price_decimals"]) payload["total"] = total + + # Sign and send request payload["sign"] = self.signer(payload) return rq.post(f"{ROOT_ADDRESS}/member/addOrder", data=payload, verify=VERIFICATION) + def __extract_market_price__(self, direction, symbol): + market_price = self.get_discount(symbol=symbol)[0] + if direction == OrderDirection.BUY: + market_price = float(market_price["Ask"]) + else: + market_price = float(market_price["Bid"]) + return market_price + @result_formatter() def cancel_order(self, order_id: str): """ diff --git a/vcx_py/utils.py b/vcx_py/utils.py index 189333a..06c9214 100644 --- a/vcx_py/utils.py +++ b/vcx_py/utils.py @@ -13,6 +13,13 @@ from vcx_py.constants import TYPICAL_KEY_TO_ENUM, ATYPICAL_KEY_TO_ENUM +class VirgoCXWarning(Warning): + """ + Base warning for the VirgoCX API. + """ + pass # overrides allow for optional fine-grained control + + class VirgoCXException(Exception): """ Base exception for the VirgoCX API.