From 6407ba2fbf51781d6477c2f6528570f0ab2f0093 Mon Sep 17 00:00:00 2001 From: 0xEniotna <0xKarasu@protonmail.com> Date: Fri, 19 Dec 2025 11:43:07 +0000 Subject: [PATCH 1/5] feat: add market orders --- .../placed_market_order_example_simple.py | 136 ++++++++++++++++++ x10/perpetual/order_object.py | 8 +- .../simple_client/simple_trading_client.py | 3 + .../trading_client/trading_client.py | 3 + 4 files changed, 149 insertions(+), 1 deletion(-) create mode 100644 examples/placed_market_order_example_simple.py diff --git a/examples/placed_market_order_example_simple.py b/examples/placed_market_order_example_simple.py new file mode 100644 index 0000000..08506a8 --- /dev/null +++ b/examples/placed_market_order_example_simple.py @@ -0,0 +1,136 @@ +import asyncio +import logging +import logging.config +import logging.handlers +import os +import random +from asyncio import run +from decimal import ROUND_DOWN, Decimal + +from dotenv import load_dotenv + +from x10.perpetual.accounts import StarkPerpetualAccount +from x10.perpetual.configuration import TESTNET_CONFIG +from x10.perpetual.orderbook import OrderBook +from x10.perpetual.orders import OrderSide, OrderType, TimeInForce +from x10.perpetual.trading_client import PerpetualTradingClient + +load_dotenv() +MARKET_NAME = "BTC-USD" +ORDER_QTY = Decimal("0.0001") + +API_KEY = os.getenv("X10_API_KEY") +PUBLIC_KEY = os.getenv("X10_PUBLIC_KEY") +PRIVATE_KEY = os.getenv("X10_PRIVATE_KEY") +VAULT_ID = int(os.environ["X10_VAULT_ID"]) + + +def round_to_step(value: Decimal, step: Decimal) -> Decimal: + """ + Round a Decimal down to the nearest multiple of `step`. + This matches typical "tick size" / "min price change" and size step constraints. + """ + if step <= 0: + return value + return (value / step).to_integral_value(rounding=ROUND_DOWN) * step + + +def marketable_price(side: OrderSide, best_bid: Decimal, best_ask: Decimal) -> Decimal: + """ + Create a marketable price using a fixed offset from best bid/ask. + Extended requires a price field even for MARKET+IOC. + """ + if side == OrderSide.BUY: + return best_ask * (Decimal("1") + Decimal("0.002")) + return best_bid * (Decimal("1") - Decimal("0.002")) + + +async def clean_it(trading_client: PerpetualTradingClient): + logger = logging.getLogger("placed_order_example") + positions = await trading_client.account.get_positions() + logger.info("Positions: %s", positions.to_pretty_json()) + balance = await trading_client.account.get_balance() + logger.info("Balance: %s", balance.to_pretty_json()) + open_orders = await trading_client.account.get_open_orders() + await trading_client.orders.mass_cancel(order_ids=[order.id for order in open_orders.data]) + + +async def setup_and_run(): + assert API_KEY is not None + assert PUBLIC_KEY is not None + assert PRIVATE_KEY is not None + assert VAULT_ID is not None + + stark_account = StarkPerpetualAccount( + vault=VAULT_ID, + private_key=PRIVATE_KEY, + public_key=PUBLIC_KEY, + api_key=API_KEY, + ) + trading_client = PerpetualTradingClient( + endpoint_config=TESTNET_CONFIG, + stark_account=stark_account, + ) + positions = await trading_client.account.get_positions() + for position in positions.data: + print( + f"market: {position.market} \ + side: {position.side} \ + size: {position.size} \ + mark_price: ${position.mark_price} \ + leverage: {position.leverage}" + ) + print(f"consumed im: ${round((position.size * position.mark_price) / position.leverage, 2)}") + + await clean_it(trading_client) + + markets = await trading_client.markets_info.get_markets_dict() + market = markets.get(MARKET_NAME) + tick_size = market.trading_config.min_price_change + size_step = market.trading_config.min_order_size_change + + orderbook = await OrderBook.create(endpoint_config=TESTNET_CONFIG, market_name=MARKET_NAME) + + await orderbook.start_orderbook() + + # Place a single MARKET+IOC order (venue requirement) using a marketable price. + # Note: this can open a real position on the venue. Use tiny size and close manually if needed. + while True: + bid = orderbook.best_bid() + ask = orderbook.best_ask() + if bid and ask: + best_bid = bid.price + best_ask = ask.price + break + await asyncio.sleep(0.5) + + side = OrderSide.BUY + raw_price = marketable_price(side=side, best_bid=best_bid, best_ask=best_ask) + price = round_to_step(raw_price, tick_size) + qty = round_to_step(ORDER_QTY, size_step) + if qty <= 0: + raise ValueError(f"Order qty rounds to 0 (ORDER_QTY={ORDER_QTY}, step={size_step})") + + external_id = str(random.randint(1, 10**40)) + placed = await trading_client.place_order( + market_name=MARKET_NAME, + amount_of_synthetic=qty, + price=price, + side=side, + order_type=OrderType.MARKET, + time_in_force=TimeInForce.IOC, + post_only=False, + external_id=external_id, + ) + placed_id = placed.data.id if placed.data is not None else None + print( + f"placed: market={MARKET_NAME} side={side.value} qty={qty} price={price} " + f"tif=IOC type=MARKET external_id={external_id} => id={placed_id}" + ) + + positions = await trading_client.account.get_positions() + print("positions:", positions.to_pretty_json()) + + +if __name__ == "__main__": + run(main=setup_and_run()) diff --git a/x10/perpetual/order_object.py b/x10/perpetual/order_object.py index 7ab7207..188d0d1 100644 --- a/x10/perpetual/order_object.py +++ b/x10/perpetual/order_object.py @@ -42,6 +42,7 @@ def create_order_object( price: Decimal, side: OrderSide, starknet_domain: StarknetDomain, + order_type: OrderType = OrderType.LIMIT, post_only: bool = False, previous_order_external_id: Optional[str] = None, expire_time: Optional[datetime] = None, @@ -76,6 +77,7 @@ def create_order_object( public_key=account.public_key, exact_only=False, expire_time=expire_time, + order_type=order_type, post_only=post_only, previous_order_external_id=previous_order_external_id, order_external_id=order_external_id, @@ -120,6 +122,7 @@ def __create_order_object( starknet_domain: StarknetDomain, exact_only: bool = False, expire_time: Optional[datetime] = None, + order_type: OrderType = OrderType.LIMIT, post_only: bool = False, previous_order_external_id: Optional[str] = None, order_external_id: Optional[str] = None, @@ -139,6 +142,9 @@ def __create_order_object( if time_in_force not in TimeInForce or time_in_force == TimeInForce.FOK: raise ValueError(f"Unexpected time in force value: {time_in_force}") + if order_type not in OrderType: + raise ValueError(f"Unexpected order type value: {order_type}") + if expire_time is None: raise ValueError("`expire_time` must be provided") @@ -203,7 +209,7 @@ def __create_order_object( order = NewOrderModel( id=order_id, market=market.name, - type=OrderType.LIMIT, + type=order_type, side=side, qty=settlement_data.synthetic_amount_human.value, price=price, diff --git a/x10/perpetual/simple_client/simple_trading_client.py b/x10/perpetual/simple_client/simple_trading_client.py index a8600c4..9273fd7 100644 --- a/x10/perpetual/simple_client/simple_trading_client.py +++ b/x10/perpetual/simple_client/simple_trading_client.py @@ -13,6 +13,7 @@ OpenOrderModel, OrderSide, OrderStatus, + OrderType, TimeInForce, ) from x10.perpetual.stream_client.perpetual_stream_connection import ( @@ -197,6 +198,7 @@ async def create_and_place_order( amount_of_synthetic: Decimal, price: Decimal, side: OrderSide, + order_type: OrderType = OrderType.LIMIT, post_only: bool = False, previous_order_external_id: str | None = None, external_id: str | None = None, @@ -214,6 +216,7 @@ async def create_and_place_order( amount_of_synthetic=amount_of_synthetic, price=price, side=side, + order_type=order_type, post_only=post_only, previous_order_external_id=previous_order_external_id, starknet_domain=self.__endpoint_config.starknet_domain, diff --git a/x10/perpetual/trading_client/trading_client.py b/x10/perpetual/trading_client/trading_client.py index 655123a..ea2473b 100644 --- a/x10/perpetual/trading_client/trading_client.py +++ b/x10/perpetual/trading_client/trading_client.py @@ -9,6 +9,7 @@ from x10.perpetual.orders import ( OrderSide, OrderTpslType, + OrderType, PlacedOrderModel, SelfTradeProtectionLevel, TimeInForce, @@ -48,6 +49,7 @@ async def place_order( amount_of_synthetic: Decimal, price: Decimal, side: OrderSide, + order_type: OrderType = OrderType.LIMIT, post_only: bool = False, previous_order_id=None, expire_time: Optional[datetime] = None, @@ -81,6 +83,7 @@ async def place_order( amount_of_synthetic=amount_of_synthetic, price=price, side=side, + order_type=order_type, post_only=post_only, previous_order_external_id=previous_order_id, expire_time=expire_time, From ca5fa07f8b15cd7d1259c48bec73043bfdb125e8 Mon Sep 17 00:00:00 2001 From: 0xEniotna <0xKarasu@protonmail.com> Date: Thu, 22 Jan 2026 16:20:34 +0000 Subject: [PATCH 2/5] feat: upgrade ws version --- pyproject.toml | 2 +- .../perpetual_stream_connection.py | 47 +++++++++++++++---- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c80b80a..5e582ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ pyyaml = ">=6.0.1" sortedcontainers = ">=2.4.0" strenum = "^0.4.15" tenacity = "^9.1.2" -websockets = ">=12.0,<14.0" +websockets = ">=12.0,<16.0" [tool.poetry.group.dev.dependencies] black = "==23.12.0" diff --git a/x10/perpetual/stream_client/perpetual_stream_connection.py b/x10/perpetual/stream_client/perpetual_stream_connection.py index 7f7c77b..0e8072f 100644 --- a/x10/perpetual/stream_client/perpetual_stream_connection.py +++ b/x10/perpetual/stream_client/perpetual_stream_connection.py @@ -1,5 +1,5 @@ from types import TracebackType -from typing import AsyncIterator, Generic, Optional, Type, TypeVar +from typing import AsyncIterator, Generic, Optional, Type, TypeVar, Union import websockets from websockets import WebSocketClientProtocol @@ -13,6 +13,31 @@ StreamMsgResponseType = TypeVar("StreamMsgResponseType", bound=X10BaseModel) +# Check websockets version for API compatibility +_WS_VERSION = tuple(int(x) for x in websockets.__version__.split(".")[:2]) +_WS_14_PLUS = _WS_VERSION >= (14, 0) + +# Import the correct connection type based on version +if _WS_14_PLUS: + from websockets.asyncio.client import ClientConnection as WebSocketConnection +else: + WebSocketConnection = WebSocketClientProtocol + + +def _is_ws_closed(ws: Union[WebSocketClientProtocol, "WebSocketConnection"]) -> bool: + """Check if websocket connection is closed (compatible with both ws 13 and 14+).""" + if _WS_14_PLUS: + # websockets 14+ uses state enum + try: + from websockets.protocol import State + return ws.state == State.CLOSED + except (ImportError, AttributeError): + # Fallback: try to check if close() was called + return getattr(ws, '_closed', False) + else: + # websockets 13 and earlier use .closed property + return ws.closed + class PerpetualStreamConnection(Generic[StreamMsgResponseType]): __stream_url: str @@ -45,7 +70,7 @@ async def recv(self) -> StreamMsgResponseType: async def close(self): assert self.__websocket is not None - if not self.__websocket.closed: + if not _is_ws_closed(self.__websocket): await self.__websocket.close() LOGGER.debug("Stream closed: %s", self.__stream_url) @@ -56,8 +81,7 @@ def msgs_count(self): @property def closed(self): assert self.__websocket is not None - - return self.__websocket.closed + return _is_ws_closed(self.__websocket) def __aiter__(self) -> AsyncIterator[StreamMsgResponseType]: return self @@ -65,7 +89,7 @@ def __aiter__(self) -> AsyncIterator[StreamMsgResponseType]: async def __anext__(self) -> StreamMsgResponseType: assert self.__websocket is not None - if self.__websocket.closed: + if _is_ws_closed(self.__websocket): raise StopAsyncIteration try: return await self.__receive() @@ -96,14 +120,19 @@ async def __aexit__( await self.close() async def __await_impl__(self): - extra_headers: dict[str, str] = { + headers: dict[str, str] = { RequestHeader.USER_AGENT: USER_AGENT, } if self.__api_key is not None: - extra_headers[RequestHeader.API_KEY] = self.__api_key - - self.__websocket = await websockets.connect(self.__stream_url, extra_headers=extra_headers) + headers[RequestHeader.API_KEY] = self.__api_key + + # websockets 14+ renamed extra_headers to additional_headers + ws_version = tuple(int(x) for x in websockets.__version__.split(".")[:2]) + if ws_version >= (14, 0): + self.__websocket = await websockets.connect(self.__stream_url, additional_headers=headers) + else: + self.__websocket = await websockets.connect(self.__stream_url, extra_headers=headers) LOGGER.debug("Connected to stream: %s", self.__stream_url) From 05fddd5f94238de4f29f2fcd1d0e71ff5f1dea6f Mon Sep 17 00:00:00 2001 From: 0xEniotna <0xKarasu@protonmail.com> Date: Wed, 25 Feb 2026 13:15:36 +0000 Subject: [PATCH 3/5] Add orderbook heartbeat callback for stale detection --- .../perpetual/test_orderbook_price_impact.py | 36 +++++++++++++++++++ x10/perpetual/orderbook.py | 25 ++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/tests/perpetual/test_orderbook_price_impact.py b/tests/perpetual/test_orderbook_price_impact.py index 5460dba..4864675 100644 --- a/tests/perpetual/test_orderbook_price_impact.py +++ b/tests/perpetual/test_orderbook_price_impact.py @@ -198,3 +198,39 @@ def test_calculate_qty_impact_invalid_side(self): qty = decimal.Decimal("1") result = self.orderbook.calculate_price_impact_qty(qty, "INVALID_SIDE") self.assertIsNone(result, "Result should be None for invalid side.") + + def test_orderbook_update_callback_runs_even_without_best_price_change(self): + callback_count = 0 + + async def _run(): + nonlocal callback_count + + async def on_update(): + nonlocal callback_count + callback_count += 1 + + ob = OrderBook( + self.endpoint_config, + self.market_name, + best_ask_change_callback=None, + best_bid_change_callback=None, + orderbook_update_callback=on_update, + ) + + initial = OrderbookUpdateModel( + market=self.market_name, + bid=[{"price": decimal.Decimal("100"), "qty": decimal.Decimal("1")}], + ask=[{"price": decimal.Decimal("101"), "qty": decimal.Decimal("1")}], + ) + await ob.update_orderbook(initial) + + # Same top-of-book price, only size changes. + no_best_change = OrderbookUpdateModel( + market=self.market_name, + bid=[{"price": decimal.Decimal("100"), "qty": decimal.Decimal("1")}], + ask=[{"price": decimal.Decimal("101"), "qty": decimal.Decimal("-0.2")}], + ) + await ob.update_orderbook(no_best_change) + + asyncio.run(_run()) + self.assertEqual(callback_count, 2) diff --git a/x10/perpetual/orderbook.py b/x10/perpetual/orderbook.py index d04837c..be7a8b3 100644 --- a/x10/perpetual/orderbook.py +++ b/x10/perpetual/orderbook.py @@ -1,6 +1,7 @@ import asyncio import dataclasses import decimal +import logging from collections.abc import Awaitable from typing import Callable, Iterable, Tuple @@ -11,6 +12,8 @@ from x10.perpetual.stream_client.stream_client import PerpetualStreamClient from x10.utils.http import StreamDataType +logger = logging.getLogger(__name__) + @dataclasses.dataclass class OrderBookEntry: @@ -34,10 +37,18 @@ async def create( market_name: str, best_ask_change_callback: Callable[[OrderBookEntry | None], Awaitable[None]] | None = None, best_bid_change_callback: Callable[[OrderBookEntry | None], Awaitable[None]] | None = None, + orderbook_update_callback: Callable[[], Awaitable[None]] | None = None, start=False, depth: int | None = None, ) -> "OrderBook": - ob = OrderBook(endpoint_config, market_name, best_ask_change_callback, best_bid_change_callback, depth) + ob = OrderBook( + endpoint_config, + market_name, + best_ask_change_callback, + best_bid_change_callback, + depth, + orderbook_update_callback, + ) if start: await ob.start_orderbook() return ob @@ -49,6 +60,7 @@ def __init__( best_ask_change_callback: Callable[[OrderBookEntry | None], Awaitable[None]] | None = None, best_bid_change_callback: Callable[[OrderBookEntry | None], Awaitable[None]] | None = None, depth: int | None = None, + orderbook_update_callback: Callable[[], Awaitable[None]] | None = None, ) -> None: self.__stream_client = PerpetualStreamClient(api_url=endpoint_config.stream_url) self.__market_name = market_name @@ -57,8 +69,17 @@ def __init__( self._ask_prices: "SortedDict[decimal.Decimal, OrderBookEntry]" = SortedDict() # type: ignore self.best_ask_change_callback = best_ask_change_callback self.best_bid_change_callback = best_bid_change_callback + self.orderbook_update_callback = orderbook_update_callback self.depth = depth + async def _notify_orderbook_update(self) -> None: + if self.orderbook_update_callback is None: + return + try: + await self.orderbook_update_callback() + except Exception as exc: + logger.error("Error in orderbook update callback: %s", exc, exc_info=True) + async def update_orderbook(self, data: OrderbookUpdateModel): best_bid_before_update = self.best_bid() for bid in data.bid: @@ -93,6 +114,7 @@ async def update_orderbook(self, data: OrderbookUpdateModel): if best_ask_before_update != now_best_ask: if self.best_ask_change_callback: await self.best_ask_change_callback(now_best_ask) + await self._notify_orderbook_update() async def init_orderbook(self, data: OrderbookUpdateModel): self._bid_prices.clear() @@ -119,6 +141,7 @@ async def init_orderbook(self, data: OrderbookUpdateModel): if best_ask_before_update != now_best_ask: if self.best_ask_change_callback: await self.best_ask_change_callback(now_best_ask) + await self._notify_orderbook_update() async def start_orderbook(self) -> asyncio.Task: loop = asyncio.get_running_loop() From 27f81ddacbd9e59655a2ca7999deef87879a0d5d Mon Sep 17 00:00:00 2001 From: 0xEniotna <0xKarasu@protonmail.com> Date: Wed, 25 Feb 2026 21:48:58 +0000 Subject: [PATCH 4/5] Add max fee rate semantics and deadman switch support --- examples/01_create_limit_order.py | 5 +- ...02_create_limit_order_with_partial_tpsl.py | 5 +- examples/03_subscribe_to_stream.py | 3 +- .../04_create_limit_order_with_builder.py | 5 +- examples/05_bridged_withdrawal.py | 3 +- examples/onboarding_example.py | 1 - .../placed_market_order_example_simple.py | 1 - examples/placed_order_example_simple.py | 1 - tests/perpetual/test_onboarding_payload.py | 1 - tests/perpetual/test_order_object.py | 96 ++++++++++++++++++- tests/perpetual/test_trading_client.py | 28 +++++- tests/perpetual/test_transfer_object.py | 1 - tests/utils/test_date.py | 1 - tests/utils/test_http.py | 1 - tests/utils/test_model.py | 1 - x10/perpetual/order_object.py | 25 +++-- x10/perpetual/order_object_settlement.py | 9 +- x10/perpetual/orderbook.py | 29 ++++++ .../trading_client/account_module.py | 16 ++++ .../trading_client/trading_client.py | 11 ++- 20 files changed, 214 insertions(+), 29 deletions(-) diff --git a/examples/01_create_limit_order.py b/examples/01_create_limit_order.py index 4a3a196..17e855d 100644 --- a/examples/01_create_limit_order.py +++ b/examples/01_create_limit_order.py @@ -1,8 +1,6 @@ import logging from asyncio import run -from examples.init_env import init_env -from examples.utils import find_order_and_cancel, get_adjust_price_by_pct from x10.config import ETH_USD_MARKET from x10.perpetual.accounts import StarkPerpetualAccount from x10.perpetual.configuration import MAINNET_CONFIG @@ -10,6 +8,9 @@ from x10.perpetual.orders import OrderSide, TimeInForce from x10.perpetual.trading_client import PerpetualTradingClient +from examples.init_env import init_env +from examples.utils import find_order_and_cancel, get_adjust_price_by_pct + LOGGER = logging.getLogger() MARKET_NAME = ETH_USD_MARKET ENDPOINT_CONFIG = MAINNET_CONFIG diff --git a/examples/02_create_limit_order_with_partial_tpsl.py b/examples/02_create_limit_order_with_partial_tpsl.py index a8ef98a..19a8fc9 100644 --- a/examples/02_create_limit_order_with_partial_tpsl.py +++ b/examples/02_create_limit_order_with_partial_tpsl.py @@ -1,8 +1,6 @@ import logging from asyncio import run -from examples.init_env import init_env -from examples.utils import find_order_and_cancel, get_adjust_price_by_pct from x10.config import ETH_USD_MARKET from x10.perpetual.accounts import StarkPerpetualAccount from x10.perpetual.configuration import MAINNET_CONFIG @@ -16,6 +14,9 @@ ) from x10.perpetual.trading_client import PerpetualTradingClient +from examples.init_env import init_env +from examples.utils import find_order_and_cancel, get_adjust_price_by_pct + LOGGER = logging.getLogger() MARKET_NAME = ETH_USD_MARKET ENDPOINT_CONFIG = MAINNET_CONFIG diff --git a/examples/03_subscribe_to_stream.py b/examples/03_subscribe_to_stream.py index 4c6f00c..4af00f2 100644 --- a/examples/03_subscribe_to_stream.py +++ b/examples/03_subscribe_to_stream.py @@ -3,11 +3,12 @@ from asyncio import run from signal import SIGINT, SIGTERM -from examples.init_env import init_env from x10.config import ETH_USD_MARKET from x10.perpetual.configuration import MAINNET_CONFIG from x10.perpetual.stream_client import PerpetualStreamClient +from examples.init_env import init_env + LOGGER = logging.getLogger() MARKET_NAME = ETH_USD_MARKET ENDPOINT_CONFIG = MAINNET_CONFIG diff --git a/examples/04_create_limit_order_with_builder.py b/examples/04_create_limit_order_with_builder.py index 97ba2b4..9a2f185 100644 --- a/examples/04_create_limit_order_with_builder.py +++ b/examples/04_create_limit_order_with_builder.py @@ -1,8 +1,6 @@ import logging.handlers from asyncio import run -from examples.init_env import init_env -from examples.utils import find_order_and_cancel, get_adjust_price_by_pct from x10.config import ETH_USD_MARKET from x10.perpetual.accounts import StarkPerpetualAccount from x10.perpetual.configuration import MAINNET_CONFIG @@ -10,6 +8,9 @@ from x10.perpetual.orders import OrderSide, TimeInForce from x10.perpetual.trading_client import PerpetualTradingClient +from examples.init_env import init_env +from examples.utils import find_order_and_cancel, get_adjust_price_by_pct + LOGGER = logging.getLogger() MARKET_NAME = ETH_USD_MARKET ENDPOINT_CONFIG = MAINNET_CONFIG diff --git a/examples/05_bridged_withdrawal.py b/examples/05_bridged_withdrawal.py index a11cab1..5826f0d 100644 --- a/examples/05_bridged_withdrawal.py +++ b/examples/05_bridged_withdrawal.py @@ -2,11 +2,12 @@ from asyncio import run from decimal import Decimal -from examples.init_env import init_env from x10.perpetual.accounts import StarkPerpetualAccount from x10.perpetual.configuration import MAINNET_CONFIG from x10.perpetual.trading_client import PerpetualTradingClient +from examples.init_env import init_env + LOGGER = logging.getLogger() ENDPOINT_CONFIG = MAINNET_CONFIG diff --git a/examples/onboarding_example.py b/examples/onboarding_example.py index 5b632f7..41cf686 100644 --- a/examples/onboarding_example.py +++ b/examples/onboarding_example.py @@ -2,7 +2,6 @@ from eth_account import Account from eth_account.signers.local import LocalAccount - from x10.perpetual.accounts import StarkPerpetualAccount from x10.perpetual.configuration import TESTNET_CONFIG from x10.perpetual.trading_client.trading_client import PerpetualTradingClient diff --git a/examples/placed_market_order_example_simple.py b/examples/placed_market_order_example_simple.py index 08506a8..5567f2a 100644 --- a/examples/placed_market_order_example_simple.py +++ b/examples/placed_market_order_example_simple.py @@ -8,7 +8,6 @@ from decimal import ROUND_DOWN, Decimal from dotenv import load_dotenv - from x10.perpetual.accounts import StarkPerpetualAccount from x10.perpetual.configuration import TESTNET_CONFIG from x10.perpetual.orderbook import OrderBook diff --git a/examples/placed_order_example_simple.py b/examples/placed_order_example_simple.py index 3c5813c..2731e02 100644 --- a/examples/placed_order_example_simple.py +++ b/examples/placed_order_example_simple.py @@ -8,7 +8,6 @@ from decimal import Decimal from dotenv import load_dotenv - from x10.perpetual.accounts import StarkPerpetualAccount from x10.perpetual.configuration import TESTNET_CONFIG from x10.perpetual.orderbook import OrderBook diff --git a/tests/perpetual/test_onboarding_payload.py b/tests/perpetual/test_onboarding_payload.py index 58fa0a7..a6a201c 100644 --- a/tests/perpetual/test_onboarding_payload.py +++ b/tests/perpetual/test_onboarding_payload.py @@ -1,7 +1,6 @@ import datetime from eth_account import Account - from x10.perpetual.user_client.onboarding import get_l2_keys_from_l1_account diff --git a/tests/perpetual/test_order_object.py b/tests/perpetual/test_order_object.py index cc6c4b3..30084cf 100644 --- a/tests/perpetual/test_order_object.py +++ b/tests/perpetual/test_order_object.py @@ -5,7 +5,6 @@ from freezegun import freeze_time from hamcrest import assert_that, equal_to, has_entries from pytest_mock import MockerFixture - from x10.perpetual.configuration import TESTNET_CONFIG from x10.perpetual.orders import ( OrderPriceType, @@ -358,3 +357,98 @@ async def test_external_order_id(mocker: MockerFixture, create_trading_account, } ), ) + + +@freeze_time("2024-01-05 01:08:56.860694") +@pytest.mark.asyncio +async def test_post_only_uses_maker_fee_by_default( + mocker: MockerFixture, create_trading_account, create_btc_usd_market +): + mocker.patch("x10.utils.nonce.generate_nonce", return_value=FROZEN_NONCE) + + from x10.perpetual.order_object import create_order_object + + trading_account = create_trading_account() + btc_usd_market = create_btc_usd_market() + order_obj = create_order_object( + account=trading_account, + market=btc_usd_market, + amount_of_synthetic=Decimal("0.00100000"), + price=Decimal("43445.11680000"), + side=OrderSide.SELL, + expire_time=utc_now() + timedelta(days=14), + post_only=True, + starknet_domain=TESTNET_CONFIG.starknet_domain, + ) + + assert_that( + order_obj.to_api_request_json(), + has_entries( + { + "postOnly": equal_to(True), + "fee": equal_to("0.0002"), + } + ), + ) + + +@freeze_time("2024-01-05 01:08:56.860694") +@pytest.mark.asyncio +async def test_max_fee_rate_overrides_payload_fee_and_settlement( + mocker: MockerFixture, create_trading_account, create_btc_usd_market +): + mocker.patch("x10.utils.nonce.generate_nonce", return_value=FROZEN_NONCE) + + from x10.perpetual.order_object import create_order_object + + trading_account = create_trading_account() + btc_usd_market = create_btc_usd_market() + order_obj = create_order_object( + account=trading_account, + market=btc_usd_market, + amount_of_synthetic=Decimal("0.00100000"), + price=Decimal("43445.11680000"), + side=OrderSide.BUY, + expire_time=utc_now() + timedelta(days=14), + max_fee_rate=Decimal("0.0001"), + starknet_domain=TESTNET_CONFIG.starknet_domain, + ) + + payload = order_obj.to_api_request_json() + assert_that(payload, has_entries({"fee": equal_to("0.0001")})) + fee_amount = Decimal(payload["debuggingAmounts"]["feeAmount"]) + # Lower max-fee cap should lower settlement fee amount from default (21723) + assert fee_amount > 0 + assert fee_amount < Decimal("21723") + + +@freeze_time("2024-01-05 01:08:56.860694") +@pytest.mark.asyncio +async def test_fee_alias_maps_to_max_fee_rate( + mocker: MockerFixture, create_trading_account, create_btc_usd_market +): + mocker.patch("x10.utils.nonce.generate_nonce", return_value=FROZEN_NONCE) + + from x10.perpetual.order_object import create_order_object + + trading_account = create_trading_account() + btc_usd_market = create_btc_usd_market() + order_obj = create_order_object( + account=trading_account, + market=btc_usd_market, + amount_of_synthetic=Decimal("0.00100000"), + price=Decimal("43445.11680000"), + side=OrderSide.BUY, + expire_time=utc_now() + timedelta(days=14), + fee=Decimal("0.00015"), + starknet_domain=TESTNET_CONFIG.starknet_domain, + ) + + assert_that( + order_obj.to_api_request_json(), + has_entries( + { + "fee": equal_to("0.00015"), + } + ), + ) diff --git a/tests/perpetual/test_trading_client.py b/tests/perpetual/test_trading_client.py index fdc71eb..78bc568 100644 --- a/tests/perpetual/test_trading_client.py +++ b/tests/perpetual/test_trading_client.py @@ -4,7 +4,6 @@ import pytest from aiohttp import web from hamcrest import assert_that, equal_to, has_length - from x10.perpetual.assets import AssetOperationModel from x10.perpetual.configuration import TESTNET_CONFIG from x10.perpetual.markets import MarketModel @@ -170,3 +169,30 @@ async def test_get_asset_operations(aiohttp_server, create_asset_operations, cre ] ), ) + + +@pytest.mark.asyncio +async def test_set_deadman_switch(aiohttp_server, create_trading_account): + from x10.perpetual.trading_client import PerpetualTradingClient + + expected_response = WrappedApiResponse.model_validate({"status": "OK", "data": None}) + + captured_query = {} + + async def _set_deadman(request): + captured_query.update(dict(request.query)) + return web.Response(text=expected_response.model_dump_json()) + + app = web.Application() + app.router.add_post("/user/deadmanswitch", _set_deadman) + + server = await aiohttp_server(app) + url = f"http://{server.host}:{server.port}" + + stark_account = create_trading_account() + endpoint_config = dataclasses.replace(TESTNET_CONFIG, api_base_url=url) + trading_client = PerpetualTradingClient(endpoint_config=endpoint_config, stark_account=stark_account) + + response = await trading_client.account.set_deadman_switch(60) + assert_that(response.status, equal_to("OK")) + assert_that(captured_query.get("countdownTime"), equal_to("60")) diff --git a/tests/perpetual/test_transfer_object.py b/tests/perpetual/test_transfer_object.py index 144bf70..79a9ad6 100644 --- a/tests/perpetual/test_transfer_object.py +++ b/tests/perpetual/test_transfer_object.py @@ -4,7 +4,6 @@ from freezegun import freeze_time from hamcrest import assert_that, equal_to from pytest_mock import MockerFixture - from x10.perpetual.configuration import TESTNET_CONFIG FROZEN_NONCE = 1473459052 diff --git a/tests/utils/test_date.py b/tests/utils/test_date.py index 48c6148..8707565 100644 --- a/tests/utils/test_date.py +++ b/tests/utils/test_date.py @@ -1,7 +1,6 @@ from datetime import datetime from hamcrest import assert_that, equal_to, raises - from x10.utils.date import to_epoch_millis diff --git a/tests/utils/test_http.py b/tests/utils/test_http.py index 33be3db..2dacaf6 100644 --- a/tests/utils/test_http.py +++ b/tests/utils/test_http.py @@ -1,6 +1,5 @@ from hamcrest import assert_that, equal_to, raises from strenum import StrEnum - from x10.utils.http import get_url diff --git a/tests/utils/test_model.py b/tests/utils/test_model.py index 5161b0d..db45f07 100644 --- a/tests/utils/test_model.py +++ b/tests/utils/test_model.py @@ -3,7 +3,6 @@ from hamcrest import assert_that, equal_to, raises from pydantic import ValidationError - from x10.utils.model import X10BaseModel diff --git a/x10/perpetual/order_object.py b/x10/perpetual/order_object.py index 188d0d1..df164c6 100644 --- a/x10/perpetual/order_object.py +++ b/x10/perpetual/order_object.py @@ -50,7 +50,10 @@ def create_order_object( time_in_force: TimeInForce = TimeInForce.GTT, self_trade_protection_level: SelfTradeProtectionLevel = SelfTradeProtectionLevel.ACCOUNT, nonce: Optional[int] = None, + max_fee_rate: Optional[Decimal] = None, + fee: Optional[Decimal] = None, builder_fee: Optional[Decimal] = None, + builder_fee_rate: Optional[Decimal] = None, builder_id: Optional[int] = None, reduce_only: bool = False, tp_sl_type: Optional[OrderTpslType] = None, @@ -66,6 +69,11 @@ def create_order_object( fees = account.trading_fee.get(market.name, DEFAULT_FEES) + resolved_max_fee_rate = max_fee_rate if max_fee_rate is not None else fee + resolved_builder_fee_rate = ( + builder_fee_rate if builder_fee_rate is not None else builder_fee + ) + return __create_order_object( market=market, synthetic_amount=amount_of_synthetic, @@ -85,7 +93,8 @@ def create_order_object( self_trade_protection_level=self_trade_protection_level, starknet_domain=starknet_domain, nonce=nonce, - builder_fee=builder_fee, + max_fee_rate=resolved_max_fee_rate, + builder_fee_rate=resolved_builder_fee_rate, builder_id=builder_id, reduce_only=reduce_only, tp_sl_type=tp_sl_type, @@ -129,7 +138,8 @@ def __create_order_object( time_in_force: TimeInForce = TimeInForce.GTT, self_trade_protection_level: SelfTradeProtectionLevel = SelfTradeProtectionLevel.ACCOUNT, nonce: Optional[int] = None, - builder_fee: Optional[Decimal] = None, + max_fee_rate: Optional[Decimal] = None, + builder_fee_rate: Optional[Decimal] = None, builder_id: Optional[int] = None, reduce_only: bool = False, tp_sl_type: Optional[OrderTpslType] = None, @@ -162,12 +172,15 @@ def __create_order_object( if nonce is None: nonce = generate_nonce() - fee_rate = fees.taker_fee_rate + if max_fee_rate is not None: + fee_rate = max_fee_rate + else: + fee_rate = fees.maker_fee_rate if post_only else fees.taker_fee_rate settlement_data_ctx = SettlementDataCtx( market=market, - fees=fees, - builder_fee=builder_fee, + max_fee_rate=fee_rate, + builder_fee_rate=builder_fee_rate, nonce=nonce, collateral_position_id=collateral_position_id, expire_time=expire_time, @@ -225,7 +238,7 @@ def __create_order_object( take_profit=tp_trigger_model, stop_loss=sl_trigger_model, debugging_amounts=settlement_data.debugging_amounts, - builderFee=builder_fee, + builderFee=builder_fee_rate, builderId=builder_id, reduce_only=reduce_only, ) diff --git a/x10/perpetual/order_object_settlement.py b/x10/perpetual/order_object_settlement.py index 336ab95..46f2a0a 100644 --- a/x10/perpetual/order_object_settlement.py +++ b/x10/perpetual/order_object_settlement.py @@ -14,7 +14,6 @@ StarkAmount, ) from x10.perpetual.configuration import StarknetDomain -from x10.perpetual.fees import TradingFeeModel from x10.perpetual.markets import MarketModel from x10.perpetual.orders import ( OrderSide, @@ -35,8 +34,8 @@ class OrderSettlementData: @dataclass(kw_only=True) class SettlementDataCtx: market: MarketModel - fees: TradingFeeModel - builder_fee: Optional[Decimal] + max_fee_rate: Decimal + builder_fee_rate: Optional[Decimal] nonce: int collateral_position_id: int expire_time: datetime @@ -95,7 +94,9 @@ def create_order_settlement_data( synthetic_amount_human = HumanReadableAmount(synthetic_amount, ctx.market.synthetic_asset) collateral_amount_human = HumanReadableAmount(synthetic_amount * price, ctx.market.collateral_asset) - total_fee = ctx.fees.taker_fee_rate + (ctx.builder_fee if ctx.builder_fee is not None else 0) + total_fee = ctx.max_fee_rate + ( + ctx.builder_fee_rate if ctx.builder_fee_rate is not None else Decimal("0") + ) fee_amount_human = HumanReadableAmount( total_fee * collateral_amount_human.value, ctx.market.collateral_asset, diff --git a/x10/perpetual/orderbook.py b/x10/perpetual/orderbook.py index be7a8b3..37a39ed 100644 --- a/x10/perpetual/orderbook.py +++ b/x10/perpetual/orderbook.py @@ -38,6 +38,8 @@ async def create( best_ask_change_callback: Callable[[OrderBookEntry | None], Awaitable[None]] | None = None, best_bid_change_callback: Callable[[OrderBookEntry | None], Awaitable[None]] | None = None, orderbook_update_callback: Callable[[], Awaitable[None]] | None = None, + sequence_gap_callback: Callable[[int, int], Awaitable[None]] | None = None, + snapshot_callback: Callable[[int], Awaitable[None]] | None = None, start=False, depth: int | None = None, ) -> "OrderBook": @@ -48,6 +50,8 @@ async def create( best_bid_change_callback, depth, orderbook_update_callback, + sequence_gap_callback, + snapshot_callback, ) if start: await ob.start_orderbook() @@ -61,6 +65,8 @@ def __init__( best_bid_change_callback: Callable[[OrderBookEntry | None], Awaitable[None]] | None = None, depth: int | None = None, orderbook_update_callback: Callable[[], Awaitable[None]] | None = None, + sequence_gap_callback: Callable[[int, int], Awaitable[None]] | None = None, + snapshot_callback: Callable[[int], Awaitable[None]] | None = None, ) -> None: self.__stream_client = PerpetualStreamClient(api_url=endpoint_config.stream_url) self.__market_name = market_name @@ -70,7 +76,10 @@ def __init__( self.best_ask_change_callback = best_ask_change_callback self.best_bid_change_callback = best_bid_change_callback self.orderbook_update_callback = orderbook_update_callback + self.sequence_gap_callback = sequence_gap_callback + self.snapshot_callback = snapshot_callback self.depth = depth + self.__last_seq: int | None = None async def _notify_orderbook_update(self) -> None: if self.orderbook_update_callback is None: @@ -149,10 +158,30 @@ async def start_orderbook(self) -> asyncio.Task: async def inner(): while True: async with self.__stream_client.subscribe_to_orderbooks(self.__market_name, depth=self.depth) as stream: + self.__last_seq = None async for event in stream: + current_seq = int(getattr(event, "seq", 0)) + if ( + self.__last_seq is not None + and current_seq != (self.__last_seq + 1) + ): + prev = self.__last_seq + logger.critical( + "Orderbook sequence gap for %s: prev=%s current=%s", + self.__market_name, + prev, + current_seq, + ) + if self.sequence_gap_callback is not None: + await self.sequence_gap_callback(prev, current_seq) + # Break and reconnect from a fresh snapshot. + break + self.__last_seq = current_seq if event.type == StreamDataType.SNAPSHOT: if not event.data: continue + if self.snapshot_callback is not None: + await self.snapshot_callback(current_seq) await self.init_orderbook(event.data) elif event.type == StreamDataType.DELTA: if not event.data: diff --git a/x10/perpetual/trading_client/account_module.py b/x10/perpetual/trading_client/account_module.py index 93d8bfa..ce06765 100644 --- a/x10/perpetual/trading_client/account_module.py +++ b/x10/perpetual/trading_client/account_module.py @@ -209,6 +209,22 @@ async def commit_bridge_quote(self, id: str): ) await send_post_request(await self.get_session(), url, EmptyModel, api_key=self._get_api_key()) + async def set_deadman_switch(self, countdown_time_s: int) -> WrappedApiResponse[EmptyModel]: + """ + https://api.docs.extended.exchange/#set-dead-man-switch + """ + + url = self._get_url( + "/user/deadmanswitch", + query={"countdownTime": max(0, int(countdown_time_s))}, + ) + return await send_post_request( + await self.get_session(), + url, + EmptyModel, + api_key=self._get_api_key(), + ) + async def transfer( self, to_vault: int, diff --git a/x10/perpetual/trading_client/trading_client.py b/x10/perpetual/trading_client/trading_client.py index ea2473b..05496eb 100644 --- a/x10/perpetual/trading_client/trading_client.py +++ b/x10/perpetual/trading_client/trading_client.py @@ -56,7 +56,10 @@ async def place_order( time_in_force: TimeInForce = TimeInForce.GTT, self_trade_protection_level: SelfTradeProtectionLevel = SelfTradeProtectionLevel.ACCOUNT, external_id: Optional[str] = None, + max_fee_rate: Optional[Decimal] = None, + fee: Optional[Decimal] = None, builder_fee: Optional[Decimal] = None, + builder_fee_rate: Optional[Decimal] = None, builder_id: Optional[int] = None, reduce_only: bool = False, tp_sl_type: Optional[OrderTpslType] = None, @@ -77,6 +80,11 @@ async def place_order( if expire_time is None: expire_time = utc_now() + timedelta(hours=1) + resolved_max_fee_rate = max_fee_rate if max_fee_rate is not None else fee + resolved_builder_fee_rate = ( + builder_fee_rate if builder_fee_rate is not None else builder_fee + ) + order = create_order_object( account=self.__stark_account, market=market, @@ -91,7 +99,8 @@ async def place_order( self_trade_protection_level=self_trade_protection_level, starknet_domain=self.__config.starknet_domain, order_external_id=external_id, - builder_fee=builder_fee, + max_fee_rate=resolved_max_fee_rate, + builder_fee_rate=resolved_builder_fee_rate, builder_id=builder_id, reduce_only=reduce_only, tp_sl_type=tp_sl_type, From 25c3ae8ae8da66504137e78af6fad21672bdbb06 Mon Sep 17 00:00:00 2001 From: 0xEniotna <0xKarasu@protonmail.com> Date: Sat, 28 Feb 2026 17:41:30 +0000 Subject: [PATCH 5/5] Handle empty POST responses in HTTP client --- x10/utils/http.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/x10/utils/http.py b/x10/utils/http.py index 625e97f..84a808a 100644 --- a/x10/utils/http.py +++ b/x10/utils/http.py @@ -162,6 +162,11 @@ async def send_post_request( async with session.post(url, json=json, headers=headers) as response: response_text = await response.text() + + if response_text == "": + LOGGER.warning("Empty HTTP %s response from POST %s", response.status, url) + response_text = '{"status": "OK"}' + handle_known_errors(url, response_code_to_exception, response, response_text) response_model = parse_response_to_model(response_text, model_class)