From 5608c3844bd7744b969b3a82439d360252417dbb Mon Sep 17 00:00:00 2001 From: stefano Date: Thu, 19 Jun 2025 16:17:39 +0100 Subject: [PATCH 1/2] add example liquidity script --- examples/market_maker_example.py | 11 +- examples/simple_market_maker.py | 137 ++++++++++++++++++ tests/perpetual/test_order_object.py | 2 +- x10/perpetual/configuration.py | 2 +- x10/perpetual/markets.py | 3 + x10/perpetual/order_object.py | 4 +- x10/perpetual/orderbook.py | 37 +++-- .../simple_client/simple_trading_client.py | 130 +++++++++++------ .../perpetual_stream_connection.py | 12 +- x10/perpetual/stream_client/stream_client.py | 4 +- .../trading_client/trading_client.py | 4 +- x10/perpetual/user_client/onboarding.py | 28 ++-- x10/perpetual/user_client/user_client.py | 2 + 13 files changed, 287 insertions(+), 89 deletions(-) create mode 100644 examples/simple_market_maker.py diff --git a/examples/market_maker_example.py b/examples/market_maker_example.py index 252b8fb..b7e27f2 100644 --- a/examples/market_maker_example.py +++ b/examples/market_maker_example.py @@ -19,9 +19,9 @@ async def build_markets_cache(trading_client: PerpetualTradingClient): # flake8: noqa -async def stupid_market_maker_example(): +async def on_board_example(): environment_config = STARKNET_TESTNET_CONFIG - eth_account_1: LocalAccount = Account.from_key("") # Replace with your actual private key + eth_account_1: LocalAccount = Account.from_key("") onboarding_client = UserClient(endpoint_config=environment_config, l1_private_key=eth_account_1.key.hex) root_account = await onboarding_client.onboard() @@ -51,8 +51,8 @@ async def stupid_market_maker_example(): markets=[market.name], ) - if "SOL" in market.name: - print("Skipping SOL market") + if "BTC" not in market.name: # Example for a specific market + print(f"Skipping {market.name} market") continue mark_price = market.market_stats.mark_price @@ -109,8 +109,9 @@ async def stupid_market_maker_example(): ) except Exception as e: print(f"Error: {e}") + await asyncio.sleep(30) except Exception as e: print(f"Error: {e}") -asyncio.run(stupid_market_maker_example()) +asyncio.run(on_board_example()) diff --git a/examples/simple_market_maker.py b/examples/simple_market_maker.py new file mode 100644 index 0000000..f122f09 --- /dev/null +++ b/examples/simple_market_maker.py @@ -0,0 +1,137 @@ +import asyncio +import math +import random +import traceback +from decimal import ROUND_CEILING, ROUND_FLOOR, Decimal +from typing import List, Tuple + +from eth_account import Account +from eth_account.signers.local import LocalAccount + +from x10.perpetual.accounts import StarkPerpetualAccount +from x10.perpetual.configuration import STARKNET_TESTNET_CONFIG +from x10.perpetual.orderbook import OrderBook +from x10.perpetual.orders import OrderSide +from x10.perpetual.simple_client.simple_trading_client import BlockingTradingClient +from x10.perpetual.trading_client.trading_client import PerpetualTradingClient +from x10.perpetual.user_client.user_client import UserClient + + +async def build_markets_cache(trading_client: PerpetualTradingClient): + markets = await trading_client.markets_info.get_markets() + assert markets.data is not None + return {m.name: m for m in markets.data} + + +# flake8: noqa +async def on_board_example(): + environment_config = STARKNET_TESTNET_CONFIG + eth_account_1: LocalAccount = Account.from_key("") + onboarding_client = UserClient(endpoint_config=environment_config, l1_private_key=eth_account_1.key.hex) + root_account = await onboarding_client.onboard() + trading_key = await onboarding_client.create_account_api_key(root_account.account, "trading_key") + + root_trading_client = await BlockingTradingClient( + environment_config, + StarkPerpetualAccount( + vault=root_account.account.l2_vault, + private_key=root_account.l2_key_pair.private_hex, + public_key=root_account.l2_key_pair.public_hex, + api_key=trading_key, + ), + ) + + print(f"User vault: {root_account.account.l2_vault}") + print(f"User pub: {root_account.l2_key_pair.public_hex}") + print(f"User priv: {root_account.l2_key_pair.private_hex}") + + available_markets = await root_trading_client.get_markets() + target_order_amount_usd = Decimal("50") + market = available_markets["BTC-USD"] + await root_trading_client.mass_cancel(markets=[market.name]) + buy_orders_infoz: List[Tuple[str, Decimal] | None] = [None, None, None] + sell_order_infoz: List[Tuple[str, Decimal] | None] = [None, None, None] + + pending_sell_job: asyncio.Future[list[BaseException | None]] | None = None + pending_buy_job: asyncio.Future[list[BaseException | None]] | None = None + + def update_sell_orders(best_ask: Decimal | None): + print(f"Best ask: {best_ask}") + nonlocal pending_sell_job + if pending_sell_job and not pending_sell_job.done(): + print("Pending sell job is still running, skipping update.") + return + tasks = [ + asyncio.create_task(place_order(best_ask, idx, OrderSide.SELL)) for idx in range(len(sell_order_infoz)) + ] + pending_sell_job = asyncio.gather(*tasks, return_exceptions=True) + + def update_buy_orders(best_bid: Decimal | None): + print(f"Best bid: {best_bid}") + nonlocal pending_buy_job + if pending_buy_job and not pending_buy_job.done(): + print("Pending buy job is still running, skipping update.") + return + tasks = [asyncio.create_task(place_order(best_bid, idx, OrderSide.BUY)) for idx in range(len(buy_orders_infoz))] + pending_buy_job = asyncio.gather(*tasks, return_exceptions=True) + + async def place_order(best_price: Decimal | None, idx: int, side: OrderSide): + order_holders = sell_order_infoz if side == OrderSide.SELL else buy_orders_infoz + try: + previous_order_info = order_holders[idx] + if previous_order_info is not None: + previous_order_id, previous_order_price = previous_order_info + else: + previous_order_id, previous_order_price = None, None + print(f"Previous order ID: {previous_order_id}, Price: {previous_order_price}") + + if previous_order_id and previous_order_price and previous_order_price == best_price: + print(f"Order at index {idx} with price {previous_order_price} is at top of the book, cancelling.") + await root_trading_client.cancel_order(previous_order_id) + order_holders[idx] = None + previous_order_id = None + + if best_price is None: + print(f"No best price available for index {idx}, skipping {side} order placement.") + return + new_external_id = ( + f"mm_{side}_order_{idx}_{random.randint(1,10000000000000000000000000000000000000000000000000000000000)}" + ) + + adjustment_direction = Decimal(1 if side == OrderSide.SELL else -1) + + adjusted_price = market.trading_config.round_price( + best_price * (Decimal(1) + (adjustment_direction * (Decimal(1) + Decimal(idx)) / Decimal(400))), + ROUND_CEILING, + ) + print(f"Placing {side} order at {adjusted_price} for index {idx}") + synthetic_amount = market.trading_config.calculate_order_size_from_value( + target_order_amount_usd, adjusted_price + ) + place_response = await root_trading_client.create_and_place_order( + market_name=market.name, + amount_of_synthetic=synthetic_amount, + price=adjusted_price, + side=side, + post_only=True, + previous_order_external_id=previous_order_id, + external_id=new_external_id, + ) + order_holders[idx] = (new_external_id, adjusted_price) + print(f"Placed sell order at {adjusted_price} with ID {place_response.external_id}") + except Exception as e: + print(traceback.format_exc()) + + order_book = await OrderBook.create( + STARKNET_TESTNET_CONFIG, + market_name="BTC-USD", + start=True, + best_ask_change_callback=lambda best_ask: update_buy_orders(best_ask.price if best_ask else None), + best_bid_change_callback=lambda best_bid: update_sell_orders(best_bid.price if best_bid else None), + ) + + while True: + await asyncio.sleep(1) + + +asyncio.run(on_board_example()) diff --git a/tests/perpetual/test_order_object.py b/tests/perpetual/test_order_object.py index 1691d91..8440ae5 100644 --- a/tests/perpetual/test_order_object.py +++ b/tests/perpetual/test_order_object.py @@ -198,7 +198,7 @@ async def test_cancel_previous_order(mocker: MockerFixture, create_trading_accou price=Decimal("43445.11680000"), side=OrderSide.BUY, expire_time=utc_now() + timedelta(days=14), - previous_order_id="previous_custom_id", + previous_order_external_id="previous_custom_id", starknet_domain=STARKNET_TESTNET_CONFIG.starknet_domain, ) diff --git a/x10/perpetual/configuration.py b/x10/perpetual/configuration.py index 4b1c33e..7b32ede 100644 --- a/x10/perpetual/configuration.py +++ b/x10/perpetual/configuration.py @@ -74,7 +74,7 @@ class EndpointConfig: STARKNET_TESTNET_CONFIG = EndpointConfig( chain_rpc_url="https://rpc.sepolia.org", api_base_url="https://api.starknet.sepolia.extended.exchange/api/v1", - stream_url="wss://api.starknet.sepolia.extended.exchange/stream.extended.exchange/v1", + stream_url="wss://starknet.sepolia.extended.exchange/stream.extended.exchange/v1", onboarding_url="https://api.starknet.sepolia.extended.exchange", signing_domain="starknet.sepolia.extended.exchange", collateral_asset_contract="0x0C9165046063B7bCD05C6924Bbe05ed535c140a1", diff --git a/x10/perpetual/markets.py b/x10/perpetual/markets.py index 3241a11..dcee73d 100644 --- a/x10/perpetual/markets.py +++ b/x10/perpetual/markets.py @@ -76,6 +76,9 @@ def calculate_order_size_from_value( else: return Decimal(0) + def round_price(self, price: Decimal, rounding_direction: str = ROUND_CEILING) -> Decimal: + return self.price_precision * (price / self.price_precision).to_integral_exact(rounding_direction) + class L2ConfigModel(X10BaseModel): type: str diff --git a/x10/perpetual/order_object.py b/x10/perpetual/order_object.py index 356e045..6dbdb86 100644 --- a/x10/perpetual/order_object.py +++ b/x10/perpetual/order_object.py @@ -38,7 +38,7 @@ def create_order_object( side: OrderSide, starknet_domain: StarknetDomain, post_only: bool = False, - previous_order_id: Optional[str] = None, + previous_order_external_id: Optional[str] = None, expire_time: Optional[datetime] = None, order_external_id: Optional[str] = None, time_in_force: TimeInForce = TimeInForce.GTT, @@ -66,7 +66,7 @@ def create_order_object( exact_only=False, expire_time=expire_time, post_only=post_only, - previous_order_external_id=previous_order_id, + previous_order_external_id=previous_order_external_id, order_external_id=order_external_id, time_in_force=time_in_force, self_trade_protection_level=self_trade_protection_level, diff --git a/x10/perpetual/orderbook.py b/x10/perpetual/orderbook.py index 3c6464d..efed8d8 100644 --- a/x10/perpetual/orderbook.py +++ b/x10/perpetual/orderbook.py @@ -31,9 +31,10 @@ class OrderBook: async def create( endpoint_config: EndpointConfig, market_name: str, - best_ask_change_callback: Callable[[OrderBookEntry], None] | None = None, - best_bid_change_callback: Callable[[OrderBookEntry], None] | None = None, + best_ask_change_callback: Callable[[OrderBookEntry | None], None] | None = None, + best_bid_change_callback: Callable[[OrderBookEntry | None], None] | None = None, start=False, + depth_1: bool = False, ) -> "OrderBook": ob = OrderBook( endpoint_config, @@ -49,8 +50,8 @@ def __init__( self, endpoint_config: EndpointConfig, market_name: str, - best_ask_change_callback: Callable[[OrderBookEntry], None] | None = None, - best_bid_change_callback: Callable[[OrderBookEntry], None] | None = None, + best_ask_change_callback: Callable[[OrderBookEntry | None], None] | None = None, + best_bid_change_callback: Callable[[OrderBookEntry | None], None] | None = None, ) -> None: self.__stream_client = PerpetualStreamClient(api_url=endpoint_config.stream_url) self.__market_name = market_name @@ -64,7 +65,7 @@ def update_orderbook(self, data: OrderbookUpdateModel): best_bid_before_update = self.best_bid() for bid in data.bid: if bid.price in self._bid_prices: - existing_bid_entry: OrderBookEntry = self._bid_prices.get(bid.price) + existing_bid_entry: OrderBookEntry = self._bid_prices[bid.price] existing_bid_entry.amount = existing_bid_entry.amount + bid.qty if existing_bid_entry.amount == 0: del self._bid_prices[bid.price] @@ -74,14 +75,14 @@ def update_orderbook(self, data: OrderbookUpdateModel): amount=bid.qty, ) now_best_bid = self.best_bid() - if now_best_bid and best_bid_before_update != now_best_bid: + if best_bid_before_update != now_best_bid: if self.best_bid_change_callback: self.best_bid_change_callback(now_best_bid) best_ask_before_update = self.best_ask() for ask in data.ask: if ask.price in self._ask_prices: - existing_ask_entry: OrderBookEntry = self._ask_prices.get(ask.price) + existing_ask_entry: OrderBookEntry = self._ask_prices[ask.price] existing_ask_entry.amount = existing_ask_entry.amount + ask.qty if existing_ask_entry.amount == 0: del self._ask_prices[ask.price] @@ -91,7 +92,7 @@ def update_orderbook(self, data: OrderbookUpdateModel): amount=ask.qty, ) now_best_ask = self.best_ask() - if now_best_ask and best_ask_before_update != now_best_ask: + if best_ask_before_update != now_best_ask: if self.best_ask_change_callback: self.best_ask_change_callback(now_best_ask) @@ -111,12 +112,20 @@ async def start_orderbook(self) -> asyncio.Task: loop = asyncio.get_running_loop() async def inner(): - async with self.__stream_client.subscribe_to_orderbooks(self.__market_name) as stream: - async for event in stream: - if event.type == StreamDataType.SNAPSHOT.value: - self.init_orderbook(event.data) - elif event.type == StreamDataType.DELTA.value: - self.update_orderbook(event.data) + while True: + print(f"Connecting to orderbook stream for market: {self.__market_name}") + async with self.__stream_client.subscribe_to_orderbooks(self.__market_name) as stream: + async for event in stream: + if event.type == StreamDataType.SNAPSHOT.value: + if not event.data: + continue + self.init_orderbook(event.data) + elif event.type == StreamDataType.DELTA.value: + if not event.data: + continue + self.update_orderbook(event.data) + print("Orderbook stream disconnected, reconnecting...") + await asyncio.sleep(1) self.__task = loop.create_task(inner()) return self.__task diff --git a/x10/perpetual/simple_client/simple_trading_client.py b/x10/perpetual/simple_client/simple_trading_client.py index 988e500..99efe8b 100644 --- a/x10/perpetual/simple_client/simple_trading_client.py +++ b/x10/perpetual/simple_client/simple_trading_client.py @@ -25,12 +25,36 @@ from x10.utils.http import WrappedStreamResponse -async def condition_to_awaitable(condition: asyncio.Condition) -> Awaitable: +class AsyncMixin: + def __init__(self, *args, **kwargs): + """ + Standard constructor used for arguments pass + Do not override. Use __ainit__ instead + """ + self.__storedargs = args, kwargs + self.async_initialized = False + + async def __ainit__(self, *args, **kwargs): + """Async constructor, you should implement this""" + + async def __initobj(self): + """Crutch used for __await__ after spawning""" + assert not self.async_initialized + self.async_initialized = True + # pass the parameters to __ainit__ that passed to __init__ + await self.__ainit__(*self.__storedargs[0], **self.__storedargs[1]) + return self + + def __await__(self): + return self.__initobj().__await__() + + +def condition_to_awaitable(condition: asyncio.Condition) -> Awaitable: async def __inner(): async with condition: await condition.wait() - return await __inner() + return __inner() class TimedOpenOrderModel(OpenOrderModel): @@ -72,8 +96,8 @@ class CancelWaiter: end_nanos: int | None -class BlockingTradingClient: - def __init__(self, endpoint_config: EndpointConfig, account: StarkPerpetualAccount): +class BlockingTradingClient(AsyncMixin): + async def __ainit__(self, endpoint_config: EndpointConfig, account: StarkPerpetualAccount): self.__endpoint_config = endpoint_config self.__account = account self.__market_module = MarketsInformationModule(endpoint_config, api_key=account.api_key) @@ -85,14 +109,13 @@ def __init__(self, endpoint_config: EndpointConfig, account: StarkPerpetualAccou PerpetualStreamConnection[WrappedStreamResponse[AccountStreamDataModel]], ] = None self.__order_waiters: Dict[str, OrderWaiter] = {} - self.__cancel_waiters: Dict[int, CancelWaiter] = {} - self.__orders_task: Union[None, asyncio.Task] = None - self.__stream_lock = asyncio.Lock() + self.__cancel_waiters: Dict[str, CancelWaiter] = {} + self.__stream_task = asyncio.create_task(self.___order_stream()) - async def handle_cancel(self, order_id: int): - if order_id not in self.__cancel_waiters: + async def __handle_cancel(self, order_external_id: str): + if order_external_id not in self.__cancel_waiters: return - cancel_waiter = self.__cancel_waiters.get(order_id) + cancel_waiter = self.__cancel_waiters.get(order_external_id) if not cancel_waiter: return if cancel_waiter.condition: @@ -100,57 +123,61 @@ async def handle_cancel(self, order_id: int): cancel_waiter.end_nanos = time.time_ns() cancel_waiter.condition.notify_all() - async def handle_update(self, order: OpenOrderModel): - if order.external_id not in self.__order_waiters: - return - order_waiter = self.__order_waiters.get(order.external_id) - if not order_waiter: - return - if order_waiter.condition: - async with order_waiter.condition: - order_waiter.open_order = TimedOpenOrderModel( - start_nanos=order_waiter.start_nanos, - end_nanos=time.time_ns(), - open_order=order, - ) - order_waiter.condition.notify_all() - - async def handle_order(self, order: OpenOrderModel): + async def __handle_update(self, order: OpenOrderModel): + if order.status == OrderStatus.NEW.value: + if order.external_id not in self.__order_waiters: + return + order_waiter = self.__order_waiters.get(order.external_id) + if not order_waiter: + return + if order_waiter.condition: + async with order_waiter.condition: + order_waiter.open_order = TimedOpenOrderModel( + start_nanos=order_waiter.start_nanos, + end_nanos=time.time_ns(), + open_order=order, + ) + order_waiter.condition.notify_all() + + async def __handle_order(self, order: OpenOrderModel): if order.status == OrderStatus.CANCELLED.value: - await self.handle_cancel(order.id) + await self.__handle_cancel(order.external_id) else: - await self.handle_update(order) + await self.__handle_update(order) async def ___order_stream(self): + self.__account_stream = await self.__stream_client.subscribe_to_account_updates(self.__account.api_key) async for event in self.__account_stream: if not (event.data and event.data.orders): continue for order in event.data.orders: - await self.handle_order(order) + await self.__handle_order(order) + print("Order stream closed, reconnecting...") + await self.___order_stream() - async def cancel_order(self, order_id: int) -> TimedCancel: + async def cancel_order(self, order_external_id: str) -> TimedCancel: awaitable: Awaitable - if order_id in self.__cancel_waiters: - awaitable = condition_to_awaitable(self.__cancel_waiters[order_id].condition) + if order_external_id in self.__cancel_waiters: + awaitable = condition_to_awaitable(self.__cancel_waiters[order_external_id].condition) else: - self.__cancel_waiters[order_id] = CancelWaiter( + self.__cancel_waiters[order_external_id] = CancelWaiter( asyncio.Condition(), start_nanos=time.time_ns(), end_nanos=None ) - cancel_task = asyncio.create_task(self.__orders_module.cancel_order(order_id)) + cancel_task = asyncio.create_task(self.__orders_module.cancel_order_by_external_id(order_external_id)) awaitable = asyncio.gather( cancel_task, - asyncio.wait_for(condition_to_awaitable(self.__cancel_waiters[order_id].condition), 5), + asyncio.wait_for(condition_to_awaitable(self.__cancel_waiters[order_external_id].condition), 5), return_exceptions=False, ) - cancel_waiter = self.__cancel_waiters[order_id] + cancel_waiter = self.__cancel_waiters[order_external_id] end_nanos = None if cancel_waiter.end_nanos: end_nanos = cancel_waiter.end_nanos else: await awaitable - end_nanos = self.__cancel_waiters[order_id].end_nanos - del self.__cancel_waiters[order_id] + end_nanos = self.__cancel_waiters[order_external_id].end_nanos + del self.__cancel_waiters[order_external_id] end_nanos = cast(int, end_nanos) return TimedCancel( start_nanos=cancel_waiter.start_nanos, @@ -164,6 +191,20 @@ async def get_markets(self) -> Dict[str, MarketModel]: self.__markets = {m.name: m for m in markets.data} return self.__markets + async def mass_cancel( + self, + order_ids: list[int] | None = None, + external_order_ids: list[str] | None = None, + markets: list[str] | None = None, + cancel_all: bool = False, + ) -> None: + await self.__orders_module.mass_cancel( + order_ids=order_ids, + external_order_ids=external_order_ids, + markets=markets, + cancel_all=cancel_all, + ) + async def create_and_place_order( self, market_name: str, @@ -171,19 +212,13 @@ async def create_and_place_order( price: Decimal, side: OrderSide, post_only: bool = False, - previous_order_id: str | None = None, + previous_order_external_id: str | None = None, + external_id: str | None = None, ) -> TimedOpenOrderModel: market = (await self.get_markets()).get(market_name) if not market: raise ValueError(f"Market '{market_name}' not found.") - if not self.__account_stream: - await self.__stream_lock.acquire() - if not self.__account_stream: - self.__account_stream = await self.__stream_client.subscribe_to_account_updates(self.__account.api_key) - self.__orders_task = asyncio.create_task(self.___order_stream()) - self.__stream_lock.release() - order: PerpetualOrderModel = create_order_object( account=self.__account, market=market, @@ -191,8 +226,9 @@ async def create_and_place_order( price=price, side=side, post_only=post_only, - previous_order_id=previous_order_id, + previous_order_external_id=previous_order_external_id, starknet_domain=self.__endpoint_config.starknet_domain, + order_external_id=external_id, ) if order.id in self.__order_waiters: diff --git a/x10/perpetual/stream_client/perpetual_stream_connection.py b/x10/perpetual/stream_client/perpetual_stream_connection.py index e319758..77a8777 100644 --- a/x10/perpetual/stream_client/perpetual_stream_connection.py +++ b/x10/perpetual/stream_client/perpetual_stream_connection.py @@ -43,10 +43,8 @@ async def recv(self) -> StreamMsgResponseType: async def close(self): assert self.__websocket is not None - assert not self.__websocket.closed - - await self.__websocket.close() - + if not self.__websocket.closed: + await self.__websocket.close() LOGGER.debug("Stream closed: %s", self.__stream_url) @property @@ -67,8 +65,10 @@ async def __anext__(self) -> StreamMsgResponseType: if self.__websocket.closed: raise StopAsyncIteration - - return await self.__receive() + try: + return await self.__receive() + except websockets.ConnectionClosed: + raise StopAsyncIteration from None async def __receive(self) -> StreamMsgResponseType: assert self.__websocket is not None diff --git a/x10/perpetual/stream_client/stream_client.py b/x10/perpetual/stream_client/stream_client.py index a0b100e..fbe165c 100644 --- a/x10/perpetual/stream_client/stream_client.py +++ b/x10/perpetual/stream_client/stream_client.py @@ -24,12 +24,12 @@ def __init__(self, *, api_url: str): self.__api_url = api_url - def subscribe_to_orderbooks(self, market_name: Optional[str] = None): + def subscribe_to_orderbooks(self, market_name: Optional[str] = None, depth: int | None = None): """ https://api.docs.extended.exchange/#orderbooks-stream """ - url = self.__get_url("/orderbooks/", market=market_name) + url = self.__get_url("/orderbooks/" + (f"?depth={depth}" if depth else ""), market=market_name) return self.__connect(url, WrappedStreamResponse[OrderbookUpdateModel]) def subscribe_to_public_trades(self, market_name: Optional[str] = None): diff --git a/x10/perpetual/trading_client/trading_client.py b/x10/perpetual/trading_client/trading_client.py index 0d4d46e..32664dc 100644 --- a/x10/perpetual/trading_client/trading_client.py +++ b/x10/perpetual/trading_client/trading_client.py @@ -52,6 +52,7 @@ async def place_order( expire_time: Optional[datetime] = None, time_in_force: TimeInForce = TimeInForce.GTT, self_trade_protection_level: SelfTradeProtectionLevel = SelfTradeProtectionLevel.ACCOUNT, + external_id: Optional[str] = None, ) -> WrappedApiResponse[PlacedOrderModel]: if not self.__stark_account: raise ValueError("Stark account is not set") @@ -74,11 +75,12 @@ async def place_order( price=price, side=side, post_only=post_only, - previous_order_id=previous_order_id, + previous_order_external_id=previous_order_id, expire_time=expire_time, time_in_force=time_in_force, self_trade_protection_level=self_trade_protection_level, starknet_domain=self.__config.starknet_domain, + order_external_id=external_id, ) return await self.__order_management_module.place_order(order) diff --git a/x10/perpetual/user_client/onboarding.py b/x10/perpetual/user_client/onboarding.py index e53b259..2073dc1 100644 --- a/x10/perpetual/user_client/onboarding.py +++ b/x10/perpetual/user_client/onboarding.py @@ -39,6 +39,7 @@ class AccountRegistration: tos_accepted: bool time: datetime action: str + host: str def __post_init__(self): self.time_string = self.time.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") @@ -52,6 +53,7 @@ def to_signable_message(self, signing_domain) -> SignableMessage: "tosAccepted": self.tos_accepted, "time": self.time_string, "action": self.action, + "host": self.host, } types = { "EIP712Domain": [ @@ -63,6 +65,7 @@ def to_signable_message(self, signing_domain) -> SignableMessage: {"name": "tosAccepted", "type": "bool"}, {"name": "time", "type": "string"}, {"name": "action", "type": "string"}, + {"name": "host", "type": "string"}, ], } primary_type = "AccountRegistration" @@ -81,6 +84,7 @@ def to_json(self): "tosAccepted": self.tos_accepted, "time": self.time_string, "action": self.action, + "host": self.host, } @@ -127,7 +131,7 @@ def to_json(self): def get_registration_struct_to_sign( - account_index: int, address: str, timestamp: datetime, action: str + account_index: int, address: str, timestamp: datetime, action: str, host: str ) -> AccountRegistration: return AccountRegistration( account_index=account_index, @@ -135,6 +139,7 @@ def get_registration_struct_to_sign( tos_accepted=True, time=timestamp, action=action, + host=host, ) @@ -180,6 +185,7 @@ def get_onboarding_payload( account: LocalAccount, signing_domain: str, key_pair: StarkKeyPair, + host: str, time: datetime | None = None, referral_code: str | None = None, ) -> OnboardingPayLoad: @@ -187,11 +193,11 @@ def get_onboarding_payload( time = datetime.now(timezone.utc) registration_payload = get_registration_struct_to_sign( - account_index=0, address=account.address, timestamp=time, action=register_action + account_index=0, address=account.address, timestamp=time, action=register_action, host=host ) - l1_signature = account.sign_message( - registration_payload.to_signable_message(signing_domain=signing_domain) - ).signature.hex() + payload = registration_payload.to_signable_message(signing_domain=signing_domain) + l1_signature = account.sign_message(payload).signature.hex() + l2_message = pedersen_hash(int(account.address, 16), key_pair.public) l2_r, l2_s = stark_sign(msg_hash=l2_message, private_key=key_pair.private) @@ -207,16 +213,18 @@ def get_onboarding_payload( def get_sub_account_creation_payload( - account_index: int, l1_address: str, key_pair: StarkKeyPair, description: str, time: datetime | None = None + account_index: int, + l1_address: str, + key_pair: StarkKeyPair, + description: str, + host: str, + time: datetime | None = None, ): if time is None: time = datetime.now(timezone.utc) registration_payload = get_registration_struct_to_sign( - account_index=account_index, - address=l1_address, - timestamp=time, - action=sub_account_action, + account_index=account_index, address=l1_address, timestamp=time, action=sub_account_action, host=host ) l2_message = pedersen_hash(int(l1_address, 16), key_pair.public) diff --git a/x10/perpetual/user_client/user_client.py b/x10/perpetual/user_client/user_client.py index 273a538..efb96aa 100644 --- a/x10/perpetual/user_client/user_client.py +++ b/x10/perpetual/user_client/user_client.py @@ -78,6 +78,7 @@ async def onboard(self, referral_code: Optional[str] = None): signing_domain=self.__endpoint_config.signing_domain, key_pair=key_pair, referral_code=referral_code, + host=self.__endpoint_config.onboarding_url, ) url = self._get_url(self.__endpoint_config.onboarding_url, path="/auth/onboard") onboarding_response = await send_post_request( @@ -111,6 +112,7 @@ async def onboard_subaccount(self, account_index: int, description: str | None = l1_address=signing_account.address, key_pair=key_pair, description=description, + host=self.__endpoint_config.onboarding_url, ) headers = { L1_AUTH_SIGNATURE_HEADER: l1_signature.signature.hex(), From c606bc7f951b2e37af0fd200cb1732f71c53bf1b Mon Sep 17 00:00:00 2001 From: stefano Date: Tue, 15 Jul 2025 14:29:31 +0100 Subject: [PATCH 2/2] remove async constructor --- examples/simple_client_example.py | 5 ++- examples/simple_market_maker.py | 2 +- .../simple_client/simple_trading_client.py | 43 +++++++------------ 3 files changed, 20 insertions(+), 30 deletions(-) diff --git a/examples/simple_client_example.py b/examples/simple_client_example.py index d6a1733..bb91a96 100644 --- a/examples/simple_client_example.py +++ b/examples/simple_client_example.py @@ -20,7 +20,7 @@ async def setup_and_run(): api_key=api_key, ) - client = BlockingTradingClient(endpoint_config=TESTNET_CONFIG, account=stark_account) + client = await BlockingTradingClient.create(endpoint_config=TESTNET_CONFIG, account=stark_account) placed_order = await client.create_and_place_order( amount_of_synthetic=Decimal("1"), @@ -28,11 +28,12 @@ async def setup_and_run(): market_name="BTC-USD", side=OrderSide.BUY, post_only=False, + external_id="test_order_123", ) print(placed_order) - await client.cancel_order(placed_order.id) + await client.cancel_order(order_external_id=placed_order.external_id) if __name__ == "__main__": diff --git a/examples/simple_market_maker.py b/examples/simple_market_maker.py index f122f09..3a7f2b6 100644 --- a/examples/simple_market_maker.py +++ b/examples/simple_market_maker.py @@ -31,7 +31,7 @@ async def on_board_example(): root_account = await onboarding_client.onboard() trading_key = await onboarding_client.create_account_api_key(root_account.account, "trading_key") - root_trading_client = await BlockingTradingClient( + root_trading_client = await BlockingTradingClient.create( environment_config, StarkPerpetualAccount( vault=root_account.account.l2_vault, diff --git a/x10/perpetual/simple_client/simple_trading_client.py b/x10/perpetual/simple_client/simple_trading_client.py index 99efe8b..0e24e5b 100644 --- a/x10/perpetual/simple_client/simple_trading_client.py +++ b/x10/perpetual/simple_client/simple_trading_client.py @@ -25,30 +25,6 @@ from x10.utils.http import WrappedStreamResponse -class AsyncMixin: - def __init__(self, *args, **kwargs): - """ - Standard constructor used for arguments pass - Do not override. Use __ainit__ instead - """ - self.__storedargs = args, kwargs - self.async_initialized = False - - async def __ainit__(self, *args, **kwargs): - """Async constructor, you should implement this""" - - async def __initobj(self): - """Crutch used for __await__ after spawning""" - assert not self.async_initialized - self.async_initialized = True - # pass the parameters to __ainit__ that passed to __init__ - await self.__ainit__(*self.__storedargs[0], **self.__storedargs[1]) - return self - - def __await__(self): - return self.__initobj().__await__() - - def condition_to_awaitable(condition: asyncio.Condition) -> Awaitable: async def __inner(): async with condition: @@ -96,8 +72,12 @@ class CancelWaiter: end_nanos: int | None -class BlockingTradingClient(AsyncMixin): - async def __ainit__(self, endpoint_config: EndpointConfig, account: StarkPerpetualAccount): +class BlockingTradingClient: + def __init__(self, endpoint_config: EndpointConfig, account: StarkPerpetualAccount): + if not asyncio.get_event_loop().is_running(): + raise RuntimeError( + "BlockingTradingClient must be initialized from an async function, use BlockingTradingClient.create()" + ) self.__endpoint_config = endpoint_config self.__account = account self.__market_module = MarketsInformationModule(endpoint_config, api_key=account.api_key) @@ -112,6 +92,12 @@ async def __ainit__(self, endpoint_config: EndpointConfig, account: StarkPerpetu self.__cancel_waiters: Dict[str, CancelWaiter] = {} self.__stream_task = asyncio.create_task(self.___order_stream()) + @staticmethod + async def create(endpoint_config: EndpointConfig, account: StarkPerpetualAccount) -> "BlockingTradingClient": + client = BlockingTradingClient(endpoint_config, account) + await client.__stream_client.subscribe_to_account_updates(account.api_key) + return client + async def __handle_cancel(self, order_external_id: str): if order_external_id not in self.__cancel_waiters: return @@ -188,7 +174,10 @@ async def cancel_order(self, order_external_id: str) -> TimedCancel: async def get_markets(self) -> Dict[str, MarketModel]: if not self.__markets: markets = await self.__market_module.get_markets() - self.__markets = {m.name: m for m in markets.data} + market_data = markets.data + if not market_data: + raise ValueError("Core market data is empty, check your connection or API key.") + self.__markets = {m.name: m for m in market_data} return self.__markets async def mass_cancel(