diff --git a/.gitignore b/.gitignore index 6afae43..c7f128e 100644 --- a/.gitignore +++ b/.gitignore @@ -115,5 +115,6 @@ site/ .history *trader +agent node_modules diff --git a/README.md b/README.md index 8b13547..d6c784c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ price = client.get_product(DEFAULT_SYMBOL) print(price) ``` +Fopr a demonstration of the async client please refer to the file in examples/async_client.py ## Installation diff --git a/examples/run_client.py b/examples/run_client.py new file mode 100644 index 0000000..1e9ce6c --- /dev/null +++ b/examples/run_client.py @@ -0,0 +1,105 @@ +""" +Example of a client that connects to a server and sends a message. +""" + +import asyncio +import os +from pprint import pprint + +from hundred_x.async_client import AsyncHundredXClient +from hundred_x.enums import Environment, OrderSide, OrderType, TimeInForce + + +async def main(): + key = os.getenv("HUNDRED_X_PRIVATE_KEY") + subaccount_id = 0 + + if not key: + raise ValueError("HUNDRED_X_PRIVATE_KEY environment variable is not set.") + + client = AsyncHundredXClient(Environment.PROD, key, subaccount_id=subaccount_id) + + print(f"Using Wallet: {client.public_key}") + print(f"Using Subaccount ID: {client.subaccount_id}") + + # In order to hit the authenticated endpoints, we need to login. + await client.login() + + # We check the initial balance. + print("Initial Balance") + response = await client.get_spot_balances() + pprint(response) + + # We first get the symbol. + print("Symbol") + response = await client.get_symbol("btcperp") + pprint(response) + + # We create an order. + print("Create Order") + response = await client.create_order( + subaccount_id=subaccount_id, + product_id=response['productId'], # This is the product_id for the symbol 'btcperp + quantity=0.001, + price=round(int(response['markPrice']) * 0.99 / 1e18), # This is the current price of the symbol 'btcperp' + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + time_in_force=TimeInForce.GTC, + ) + + # We check the open orders. + print("Open Orders") + response = await client.get_open_orders("btcperp") + pprint(response) + + # We cancel the order. + print("Cancel Order") + response = await client.cancel_order(order_id=response[0]["id"], product_id=response[0]["productId"]) + pprint(response) + + # We check the positions. + print("Positions") + response = await client.get_position() + pprint(response) + + # We check if we can cancel and replace an order. + # We check the open orders. + print("Open Orders") + response = await client.get_open_orders("btcperp") + pprint(response) + + # We create an order. + print("Create Order") + + response = await client.create_order( + subaccount_id=subaccount_id, + product_id=response[0]["productId"], + quantity=0.001, + price=round(int(response[0]["price"]) * 0.99 / 1e18), + side=OrderSide.BUY, + order_type=OrderType.LIMIT, + time_in_force=TimeInForce.GTC, + ) + + # We check the open orders. + print("Open Orders") + response = await client.get_open_orders("btcperp") + pprint(response) + + # We cancel and replace the order. + print("Cancel and Replace Order") + response = await client.cancel_and_replace_order( + order_id_to_cancel=response[0]["id"], + product_id=response[0]["productId"], + quantity=0.002, + price=round(int(response[0]["price"]) * 0.99 / 1e18), + side=OrderSide.BUY, + ) + pprint(response) + + # We cancel all orders. + response = await client.cancel_all_orders(subaccount_id=subaccount_id, product_id=1002) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/hundred_x/async_client.py b/hundred_x/async_client.py index a6e5f6f..21ec2e1 100644 --- a/hundred_x/async_client.py +++ b/hundred_x/async_client.py @@ -2,7 +2,15 @@ Async client for the HundredX API """ +from typing import Any + +import httpx + from hundred_x.client import HundredXClient +from hundred_x.constants import LOGIN_MESSAGE +from hundred_x.eip_712 import LoginMessage +from hundred_x.exceptions import ClientError +from hundred_x.utils import from_message_to_payload class AsyncHundredXClient(HundredXClient): @@ -10,20 +18,114 @@ class AsyncHundredXClient(HundredXClient): Asynchronous client for the HundredX API. """ - async def list_products(self): + async def get_symbol(self, symbol: str = None): + """ + Get the symbol infos. + If symbol is None, return all symbols. + """ + response = await super().get_symbol(symbol) + if symbol: + return response[0] + else: + return response + + async def get_depth(self, symbol: str, **kwargs) -> Any: + """ + Get the depth for a specific symbol. + """ + return await super().get_depth(symbol, **kwargs) + + async def get_trade_history(self, symbol: str, lookback: int = 10, **kwargs) -> Any: + """ + Get the trade history for a specific symbol. + if symbol is None, return all trade history. + """ + return await super().get_trade_history(symbol, lookback, **kwargs) + + async def get_position(self, symbol: str = None): + """ + Get the position for a specific symbol. + """ + return await super().get_position(symbol) + + async def get_spot_balances(self): + """ + Get the spot balances. + """ + return await super().get_spot_balances() + + async def get_open_orders(self, symbol: str = None): + """ + Get the open orders for a specific symbol. + """ + return await super().get_open_orders(symbol) + + async def create_order(self, *args, **kwargs): + """ + Create and send order. + """ + return await super().create_order(*args, **kwargs) + + async def cancel_order(self, *args, **kwargs): + """ + Cancel an order. + """ + return await super().cancel_order(*args, **kwargs) + + async def cancel_and_replace_order(self, *args, **kwargs): + """ + Cancel and replace an order. + """ + return await super().cancel_and_replace_order(*args, **kwargs) + + async def cancel_all_orders(self, subaccount_id: int, product_id: int): """ - List all products available on the exchange. + Cancel all orders. """ - return super().list_products() + return await super().cancel_all_orders(subaccount_id, product_id) - async def get_product(self, symbol: str): + async def login(self): """ - Get a specific product available on the exchange. + Login to the exchange. """ - return super().get_product(symbol) + response = await self.create_authenticated_session_with_service() + if response is None: + raise Exception("Failed to login") - async def get_server_time(self): + async def send_message_to_endpoint( + self, endpoint: str, method: str, message: dict = {}, authenticated: bool = True, params: dict = {} + ): """ - Get the server time. + Send a message to an endpoint. """ - return super().get_server_time() + if not self._validate_function( + endpoint, + ): + raise ClientError(f"Invalid endpoint: {endpoint}") + payload = from_message_to_payload(message) + + async with httpx.AsyncClient() as client: + response = await client.request( + method, + self.rest_url + endpoint, + params=params, + headers={} if not authenticated else self.authenticated_headers, + json=payload, + ) + + if response.status_code != 200: + raise Exception( + f"Failed to send message: {response.text} {response.status_code} {self.rest_url} {payload}" + ) + return response.json() + + async def create_authenticated_session_with_service(self): + login_payload = self.generate_and_sign_message( + LoginMessage, + message=LOGIN_MESSAGE, + timestamp=self._current_timestamp(), + **self.get_shared_params(), + ) + response = await self.send_message_to_endpoint("/v1/session/login", "POST", login_payload, authenticated=False) + self.session_cookie = response.get("value") + return response diff --git a/hundred_x/client.py b/hundred_x/client.py index ab6ed9b..e6ba254 100644 --- a/hundred_x/client.py +++ b/hundred_x/client.py @@ -48,6 +48,7 @@ class HundredXClient: public_functions: List[str] = [ "/v1/products", "/v1/products/{product_symbol}", + "/v1/ticker/24hr?symbol={symbol}", "/v1/trade-history", "/v1/time", "/v1/uiKlines", @@ -55,6 +56,10 @@ class HundredXClient: "/v1/depth", ] + @property + def http_client(self): + return requests + def __init__( self, env: Environment = Environment.TESTNET, @@ -87,7 +92,6 @@ def __init__( chainId=CONTRACTS[env]["CHAIN_ID"], verifyingContract=CONTRACTS[env]["VERIFYING_CONTRACT"], ) - self.set_referral_code() def _validate_function( self, @@ -133,7 +137,9 @@ def get_shared_params(self, asset: str = None, subaccount_id: int = None): params["subAccountId"] = subaccount_id return params - def send_message_to_endpoint(self, endpoint: str, method: str, message: dict, authenticated: bool = True): + def send_message_to_endpoint( + self, endpoint: str, method: str, message: dict = {}, authenticated: bool = True, params=None + ): """ Send a message to an endpoint. """ @@ -142,9 +148,10 @@ def send_message_to_endpoint(self, endpoint: str, method: str, message: dict, au ): raise ClientError(f"Invalid endpoint: {endpoint}") payload = from_message_to_payload(message) - response = requests.request( + response = self.http_client.request( method, self.rest_url + endpoint, + params=params, headers={} if not authenticated else self.authenticated_headers, json=payload, ) @@ -199,13 +206,15 @@ def create_order( def cancel_and_replace_order( self, - subaccount_id: int, product_id: int, quantity: int, price: int, side: OrderSide, order_id_to_cancel: str, nonce: int = 0, + subaccount_id: int = None, + order_type: OrderType = OrderType.LIMIT_MAKER, + time_in_force: TimeInForce = TimeInForce.GTC, ): """ Cancel and replace an order. @@ -213,6 +222,8 @@ def cancel_and_replace_order( ts = self._current_timestamp() if nonce == 0: nonce = ts + if subaccount_id is None: + subaccount_id = self.subaccount_id _message = self.generate_and_sign_message( Order, subAccountId=subaccount_id, @@ -220,8 +231,8 @@ def cancel_and_replace_order( quantity=int(Decimal(str(quantity)) * Decimal(1e18)), price=int(Decimal(str(price)) * Decimal(1e18)), isBuy=side.value, - orderType=OrderType.LIMIT_MAKER.value, - timeInForce=TimeInForce.GTC.value, + orderType=order_type.value, + timeInForce=time_in_force.value, nonce=nonce, expiration=(ts + 1000 * 60 * 60 * 24) * 1000, **self.get_shared_params(), @@ -231,13 +242,13 @@ def cancel_and_replace_order( message["idToCancel"] = order_id_to_cancel return self.send_message_to_endpoint("/v1/order/cancel-and-replace", "POST", message) - def cancel_order(self, subaccount_id: int, product_id: int, order_id: int): + def cancel_order(self, product_id: int, order_id: int, subaccount_id: int = None): """ Cancel an order. """ message = self.generate_and_sign_message( CancelOrder, - subAccountId=subaccount_id, + subAccountId=self.subaccount_id if subaccount_id is None else subaccount_id, productId=product_id, orderId=order_id, **self.get_shared_params(), @@ -264,6 +275,10 @@ def create_authenticated_session_with_service(self): **self.get_shared_params(), ) response = self.send_message_to_endpoint("/v1/session/login", "POST", login_payload, authenticated=False) + try: + self.set_referral_code() + except Exception: # pylint: disable=broad-except + pass self.session_cookie = response.get("value") return response @@ -283,10 +298,11 @@ def get_trade_history(self, symbol: str, lookback: int) -> Any: """ Get the trade history for a specific product symbol and lookback amount. """ - return requests.get( - self.rest_url + "/v1/trade-history", + return self.send_message_to_endpoint( + endpoint="/v1/trade-history", + method="GET", params={"symbol": symbol, "lookback": lookback}, - ).json() + ) def get_server_time(self) -> Any: """ @@ -308,12 +324,23 @@ def get_candlestick(self, symbol: str, **kwargs) -> Any: params=params, ).json() - def get_symbol(self, symbol: str) -> Any: + def get_symbol(self, symbol: str = None) -> Any: """ Get the details of a specific symbol. + If symbol is None, return all symbols. """ - endpoint = f"/v1/ticker/24hr?symbol={symbol}" - return requests.get(self.rest_url + endpoint).json()[0] + response = self.send_message_to_endpoint( + endpoint="/v1/ticker/24hr", + method="GET", + message={}, + params={"symbol": symbol} if symbol else {}, + ) + if not isinstance(response, list): + return response + if symbol: + return response[0] + else: + return response def get_depth(self, symbol: str, **kwargs) -> Any: """ @@ -324,10 +351,11 @@ def get_depth(self, symbol: str, **kwargs) -> Any: var = kwargs.get(arg) if var is not None: params[arg] = var - return requests.get( - self.rest_url + "/v1/depth", + return self.send_message_to_endpoint( + endpoint="/v1/depth", + method="GET", params=params, - ).json() + ) def login(self): """ @@ -359,24 +387,21 @@ def get_spot_balances(self): """ Get the spot balances. """ - return requests.get( - self.rest_url + "/v1/balances", - headers=self.authenticated_headers, + return self.send_message_to_endpoint( + "/v1/balances", + "GET", params={"account": self.public_key, "subAccountId": self.subaccount_id}, - ).json() + authenticated=True, + ) - def get_position(self): + def get_position(self, symbol: str = None): """ Get all positions for the subaccount. """ - return requests.get( - self.rest_url + "/v1/positionRisk", - headers=self.authenticated_headers, - params={ - "account": self.public_key, - "subAccountId": self.subaccount_id, - }, - ).json() + params = {"account": self.public_key, "subAccountId": self.subaccount_id} + if symbol is not None: + params["symbol"] = symbol + return self.send_message_to_endpoint("/v1/positionRisk", "GET", params=params, authenticated=True) def get_approved_signers(self): """ @@ -398,11 +423,7 @@ def get_open_orders( params = {"account": self.public_key, "subAccountId": self.subaccount_id} if symbol is not None: params["symbol"] = symbol - return requests.get( - self.rest_url + "/v1/openOrders", - headers=self.authenticated_headers, - params=params, - ).json() + return self.send_message_to_endpoint("/v1/openOrders", "GET", params=params, authenticated=True) def get_orders(self, symbol: str = None, ids: List[str] = None): """ diff --git a/poetry.lock b/poetry.lock index 954548d..b97803d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,9 +1,10 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry and should not be changed by hand. [[package]] name = "aiohttp" version = "3.9.5" description = "Async http client/server framework (asyncio)" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -100,6 +101,7 @@ speedups = ["Brotli", "aiodns", "brotlicffi"] name = "aiosignal" version = "1.3.1" description = "aiosignal: a list of registered asynchronous callbacks" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -110,10 +112,34 @@ files = [ [package.dependencies] frozenlist = ">=1.1.0" +[[package]] +name = "anyio" +version = "4.4.0" +description = "High level compatibility layer for multiple asynchronous event loop implementations" +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, + {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, +] + +[package.dependencies] +exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""} +idna = ">=2.8" +sniffio = ">=1.1" +typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""} + +[package.extras] +doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"] +test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"] +trio = ["trio (>=0.23)"] + [[package]] name = "astroid" version = "3.2.2" description = "An abstract syntax tree for Python with inference support." +category = "dev" optional = false python-versions = ">=3.8.0" files = [ @@ -128,6 +154,7 @@ typing-extensions = {version = ">=4.0.0", markers = "python_version < \"3.11\""} name = "async-timeout" version = "4.0.3" description = "Timeout context manager for asyncio programs" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -139,6 +166,7 @@ files = [ name = "attrs" version = "23.2.0" description = "Classes Without Boilerplate" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -158,6 +186,7 @@ tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "p name = "bitarray" version = "2.9.2" description = "efficient arrays of booleans -- C extension" +category = "main" optional = false python-versions = "*" files = [ @@ -289,6 +318,7 @@ files = [ name = "black" version = "24.4.2" description = "The uncompromising code formatter." +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -335,6 +365,7 @@ uvloop = ["uvloop (>=0.15.2)"] name = "certifi" version = "2024.2.2" description = "Python package for providing Mozilla's CA Bundle." +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -346,6 +377,7 @@ files = [ name = "charset-normalizer" version = "3.3.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +category = "main" optional = false python-versions = ">=3.7.0" files = [ @@ -445,6 +477,7 @@ files = [ name = "cli-ui" version = "0.17.2" description = "Build Nice User Interfaces In The Terminal" +category = "dev" optional = false python-versions = ">=3.7,<4.0" files = [ @@ -461,6 +494,7 @@ unidecode = ">=1.0.23,<2.0.0" name = "click" version = "8.1.7" description = "Composable command line interface toolkit" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -475,6 +509,7 @@ colorama = {version = "*", markers = "platform_system == \"Windows\""} name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." +category = "dev" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -486,6 +521,7 @@ files = [ name = "cytoolz" version = "0.12.3" description = "Cython implementation of Toolz: High performance functional utilities" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -605,6 +641,7 @@ cython = ["cython"] name = "dill" version = "0.3.8" description = "serialize all of Python" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -620,6 +657,7 @@ profile = ["gprof2dot (>=2022.7.29)"] name = "docopt" version = "0.6.2" description = "Pythonic argument parser, that will make you smile" +category = "dev" optional = false python-versions = "*" files = [ @@ -630,6 +668,7 @@ files = [ name = "eip712-structs-ng" version = "2.0.1" description = "A Python interface for EIP-712 struct construction." +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -648,6 +687,7 @@ tests = ["eth-tester (==0.10.0b1)", "mypy", "py-evm (==0.8.0b1)", "py-solc-x (== name = "eth-abi" version = "5.1.0" description = "eth_abi: Python utilities for working with Ethereum ABI definitions, especially encoding and decoding" +category = "main" optional = false python-versions = "<4,>=3.8" files = [ @@ -670,6 +710,7 @@ tools = ["hypothesis (>=4.18.2,<5.0.0)"] name = "eth-account" version = "0.11.0" description = "eth-account: Sign Ethereum transactions and messages with local private keys" +category = "main" optional = false python-versions = ">=3.8, <4" files = [ @@ -696,6 +737,7 @@ test = ["coverage", "hypothesis (>=4.18.0,<5)", "pytest (>=7.0.0)", "pytest-xdis name = "eth-hash" version = "0.7.0" description = "eth-hash: The Ethereum hashing function, keccak256, sometimes (erroneously) called sha3" +category = "main" optional = false python-versions = ">=3.8, <4" files = [ @@ -717,6 +759,7 @@ test = ["pytest (>=7.0.0)", "pytest-xdist (>=2.4.0)"] name = "eth-keyfile" version = "0.8.1" description = "eth-keyfile: A library for handling the encrypted keyfiles used to store ethereum private keys" +category = "main" optional = false python-versions = "<4,>=3.8" files = [ @@ -738,6 +781,7 @@ test = ["pytest (>=7.0.0)", "pytest-xdist (>=2.4.0)"] name = "eth-keys" version = "0.5.1" description = "eth-keys: Common API for Ethereum key operations" +category = "main" optional = false python-versions = "<4,>=3.8" files = [ @@ -759,6 +803,7 @@ test = ["asn1tools (>=0.146.2)", "eth-hash[pysha3]", "factory-boy (>=3.0.1)", "h name = "eth-rlp" version = "1.0.1" description = "eth-rlp: RLP definitions for common Ethereum objects in Python" +category = "main" optional = false python-versions = ">=3.8, <4" files = [ @@ -781,6 +826,7 @@ test = ["eth-hash[pycryptodome]", "pytest (>=7.0.0)", "pytest-xdist (>=2.4.0)"] name = "eth-typing" version = "4.2.3" description = "eth-typing: Common type annotations for ethereum python packages" +category = "main" optional = false python-versions = "<4,>=3.8" files = [ @@ -797,6 +843,7 @@ test = ["pytest (>=7.0.0)", "pytest-xdist (>=2.4.0)"] name = "eth-utils" version = "2.3.1" description = "eth-utils: Common utility functions for python code that interacts with Ethereum" +category = "main" optional = false python-versions = ">=3.7,<4" files = [ @@ -820,6 +867,7 @@ test = ["hypothesis (>=4.43.0)", "mypy (==0.971)", "pytest (>=7.0.0)", "pytest-x name = "exceptiongroup" version = "1.2.1" description = "Backport of PEP 654 (exception groups)" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -834,6 +882,7 @@ test = ["pytest (>=6)"] name = "flake8" version = "7.0.0" description = "the modular source code checker: pep8 pyflakes and co" +category = "dev" optional = false python-versions = ">=3.8.1" files = [ @@ -850,6 +899,7 @@ pyflakes = ">=3.2.0,<3.3.0" name = "frozenlist" version = "1.4.1" description = "A list-like structure which implements collections.abc.MutableSequence" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -932,10 +982,23 @@ files = [ {file = "frozenlist-1.4.1.tar.gz", hash = "sha256:c037a86e8513059a2613aaba4d817bb90b9d9b6b69aace3ce9c877e8c8ed402b"}, ] +[[package]] +name = "h11" +version = "0.14.0" +description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761"}, + {file = "h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d"}, +] + [[package]] name = "hexbytes" version = "0.3.1" description = "hexbytes: Python `bytes` subclass that decodes hex, with a readable console output" +category = "main" optional = false python-versions = ">=3.7, <4" files = [ @@ -949,10 +1012,58 @@ doc = ["sphinx (>=5.0.0)", "sphinx-rtd-theme (>=1.0.0)", "towncrier (>=21,<22)"] lint = ["black (>=22)", "flake8 (==6.0.0)", "flake8-bugbear (==23.3.23)", "isort (>=5.10.1)", "mypy (==0.971)", "pydocstyle (>=5.0.0)"] test = ["eth-utils (>=1.0.1,<3)", "hypothesis (>=3.44.24,<=6.31.6)", "pytest (>=7.0.0)", "pytest-xdist (>=2.4.0)"] +[[package]] +name = "httpcore" +version = "1.0.5" +description = "A minimal low-level HTTP client." +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpcore-1.0.5-py3-none-any.whl", hash = "sha256:421f18bac248b25d310f3cacd198d55b8e6125c107797b609ff9b7a6ba7991b5"}, + {file = "httpcore-1.0.5.tar.gz", hash = "sha256:34a38e2f9291467ee3b44e89dd52615370e152954ba21721378a87b2960f7a61"}, +] + +[package.dependencies] +certifi = "*" +h11 = ">=0.13,<0.15" + +[package.extras] +asyncio = ["anyio (>=4.0,<5.0)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] +trio = ["trio (>=0.22.0,<0.26.0)"] + +[[package]] +name = "httpx" +version = "0.27.0" +description = "The next generation HTTP client." +category = "main" +optional = false +python-versions = ">=3.8" +files = [ + {file = "httpx-0.27.0-py3-none-any.whl", hash = "sha256:71d5465162c13681bff01ad59b2cc68dd838ea1f10e51574bac27103f00c91a5"}, + {file = "httpx-0.27.0.tar.gz", hash = "sha256:a0cb88a46f32dc874e04ee956e4c2764aba2aa228f650b06788ba6bda2962ab5"}, +] + +[package.dependencies] +anyio = "*" +certifi = "*" +httpcore = ">=1.0.0,<2.0.0" +idna = "*" +sniffio = "*" + +[package.extras] +brotli = ["brotli", "brotlicffi"] +cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<14)"] +http2 = ["h2 (>=3,<5)"] +socks = ["socksio (>=1.0.0,<2.0.0)"] + [[package]] name = "idna" version = "3.7" description = "Internationalized Domain Names in Applications (IDNA)" +category = "main" optional = false python-versions = ">=3.5" files = [ @@ -964,6 +1075,7 @@ files = [ name = "iniconfig" version = "2.0.0" description = "brain-dead simple config-ini parsing" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -975,6 +1087,7 @@ files = [ name = "isort" version = "5.13.2" description = "A Python utility / library to sort Python imports." +category = "dev" optional = false python-versions = ">=3.8.0" files = [ @@ -989,6 +1102,7 @@ colors = ["colorama (>=0.4.6)"] name = "jsonschema" version = "4.22.0" description = "An implementation of JSON Schema validation for Python" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1010,6 +1124,7 @@ format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339- name = "jsonschema-specifications" version = "2023.12.1" description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1024,6 +1139,7 @@ referencing = ">=0.31.0" name = "lru-dict" version = "1.2.0" description = "An Dict like LRU container." +category = "main" optional = false python-versions = "*" files = [ @@ -1118,6 +1234,7 @@ test = ["pytest"] name = "mccabe" version = "0.7.0" description = "McCabe checker, plugin for flake8" +category = "dev" optional = false python-versions = ">=3.6" files = [ @@ -1129,6 +1246,7 @@ files = [ name = "multidict" version = "6.0.5" description = "multidict implementation" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1228,6 +1346,7 @@ files = [ name = "mypy-extensions" version = "1.0.0" description = "Type system extensions for programs checked with the mypy type checker." +category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -1239,6 +1358,7 @@ files = [ name = "packaging" version = "24.0" description = "Core utilities for Python packages" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1250,6 +1370,7 @@ files = [ name = "parsimonious" version = "0.10.0" description = "(Soon to be) the fastest pure-Python PEG parser I could muster" +category = "main" optional = false python-versions = "*" files = [ @@ -1264,6 +1385,7 @@ regex = ">=2022.3.15" name = "pathspec" version = "0.12.1" description = "Utility library for gitignore style pattern matching of file paths." +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1275,6 +1397,7 @@ files = [ name = "platformdirs" version = "4.2.2" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1291,6 +1414,7 @@ type = ["mypy (>=1.8)"] name = "pluggy" version = "1.5.0" description = "plugin and hook calling mechanisms for python" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1306,6 +1430,7 @@ testing = ["pytest", "pytest-benchmark"] name = "protobuf" version = "5.27.0" description = "" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1326,6 +1451,7 @@ files = [ name = "pycodestyle" version = "2.11.1" description = "Python style guide checker" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1337,6 +1463,7 @@ files = [ name = "pycryptodome" version = "3.20.0" description = "Cryptographic library for Python" +category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -1378,6 +1505,7 @@ files = [ name = "pyflakes" version = "3.2.0" description = "passive checker of Python programs" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1389,6 +1517,7 @@ files = [ name = "pylint" version = "3.2.2" description = "python code static checker" +category = "dev" optional = false python-versions = ">=3.8.0" files = [ @@ -1401,8 +1530,8 @@ astroid = ">=3.2.2,<=3.3.0-dev0" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} dill = [ {version = ">=0.2", markers = "python_version < \"3.11\""}, + {version = ">=0.3.6", markers = "python_version >= \"3.11\""}, {version = ">=0.3.7", markers = "python_version >= \"3.12\""}, - {version = ">=0.3.6", markers = "python_version >= \"3.11\" and python_version < \"3.12\""}, ] isort = ">=4.2.5,<5.13.0 || >5.13.0,<6" mccabe = ">=0.6,<0.8" @@ -1419,6 +1548,7 @@ testutils = ["gitpython (>3)"] name = "pytest" version = "8.2.1" description = "pytest: simple powerful testing with Python" +category = "dev" optional = false python-versions = ">=3.8" files = [ @@ -1437,10 +1567,30 @@ tomli = {version = ">=1", markers = "python_version < \"3.11\""} [package.extras] dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-asyncio" +version = "0.23.7" +description = "Pytest support for asyncio" +category = "dev" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest_asyncio-0.23.7-py3-none-any.whl", hash = "sha256:009b48127fbe44518a547bddd25611551b0e43ccdbf1e67d12479f569832c20b"}, + {file = "pytest_asyncio-0.23.7.tar.gz", hash = "sha256:5f5c72948f4c49e7db4f29f2521d4031f1c27f86e57b046126654083d4770268"}, +] + +[package.dependencies] +pytest = ">=7.0.0,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + [[package]] name = "pyunormalize" version = "15.1.0" description = "Unicode normalization forms (NFC, NFKC, NFD, NFKD). A library independent from the Python core Unicode database." +category = "main" optional = false python-versions = ">=3.6" files = [ @@ -1451,6 +1601,7 @@ files = [ name = "pywin32" version = "306" description = "Python for Window Extensions" +category = "main" optional = false python-versions = "*" files = [ @@ -1474,6 +1625,7 @@ files = [ name = "referencing" version = "0.35.1" description = "JSON Referencing + Python" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1489,6 +1641,7 @@ rpds-py = ">=0.7.0" name = "regex" version = "2024.5.15" description = "Alternative regular expression module, to replace re." +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1577,6 +1730,7 @@ files = [ name = "requests" version = "2.32.3" description = "Python HTTP for Humans." +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1594,10 +1748,26 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "respx" +version = "0.21.1" +description = "A utility for mocking out the Python HTTPX and HTTP Core libraries." +category = "dev" +optional = false +python-versions = ">=3.7" +files = [ + {file = "respx-0.21.1-py2.py3-none-any.whl", hash = "sha256:05f45de23f0c785862a2c92a3e173916e8ca88e4caad715dd5f68584d6053c20"}, + {file = "respx-0.21.1.tar.gz", hash = "sha256:0bd7fe21bfaa52106caa1223ce61224cf30786985f17c63c5d71eff0307ee8af"}, +] + +[package.dependencies] +httpx = ">=0.21.0" + [[package]] name = "rlp" version = "4.0.1" description = "rlp: A package for Recursive Length Prefix encoding and decoding" +category = "main" optional = false python-versions = "<4,>=3.8" files = [ @@ -1618,6 +1788,7 @@ test = ["hypothesis (==5.19.0)", "pytest (>=7.0.0)", "pytest-xdist (>=2.4.0)"] name = "rpds-py" version = "0.18.1" description = "Python bindings to Rust's persistent data structures (rpds)" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1726,6 +1897,7 @@ files = [ name = "safe-pysha3" version = "1.0.4" description = "SHA-3 (Keccak) for Python 3.9 - 3.11" +category = "main" optional = false python-versions = "*" files = [ @@ -1736,6 +1908,7 @@ files = [ name = "schema" version = "0.7.7" description = "Simple data validation library" +category = "dev" optional = false python-versions = "*" files = [ @@ -1747,6 +1920,7 @@ files = [ name = "semver" version = "3.0.2" description = "Python helper for Semantic Versioning (https://semver.org)" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1754,10 +1928,23 @@ files = [ {file = "semver-3.0.2.tar.gz", hash = "sha256:6253adb39c70f6e51afed2fa7152bcd414c411286088fb4b9effb133885ab4cc"}, ] +[[package]] +name = "sniffio" +version = "1.3.1" +description = "Sniff out which async library your code is running under" +category = "main" +optional = false +python-versions = ">=3.7" +files = [ + {file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"}, + {file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"}, +] + [[package]] name = "tabulate" version = "0.8.10" description = "Pretty-print tabular data" +category = "dev" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" files = [ @@ -1772,6 +1959,7 @@ widechars = ["wcwidth"] name = "tbump" version = "6.11.0" description = "Bump software releases" +category = "dev" optional = false python-versions = ">=3.7,<4.0" files = [ @@ -1789,6 +1977,7 @@ tomlkit = ">=0.11,<0.12" name = "tomli" version = "2.0.1" description = "A lil' TOML parser" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1800,6 +1989,7 @@ files = [ name = "tomlkit" version = "0.11.8" description = "Style preserving TOML library" +category = "dev" optional = false python-versions = ">=3.7" files = [ @@ -1811,6 +2001,7 @@ files = [ name = "toolz" version = "0.12.1" description = "List processing tools and functional utilities" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -1822,6 +2013,7 @@ files = [ name = "typing-extensions" version = "4.12.0" description = "Backported and Experimental Type Hints for Python 3.8+" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1833,6 +2025,7 @@ files = [ name = "unidecode" version = "1.3.8" description = "ASCII transliterations of Unicode text" +category = "dev" optional = false python-versions = ">=3.5" files = [ @@ -1844,6 +2037,7 @@ files = [ name = "urllib3" version = "2.2.1" description = "HTTP library with thread-safe connection pooling, file post, and more." +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1861,6 +2055,7 @@ zstd = ["zstandard (>=0.18.0)"] name = "web3" version = "6.19.0" description = "web3.py" +category = "main" optional = false python-versions = ">=3.7.2" files = [ @@ -1895,6 +2090,7 @@ tester = ["eth-tester[py-evm] (>=0.11.0b1,<0.12.0b1)", "eth-tester[py-evm] (>=0. name = "websockets" version = "12.0" description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)" +category = "main" optional = false python-versions = ">=3.8" files = [ @@ -1976,6 +2172,7 @@ files = [ name = "yarl" version = "1.9.4" description = "Yet another URL library" +category = "main" optional = false python-versions = ">=3.7" files = [ @@ -2078,4 +2275,4 @@ multidict = ">=4.0" [metadata] lock-version = "2.0" python-versions = ">=3.9,<=3.13" -content-hash = "b4e9c3c2ae6ffbb84991de263d27e603485843eaaf0bae9477b2954154ab410f" +content-hash = "5958ab1d4d9f0c0ced1fb24a3d2cf52a48460c4a7787fc335321cec8d4ad67f1" diff --git a/pyproject.toml b/pyproject.toml index 9851e47..a7e3228 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ eip712-structs-ng = "^2.0.1" web3 = ">=6,<7" websockets = ">=9" safe-pysha3 = "^1.0.4" +httpx = "^0.27.0" [tool.poetry.group.dev.dependencies] @@ -24,6 +25,8 @@ black = "^24.3.0" flake8 = "^7.0.0" semver = "^3.0.2" tbump = "^6.11.0" +pytest-asyncio = "^0.23.7" +respx = "^0.21.1" [build-system] requires = ["poetry-core"] @@ -65,4 +68,4 @@ exclude = ''' markers = [ "devs: test devnet (deselect with '-m dev')", ] -addopts = "-m 'not dev'" \ No newline at end of file +addopts = "-m 'not dev' -p no:warnings" \ No newline at end of file diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_async_hundred_x_client.py b/tests/test_async_hundred_x_client.py new file mode 100644 index 0000000..a1f191c --- /dev/null +++ b/tests/test_async_hundred_x_client.py @@ -0,0 +1,94 @@ +""" +Tests for the async client. +""" + +import pytest +import respx +from httpx import Response + +from hundred_x.async_client import AsyncHundredXClient +from hundred_x.enums import Environment +from tests.test_data import TEST_ORDER_ID, TEST_PRICE, TEST_PRIVATE_KEY, TEST_SYMBOL + + +@pytest.fixture +def client(): + env = Environment.PROD + return AsyncHundredXClient(env, TEST_PRIVATE_KEY) + + +@pytest.mark.asyncio +@respx.mock +async def test_get_symbol( + client, +): + respx.get(f"{client.rest_url}/v1/ticker/24hr", params={"symbol": TEST_SYMBOL}).mock( + return_value=Response(200, json=[{"productSymbol": TEST_SYMBOL}]) + ) + response = await client.get_symbol(TEST_SYMBOL) + assert response["productSymbol"] == TEST_SYMBOL + + +@pytest.mark.asyncio +async def test_get_depth(client): + respx.get(f"{client.rest_url}/v1/depth", params={"symbol": TEST_SYMBOL, "limit": 5}).mock( + return_value=Response(200, json={"bids": [], "asks": []}) + ) + response = await client.get_depth(TEST_SYMBOL) + assert "bids" in response + assert "asks" in response + + +@pytest.mark.asyncio +@respx.mock +async def test_get_trade_history(client): + respx.get( + f"{client.rest_url}/v1/trade-history", + params={"symbol": TEST_SYMBOL}, + ).mock(return_value=Response(200, json={"success": True, "trades": [{"id": TEST_ORDER_ID, "price": TEST_PRICE}]})) + response = await client.get_trade_history(TEST_SYMBOL) + assert isinstance(response['trades'], list) + assert response['trades'][0]["id"] == TEST_ORDER_ID + assert response['trades'][0]["price"] == TEST_PRICE + + +@pytest.mark.asyncio +@respx.mock +async def test_get_position(client): + respx.get( + f"{client.rest_url}/v1/positionRisk", + ).mock(return_value=Response(200, json={"symbol": TEST_SYMBOL})) + response = await client.get_position(TEST_SYMBOL) + assert response["symbol"] == TEST_SYMBOL + + +@pytest.mark.asyncio +@respx.mock +async def test_get_spot_balances(client): + respx.get( + f"{client.rest_url}/v1/balances", params={"account": client.public_key, "subAccountId": client.subaccount_id} + ).mock(return_value=Response(200, json={"balances": []})) + response = await client.get_spot_balances() + assert "balances" in response + + +@pytest.mark.asyncio +@respx.mock +async def test_get_open_orders(client): + respx.get(f"{client.rest_url}/v1/openOrders", params={"symbol": TEST_SYMBOL}).mock( + return_value=Response(200, json=[{"orderId": "12345", "symbol": TEST_SYMBOL}]) + ) + response = await client.get_open_orders(TEST_SYMBOL) + assert isinstance(response, list) + assert response[0]["orderId"] == "12345" + assert response[0]["symbol"] == TEST_SYMBOL + + +@pytest.mark.asyncio +@respx.mock +async def test_cancel_order(client): + respx.delete( + f"{client.rest_url}/v1/order", + ).mock(return_value=Response(200, json={"orderId": TEST_ORDER_ID})) + response = await client.cancel_order(order_id=TEST_ORDER_ID, product_id=1002) + assert response["orderId"] == TEST_ORDER_ID diff --git a/tests/test_data.py b/tests/test_data.py index 09f104e..b35a998 100644 --- a/tests/test_data.py +++ b/tests/test_data.py @@ -8,6 +8,9 @@ DEFAULT_SYMBOL = "ethperp" TEST_PRIVATE_KEY = "0x8f58e47491ac5fe6897216208fe1fed316d6ee89de6c901bfc521c2178ebe6dd" TEST_ADDRESS = "0xEEF7faba495b4875d67E3ED8FB3a32433d3DB3b3" +TEST_SYMBOL = "btcperp" +TEST_PRICE = "50000.00" +TEST_ORDER_ID = "12345" TEST_ORDER = { "subaccount_id": 1, "product_id": 1002,