From 228f6ffce914a93b7cc79a0f74fc0952b4081f82 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Wed, 24 Jun 2026 13:13:58 +0400 Subject: [PATCH 01/41] Add stream RPC client --- tests/clients/test_streamrpc_client.py | 0 x10/clients/streamrpc/__init__.py | 0 x10/clients/streamrpc/streamrpc_client.py | 2 ++ 3 files changed, 2 insertions(+) create mode 100644 tests/clients/test_streamrpc_client.py create mode 100644 x10/clients/streamrpc/__init__.py create mode 100644 x10/clients/streamrpc/streamrpc_client.py diff --git a/tests/clients/test_streamrpc_client.py b/tests/clients/test_streamrpc_client.py new file mode 100644 index 00000000..e69de29b diff --git a/x10/clients/streamrpc/__init__.py b/x10/clients/streamrpc/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py new file mode 100644 index 00000000..343881a0 --- /dev/null +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -0,0 +1,2 @@ +class StreamRPCClient: + pass From ce0d3f69e4986011393718100c4f44e83e4b915a Mon Sep 17 00:00:00 2001 From: alex101xela Date: Fri, 26 Jun 2026 11:02:53 +0400 Subject: [PATCH 02/41] Add stream RPC client --- .../cases/stream/subscribe_to_rpc_stream.py | 82 +++++++++++++++++++ examples/utils.py | 4 + tests/clients/test_streamrpc_client.py | 2 + x10/clients/streamrpc/streamrpc_client.py | 42 +++++++++- x10/config.py | 2 + x10/core/client_config.py | 1 + 6 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 examples/cases/stream/subscribe_to_rpc_stream.py diff --git a/examples/cases/stream/subscribe_to_rpc_stream.py b/examples/cases/stream/subscribe_to_rpc_stream.py new file mode 100644 index 00000000..fb333c79 --- /dev/null +++ b/examples/cases/stream/subscribe_to_rpc_stream.py @@ -0,0 +1,82 @@ +import asyncio +import logging +from asyncio import run +from signal import SIGINT, SIGTERM + +from examples.utils import BTC_USD_MARKET, create_stream_rpc_client, init_env +from x10.config import get_config_by_name + +LOGGER = logging.getLogger() +MARKET_NAME = BTC_USD_MARKET + + +# def on_trade(env: StreamEnvelope[PublicTrade]) -> None: +# for t in env.data: +# print(f"[trade] {t.market} {t.side.value} {t.qty} @ {t.price} (seq={env.seq})") + + +async def subscribe_to_rpc_stream(stop_event: asyncio.Event): + env_config = init_env() + client_config = get_config_by_name(env_config.client_config_name) + + async with create_stream_rpc_client(client_config) as client: + pass +# await c.ping() +# print("Ping OK") +# await c.subscribe(TradesParams(market="BTC-USD"), on_trade) +# await c.subscribe(TradesParams(market="ETH-USD"), on_trade) +# subs = await c.list_subscriptions() +# print(f"Active subscriptions: {subs}") +# await asyncio.sleep(10) +# await c.unsubscribe(TradesParams(market="BTC-USD").topic_id) +# await c.unsubscribe(TradesParams(market="ETH-USD").topic_id) +# print("Unsubscribed from trades") + +# async def subscribe_to_orderbook(): +# async with stream_client.subscribe_to_orderbooks(MARKET_NAME) as orderbook_stream: +# while not stop_event.is_set(): +# try: +# msg = await asyncio.wait_for(orderbook_stream.recv(), timeout=1) +# LOGGER.info("Orderbook update %s#%s: %s", msg.type, msg.seq, msg.data.market) +# except asyncio.TimeoutError: +# pass +# +# async def subscribe_to_account(): +# async with stream_client.subscribe_to_account_updates(env_config.api_key) as account_stream: +# while not stop_event.is_set(): +# try: +# msg = await asyncio.wait_for(account_stream.recv(), timeout=1) +# if msg.type == "BALANCE": +# LOGGER.info( +# "Account balance update %s#%s: %s%s", +# msg.type, +# msg.seq, +# msg.data.balance.collateral_name, +# msg.data.balance.balance, +# ) +# else: +# LOGGER.info("Account update %s#%s", msg.type, msg.seq) +# except asyncio.TimeoutError: +# pass +# +# LOGGER.info("Press Ctrl+C to stop") +# +# await asyncio.gather(subscribe_to_orderbook(), subscribe_to_account()) + + +async def run_example(): + stop_event = asyncio.Event() + loop = asyncio.get_running_loop() + + def signal_handler(): + LOGGER.info("Signal received, stopping...") + stop_event.set() + + loop.add_signal_handler(SIGINT, signal_handler) + loop.add_signal_handler(SIGTERM, signal_handler) + + await subscribe_to_rpc_stream(stop_event) + + +if __name__ == "__main__": + run(main=run_example()) diff --git a/examples/utils.py b/examples/utils.py index 60cfabca..33925873 100644 --- a/examples/utils.py +++ b/examples/utils.py @@ -11,6 +11,7 @@ from x10.clients.blocking import BlockingTradingClient from x10.clients.rest import RestApiClient from x10.clients.stream import StreamClient +from x10.clients.streamrpc.streamrpc_client import StreamRPCClient from x10.config import get_config_by_name from x10.core.client_config import ClientConfig from x10.core.env_config import EnvConfig @@ -70,6 +71,9 @@ def create_blocking_client(config: ClientConfig | None = None): def create_stream_client(config: ClientConfig): return StreamClient(api_url=config.endpoints.stream_url) +def create_stream_rpc_client(config: ClientConfig): + return StreamRPCClient(api_url=config.endpoints.rpc_stream_url) + def get_adjust_price_by_pct(config: TradingConfigModel): def adjust_price_by_pct(price: Decimal, pct: Decimal | int): diff --git a/tests/clients/test_streamrpc_client.py b/tests/clients/test_streamrpc_client.py index e69de29b..bc7b86de 100644 --- a/tests/clients/test_streamrpc_client.py +++ b/tests/clients/test_streamrpc_client.py @@ -0,0 +1,2 @@ +def test_streamrpc_client(): + pass diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index 343881a0..8a37cde0 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -1,2 +1,42 @@ +from x10.utils.log import get_logger + +LOGGER = get_logger(__name__) + class StreamRPCClient: - pass + """ + X10 WebSocket RPC client. + + Implements the JSON-RPC 2.0 like protocol over a WebSocket connection. + Supports automatic reconnection and transparent re-subscription after connection loss. + + :param api_url: Full WebSocket URL + """ + + async def connect(self): + LOGGER.debug("Connecting to %s", self._api_url) + pass + + async def close(self): + pass + + async def ping(self): + pass + + async def list_subscriptions(self): + pass + + async def subscribe(self): + pass + + async def unsubscribe(self): + pass + + def __init__(self, api_url: str): + self._api_url = api_url + + async def __aenter__(self) -> "StreamRPCClient": + await self.connect() + return self + + async def __aexit__(self, exc_type, exc_value, traceback) -> None: + await self.close() diff --git a/x10/config.py b/x10/config.py index 9b1ced8c..4b4d531e 100644 --- a/x10/config.py +++ b/x10/config.py @@ -22,6 +22,7 @@ api_base_url="https://api.starknet.sepolia.extended.exchange/api/v1", api_base_order_management_url="https://api.starknet.sepolia.extended.exchange/api/v1", stream_url="wss://api.starknet.sepolia.extended.exchange/stream.extended.exchange/v1", + rpc_stream_url="wss://api.starknet.sepolia.extended.exchange/stream.extended.exchange/v2/rpc", onboarding_url="https://api.starknet.sepolia.extended.exchange", vault_asset_name="XVS", ), @@ -38,6 +39,7 @@ api_base_url="https://api.starknet.extended.exchange/api/v1", api_base_order_management_url="https://api.starknet.extended.exchange/api/v1", stream_url="wss://api.starknet.extended.exchange/stream.extended.exchange/v1", + rpc_stream_url="wss://api.starknet.extended.exchange/stream.extended.exchange/v2/rpc", onboarding_url="https://api.starknet.extended.exchange", vault_asset_name="XVS", ), diff --git a/x10/core/client_config.py b/x10/core/client_config.py index b0667882..83921ba3 100644 --- a/x10/core/client_config.py +++ b/x10/core/client_config.py @@ -30,6 +30,7 @@ class EndpointsConfig: api_base_url: str api_base_order_management_url: str stream_url: str + rpc_stream_url: str onboarding_url: str vault_asset_name: str From 17e61ae19ff2c167109982034a1389140f05df22 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Fri, 26 Jun 2026 11:06:11 +0400 Subject: [PATCH 03/41] Add stream RPC client --- examples/cases/stream/subscribe_to_rpc_stream.py | 2 ++ examples/utils.py | 3 ++- tests/clients/test_rest_api_client.py | 4 ++-- tests/fixtures/market.py | 4 ++-- x10/clients/streamrpc/streamrpc_client.py | 1 + x10/config.py | 4 ++-- x10/core/client_config.py | 2 +- 7 files changed, 12 insertions(+), 8 deletions(-) diff --git a/examples/cases/stream/subscribe_to_rpc_stream.py b/examples/cases/stream/subscribe_to_rpc_stream.py index fb333c79..c631be54 100644 --- a/examples/cases/stream/subscribe_to_rpc_stream.py +++ b/examples/cases/stream/subscribe_to_rpc_stream.py @@ -21,6 +21,8 @@ async def subscribe_to_rpc_stream(stop_event: asyncio.Event): async with create_stream_rpc_client(client_config) as client: pass + + # await c.ping() # print("Ping OK") # await c.subscribe(TradesParams(market="BTC-USD"), on_trade) diff --git a/examples/utils.py b/examples/utils.py index 33925873..e75a70d2 100644 --- a/examples/utils.py +++ b/examples/utils.py @@ -71,8 +71,9 @@ def create_blocking_client(config: ClientConfig | None = None): def create_stream_client(config: ClientConfig): return StreamClient(api_url=config.endpoints.stream_url) + def create_stream_rpc_client(config: ClientConfig): - return StreamRPCClient(api_url=config.endpoints.rpc_stream_url) + return StreamRPCClient(api_url=config.endpoints.stream_rpc_url) def get_adjust_price_by_pct(config: TradingConfigModel): diff --git a/tests/clients/test_rest_api_client.py b/tests/clients/test_rest_api_client.py index 1b10d4e0..376dc940 100644 --- a/tests/clients/test_rest_api_client.py +++ b/tests/clients/test_rest_api_client.py @@ -51,8 +51,8 @@ async def test_get_markets(aiohttp_server, create_btc_usd_market): "collateralAssetName": "USD", "collateralAssetPrecision": 6, "active": True, - "isRfq": True, - "isOffHours": True, + "isRfq": False, + "isOffHours": False, "marketStats": { "dailyVolume": "2410800.768021", "dailyVolumeBase": "37.94502", diff --git a/tests/fixtures/market.py b/tests/fixtures/market.py index 68dc5bb7..8d0eaa9a 100644 --- a/tests/fixtures/market.py +++ b/tests/fixtures/market.py @@ -15,8 +15,8 @@ def get_btc_usd_market_json_data(): "collateralAssetName": "USD", "collateralAssetPrecision": 6, "active": true, - "isRfq": true, - "isOffHours": true, + "isRfq": false, + "isOffHours": false, "marketStats": { "dailyVolume": "2410800.768021", "dailyVolumeBase": "37.94502", diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index 8a37cde0..6d902652 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -2,6 +2,7 @@ LOGGER = get_logger(__name__) + class StreamRPCClient: """ X10 WebSocket RPC client. diff --git a/x10/config.py b/x10/config.py index 4b4d531e..a39521af 100644 --- a/x10/config.py +++ b/x10/config.py @@ -22,7 +22,7 @@ api_base_url="https://api.starknet.sepolia.extended.exchange/api/v1", api_base_order_management_url="https://api.starknet.sepolia.extended.exchange/api/v1", stream_url="wss://api.starknet.sepolia.extended.exchange/stream.extended.exchange/v1", - rpc_stream_url="wss://api.starknet.sepolia.extended.exchange/stream.extended.exchange/v2/rpc", + stream_rpc_url="wss://api.starknet.sepolia.extended.exchange/stream.extended.exchange/v2/rpc", onboarding_url="https://api.starknet.sepolia.extended.exchange", vault_asset_name="XVS", ), @@ -39,7 +39,7 @@ api_base_url="https://api.starknet.extended.exchange/api/v1", api_base_order_management_url="https://api.starknet.extended.exchange/api/v1", stream_url="wss://api.starknet.extended.exchange/stream.extended.exchange/v1", - rpc_stream_url="wss://api.starknet.extended.exchange/stream.extended.exchange/v2/rpc", + stream_rpc_url="wss://api.starknet.extended.exchange/stream.extended.exchange/v2/rpc", onboarding_url="https://api.starknet.extended.exchange", vault_asset_name="XVS", ), diff --git a/x10/core/client_config.py b/x10/core/client_config.py index 83921ba3..316dfb0f 100644 --- a/x10/core/client_config.py +++ b/x10/core/client_config.py @@ -30,7 +30,7 @@ class EndpointsConfig: api_base_url: str api_base_order_management_url: str stream_url: str - rpc_stream_url: str + stream_rpc_url: str onboarding_url: str vault_asset_name: str From dc238e52b593f9241f483e7984e83443a9d7eed9 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Fri, 26 Jun 2026 11:59:19 +0400 Subject: [PATCH 04/41] Add stream RPC client --- .../cases/stream/subscribe_to_rpc_stream.py | 33 +--------- x10/clients/streamrpc/streamrpc_client.py | 44 ++++++++++++- x10/clients/streamrpc/subscription.py | 63 +++++++++++++++++++ x10/models/stream_rpc.py | 14 +++++ 4 files changed, 120 insertions(+), 34 deletions(-) create mode 100644 x10/clients/streamrpc/subscription.py create mode 100644 x10/models/stream_rpc.py diff --git a/examples/cases/stream/subscribe_to_rpc_stream.py b/examples/cases/stream/subscribe_to_rpc_stream.py index c631be54..defb7ad8 100644 --- a/examples/cases/stream/subscribe_to_rpc_stream.py +++ b/examples/cases/stream/subscribe_to_rpc_stream.py @@ -20,7 +20,7 @@ async def subscribe_to_rpc_stream(stop_event: asyncio.Event): client_config = get_config_by_name(env_config.client_config_name) async with create_stream_rpc_client(client_config) as client: - pass + await stop_event.wait() # await c.ping() @@ -34,37 +34,6 @@ async def subscribe_to_rpc_stream(stop_event: asyncio.Event): # await c.unsubscribe(TradesParams(market="ETH-USD").topic_id) # print("Unsubscribed from trades") -# async def subscribe_to_orderbook(): -# async with stream_client.subscribe_to_orderbooks(MARKET_NAME) as orderbook_stream: -# while not stop_event.is_set(): -# try: -# msg = await asyncio.wait_for(orderbook_stream.recv(), timeout=1) -# LOGGER.info("Orderbook update %s#%s: %s", msg.type, msg.seq, msg.data.market) -# except asyncio.TimeoutError: -# pass -# -# async def subscribe_to_account(): -# async with stream_client.subscribe_to_account_updates(env_config.api_key) as account_stream: -# while not stop_event.is_set(): -# try: -# msg = await asyncio.wait_for(account_stream.recv(), timeout=1) -# if msg.type == "BALANCE": -# LOGGER.info( -# "Account balance update %s#%s: %s%s", -# msg.type, -# msg.seq, -# msg.data.balance.collateral_name, -# msg.data.balance.balance, -# ) -# else: -# LOGGER.info("Account update %s#%s", msg.type, msg.seq) -# except asyncio.TimeoutError: -# pass -# -# LOGGER.info("Press Ctrl+C to stop") -# -# await asyncio.gather(subscribe_to_orderbook(), subscribe_to_account()) - async def run_example(): stop_event = asyncio.Event() diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index 6d902652..b4f4cc87 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -1,8 +1,18 @@ +import asyncio +from dataclasses import dataclass +from typing import Any, Callable, Coroutine + +import websockets + from x10.utils.log import get_logger LOGGER = get_logger(__name__) +OnReconnectCallback = Callable[[list[str]], Coroutine[Any, Any, None]] +OnSequenceBreakCallback = Callable[[str, int, int], Coroutine[Any, Any, None]] + + class StreamRPCClient: """ X10 WebSocket RPC client. @@ -10,7 +20,12 @@ class StreamRPCClient: Implements the JSON-RPC 2.0 like protocol over a WebSocket connection. Supports automatic reconnection and transparent re-subscription after connection loss. - :param api_url: Full WebSocket URL + :param api_url: Full WebSocket URL. + :param api_key: API key for private topics. + :param on_reconnect: Optional async callback invoked after a successful reconnection. + :param on_sequence_break: Optional callback invoked when a gap is detected in the + connection-level ``seq`` counter, indicating that one or more stream + messages were dropped. """ async def connect(self): @@ -32,8 +47,33 @@ async def subscribe(self): async def unsubscribe(self): pass - def __init__(self, api_url: str): + def __init__( + self, + *, + api_url: str, + api_key: str | None = None, + on_reconnect: OnReconnectCallback | None = None, + on_sequence_break: OnSequenceBreakCallback | None = None, + ): self._api_url = api_url + self._api_key = api_key + self._on_reconnect = on_reconnect + self._on_sequence_break = on_sequence_break + + self._ws: websockets.WebSocketClientProtocol | None = None + # FIXME: Rename? + self._run_task: asyncio.Task[None] | None = None + # FIXME: Replace with state? + self._is_stopped = False + + # Fires when a connection is fully established (and resubscription done). + self._ready = asyncio.Event() + + # Pending RPC request futures keyed by request id. + self._pending: dict[str, asyncio.Future[dict[str, Any]]] = {} + + # Active subscriptions keyed by topic_id. + self._subscriptions: dict[str, _Subscription] = {} async def __aenter__(self) -> "StreamRPCClient": await self.connect() diff --git a/x10/clients/streamrpc/subscription.py b/x10/clients/streamrpc/subscription.py new file mode 100644 index 00000000..02466425 --- /dev/null +++ b/x10/clients/streamrpc/subscription.py @@ -0,0 +1,63 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, Callable, Coroutine, Generic, TypeVar + +from x10.models.stream_rpc import StreamMessageEnvelope +from x10.models.trade import PublicTradeModel + +T = TypeVar("T") +StreamMessageHandler = Callable[[StreamMessageEnvelope[Any]], Coroutine[Any, Any, None] | None] + + +class SubscribeParams(ABC, Generic[T]): + """ + Base class for all subscription parameter types. + """ + + @property + @abstractmethod + def topic_id(self) -> str: + """ + The unique topic identifier (e.g. ``trades.BTC-USD``). + """ + + @abstractmethod + def to_dict(self) -> dict[str, Any]: + """ + Serialize to the JSON structure expected by the RPC ``subscribe`` call. + """ + + @abstractmethod + def deserialize(self, data: dict[str, Any], msg_type: str) -> T: + """ + Convert a raw JSON payload into the typed domain model ``T``. + + :param data: The raw dict from the ``data`` field of the envelope. + :param msg_type: The ``type`` field of the envelope, used by multi-type subscriptions + (e.g. ``account``) to select the correct model. + """ + + +class TradesParams(SubscribeParams[PublicTradeModel]): + """ + Subscribe to public trade events for a market (or all markets). + """ + + def __init__(self, market: str | None = None) -> None: + self.market = market + + @property + def topic_id(self) -> str: + return f"trades.{self.market or 'all'}" + + def to_dict(self) -> dict[str, Any]: + return {"scope": "trades", "selector": {"market": self.market}} + + def deserialize(self, data: dict[str, Any], msg_type: str) -> PublicTradeModel: + return PublicTradeModel.model_validate(data) + + +@dataclass +class TopicSubscription: + params: SubscribeParams[Any] + handler: StreamMessageHandler diff --git a/x10/models/stream_rpc.py b/x10/models/stream_rpc.py new file mode 100644 index 00000000..5997642c --- /dev/null +++ b/x10/models/stream_rpc.py @@ -0,0 +1,14 @@ +from dataclasses import dataclass +from typing import Generic, TypeVar + +T = TypeVar("T") + + +@dataclass(frozen=True) +class StreamMessageEnvelope(Generic[T]): + type: str + data: T + ts: int + seq: int + subscription: str + error: str | None = None From ca9361da0e6b19373901daf04134f70cf180af69 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Fri, 26 Jun 2026 12:40:39 +0400 Subject: [PATCH 05/41] Add stream RPC client --- examples/cases/stream/subscribe_to_rpc_stream.py | 10 +++++++--- x10/clients/streamrpc/streamrpc_client.py | 4 ++-- x10/clients/streamrpc/subscription.py | 8 ++++---- 3 files changed, 13 insertions(+), 9 deletions(-) diff --git a/examples/cases/stream/subscribe_to_rpc_stream.py b/examples/cases/stream/subscribe_to_rpc_stream.py index defb7ad8..9249f726 100644 --- a/examples/cases/stream/subscribe_to_rpc_stream.py +++ b/examples/cases/stream/subscribe_to_rpc_stream.py @@ -4,15 +4,18 @@ from signal import SIGINT, SIGTERM from examples.utils import BTC_USD_MARKET, create_stream_rpc_client, init_env +from x10.clients.streamrpc.subscription import TradesParams from x10.config import get_config_by_name +from x10.models.stream_rpc import StreamMessageEnvelope +from x10.models.trade import PublicTradeModel LOGGER = logging.getLogger() MARKET_NAME = BTC_USD_MARKET -# def on_trade(env: StreamEnvelope[PublicTrade]) -> None: -# for t in env.data: -# print(f"[trade] {t.market} {t.side.value} {t.qty} @ {t.price} (seq={env.seq})") +def on_trade(env: StreamMessageEnvelope[list[PublicTradeModel]]) -> None: + for t in env.data: + print(f"[trade] {t.market} {t.side.value} {t.qty} @ {t.price} (seq={env.seq})") async def subscribe_to_rpc_stream(stop_event: asyncio.Event): @@ -20,6 +23,7 @@ async def subscribe_to_rpc_stream(stop_event: asyncio.Event): client_config = get_config_by_name(env_config.client_config_name) async with create_stream_rpc_client(client_config) as client: + await client.subscribe(TradesParams(market="BTC-USD"), on_trade) await stop_event.wait() diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index b4f4cc87..f343e7ab 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -1,9 +1,9 @@ import asyncio -from dataclasses import dataclass from typing import Any, Callable, Coroutine import websockets +from x10.clients.streamrpc.subscription import TopicSubscription from x10.utils.log import get_logger LOGGER = get_logger(__name__) @@ -73,7 +73,7 @@ def __init__( self._pending: dict[str, asyncio.Future[dict[str, Any]]] = {} # Active subscriptions keyed by topic_id. - self._subscriptions: dict[str, _Subscription] = {} + self._subscriptions: dict[str, TopicSubscription] = {} async def __aenter__(self) -> "StreamRPCClient": await self.connect() diff --git a/x10/clients/streamrpc/subscription.py b/x10/clients/streamrpc/subscription.py index 02466425..19e97a20 100644 --- a/x10/clients/streamrpc/subscription.py +++ b/x10/clients/streamrpc/subscription.py @@ -28,7 +28,7 @@ def to_dict(self) -> dict[str, Any]: """ @abstractmethod - def deserialize(self, data: dict[str, Any], msg_type: str) -> T: + def deserialize_data(self, data: Any, msg_type: str) -> T: """ Convert a raw JSON payload into the typed domain model ``T``. @@ -38,7 +38,7 @@ def deserialize(self, data: dict[str, Any], msg_type: str) -> T: """ -class TradesParams(SubscribeParams[PublicTradeModel]): +class TradesParams(SubscribeParams[list[PublicTradeModel]]): """ Subscribe to public trade events for a market (or all markets). """ @@ -53,8 +53,8 @@ def topic_id(self) -> str: def to_dict(self) -> dict[str, Any]: return {"scope": "trades", "selector": {"market": self.market}} - def deserialize(self, data: dict[str, Any], msg_type: str) -> PublicTradeModel: - return PublicTradeModel.model_validate(data) + def deserialize_data(self, data: list[dict[str, Any]], msg_type: str) -> list[PublicTradeModel]: + return [PublicTradeModel.model_validate(item) for item in data] @dataclass From f0b6c8b1687d16f6e233069f54e80a9e17397f25 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Fri, 26 Jun 2026 12:49:49 +0400 Subject: [PATCH 06/41] Add stream RPC client --- x10/clients/streamrpc/streamrpc_client.py | 30 +++++++++++++++++------ 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index f343e7ab..02c250ef 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -1,14 +1,19 @@ import asyncio -from typing import Any, Callable, Coroutine +from typing import Any, Callable, Coroutine, TypeVar import websockets -from x10.clients.streamrpc.subscription import TopicSubscription +from x10.clients.streamrpc.subscription import ( + StreamMessageHandler, + SubscribeParams, + TopicSubscription, +) from x10.utils.log import get_logger LOGGER = get_logger(__name__) +T = TypeVar("T") OnReconnectCallback = Callable[[list[str]], Coroutine[Any, Any, None]] OnSequenceBreakCallback = Callable[[str, int, int], Coroutine[Any, Any, None]] @@ -30,22 +35,31 @@ class StreamRPCClient: async def connect(self): LOGGER.debug("Connecting to %s", self._api_url) - pass async def close(self): - pass + raise NotImplementedError async def ping(self): - pass + raise NotImplementedError async def list_subscriptions(self): - pass + raise NotImplementedError - async def subscribe(self): + async def subscribe(self, *, params: SubscribeParams[T], handler: StreamMessageHandler): + """ + Subscribe to a topic and register a handler for incoming messages. + + If a subscription with the same topic_id already exists it is replaced + (the server cancels the previous one automatically). + + :param params: Subscription parameters. + :param handler: Callable invoked for each message. May be sync or async. + :returns: The ``topic_id`` string. + """ pass async def unsubscribe(self): - pass + raise NotImplementedError def __init__( self, From 1aef5a4346ad4d583722ff29484affca32abcf06 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Fri, 26 Jun 2026 12:58:55 +0400 Subject: [PATCH 07/41] Add stream RPC client --- x10/clients/streamrpc/streamrpc_client.py | 14 +++++++++++++- x10/models/stream_rpc.py | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index 02c250ef..a4e791b0 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -56,7 +56,16 @@ async def subscribe(self, *, params: SubscribeParams[T], handler: StreamMessageH :param handler: Callable invoked for each message. May be sync or async. :returns: The ``topic_id`` string. """ - pass + + await self._ready.wait() + + result = await self._rpc("subscribe", params=params.to_dict()) + topic_id: str = result["subscription"] + self._subscriptions[topic_id] = TopicSubscription(params=params, handler=handler) + + LOGGER.debug("Subscribed to %s", topic_id) + + return topic_id async def unsubscribe(self): raise NotImplementedError @@ -95,3 +104,6 @@ async def __aenter__(self) -> "StreamRPCClient": async def __aexit__(self, exc_type, exc_value, traceback) -> None: await self.close() + + async def _rpc(self, method: str, **kwargs: Any) -> dict[str, Any]: + raise NotImplementedError diff --git a/x10/models/stream_rpc.py b/x10/models/stream_rpc.py index 5997642c..08077f88 100644 --- a/x10/models/stream_rpc.py +++ b/x10/models/stream_rpc.py @@ -4,6 +4,7 @@ T = TypeVar("T") +# FIXME: Not a model? @dataclass(frozen=True) class StreamMessageEnvelope(Generic[T]): type: str From 3f2f45664eae4313b45ef1590c75dacef54fdbce Mon Sep 17 00:00:00 2001 From: alex101xela Date: Fri, 26 Jun 2026 13:24:05 +0400 Subject: [PATCH 08/41] Add stream RPC client --- examples/utils.py | 4 +-- x10/clients/streamrpc/streamrpc_client.py | 30 ++++++++++++++++++----- x10/clients/streamrpc/subscription.py | 7 +++--- x10/errors.py | 4 +++ 4 files changed, 34 insertions(+), 11 deletions(-) diff --git a/examples/utils.py b/examples/utils.py index e75a70d2..e4cf8276 100644 --- a/examples/utils.py +++ b/examples/utils.py @@ -11,7 +11,7 @@ from x10.clients.blocking import BlockingTradingClient from x10.clients.rest import RestApiClient from x10.clients.stream import StreamClient -from x10.clients.streamrpc.streamrpc_client import StreamRPCClient +from x10.clients.streamrpc.streamrpc_client import StreamRpcClient from x10.config import get_config_by_name from x10.core.client_config import ClientConfig from x10.core.env_config import EnvConfig @@ -73,7 +73,7 @@ def create_stream_client(config: ClientConfig): def create_stream_rpc_client(config: ClientConfig): - return StreamRPCClient(api_url=config.endpoints.stream_rpc_url) + return StreamRpcClient(api_url=config.endpoints.stream_rpc_url) def get_adjust_price_by_pct(config: TradingConfigModel): diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index a4e791b0..ae5e56d3 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -2,10 +2,12 @@ from typing import Any, Callable, Coroutine, TypeVar import websockets +from errors import StreamRpcError from x10.clients.streamrpc.subscription import ( StreamMessageHandler, SubscribeParams, + TopicId, TopicSubscription, ) from x10.utils.log import get_logger @@ -18,7 +20,7 @@ OnSequenceBreakCallback = Callable[[str, int, int], Coroutine[Any, Any, None]] -class StreamRPCClient: +class StreamRpcClient: """ X10 WebSocket RPC client. @@ -60,15 +62,31 @@ async def subscribe(self, *, params: SubscribeParams[T], handler: StreamMessageH await self._ready.wait() result = await self._rpc("subscribe", params=params.to_dict()) - topic_id: str = result["subscription"] + topic_id: TopicId = result["subscription"] self._subscriptions[topic_id] = TopicSubscription(params=params, handler=handler) LOGGER.debug("Subscribed to %s", topic_id) return topic_id - async def unsubscribe(self): - raise NotImplementedError + async def unsubscribe(self, topic_id: TopicId): + """ + Cancel an active subscription. + + :param topic_id: The string returned by :meth:`subscribe`. + :raises StreamRpcError: If no subscription with this ``topic_id`` exists. + """ + + subscription = self._subscriptions.get(topic_id) + + if subscription is None: + raise StreamRpcError(f"No active subscription: {topic_id}") + + await self._ready.wait() + await self._rpc("unsubscribe", params=subscription.params.to_dict()) + self._subscriptions.pop(topic_id, None) + + LOGGER.debug("Unsubscribed from %s", topic_id) def __init__( self, @@ -96,9 +114,9 @@ def __init__( self._pending: dict[str, asyncio.Future[dict[str, Any]]] = {} # Active subscriptions keyed by topic_id. - self._subscriptions: dict[str, TopicSubscription] = {} + self._subscriptions: dict[TopicId, TopicSubscription] = {} - async def __aenter__(self) -> "StreamRPCClient": + async def __aenter__(self) -> "StreamRpcClient": await self.connect() return self diff --git a/x10/clients/streamrpc/subscription.py b/x10/clients/streamrpc/subscription.py index 19e97a20..8deb925b 100644 --- a/x10/clients/streamrpc/subscription.py +++ b/x10/clients/streamrpc/subscription.py @@ -1,11 +1,12 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, Callable, Coroutine, Generic, TypeVar +from typing import Any, Callable, Coroutine, Generic, TypeAlias, TypeVar from x10.models.stream_rpc import StreamMessageEnvelope from x10.models.trade import PublicTradeModel T = TypeVar("T") +TopicId: TypeAlias = str StreamMessageHandler = Callable[[StreamMessageEnvelope[Any]], Coroutine[Any, Any, None] | None] @@ -16,7 +17,7 @@ class SubscribeParams(ABC, Generic[T]): @property @abstractmethod - def topic_id(self) -> str: + def topic_id(self) -> TopicId: """ The unique topic identifier (e.g. ``trades.BTC-USD``). """ @@ -47,7 +48,7 @@ def __init__(self, market: str | None = None) -> None: self.market = market @property - def topic_id(self) -> str: + def topic_id(self) -> TopicId: return f"trades.{self.market or 'all'}" def to_dict(self) -> dict[str, Any]: diff --git a/x10/errors.py b/x10/errors.py index 194c9113..048aa595 100644 --- a/x10/errors.py +++ b/x10/errors.py @@ -10,6 +10,10 @@ class NotSupportedError(SdkError, NotImplementedError): pass +class StreamRpcError(SdkError): + pass + + class ApiError(SdkError): pass From 3b9ec8c2f277629378ebb89a91a8ba44d9f376dc Mon Sep 17 00:00:00 2001 From: alex101xela Date: Fri, 26 Jun 2026 13:48:20 +0400 Subject: [PATCH 09/41] Add stream RPC client --- x10/clients/streamrpc/streamrpc_client.py | 26 ++++++++++++++--- x10/errors.py | 34 +++++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index ae5e56d3..0890b11f 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -1,5 +1,5 @@ import asyncio -from typing import Any, Callable, Coroutine, TypeVar +from typing import Any, Callable, Coroutine, TypeAlias, TypeVar import websockets from errors import StreamRpcError @@ -16,6 +16,7 @@ T = TypeVar("T") +RequestId: TypeAlias = str OnReconnectCallback = Callable[[list[str]], Coroutine[Any, Any, None]] OnSequenceBreakCallback = Callable[[str, int, int], Coroutine[Any, Any, None]] @@ -102,17 +103,26 @@ def __init__( self._on_sequence_break = on_sequence_break self._ws: websockets.WebSocketClientProtocol | None = None - # FIXME: Rename? - self._run_task: asyncio.Task[None] | None = None # FIXME: Replace with state? self._is_stopped = False + self._next_request_id = 0 + # FIXME: Update description + # Last observed connection-level sequence number; None until the first + # message arrives on a connection (also reset to None on each reconnect). + self._last_seq: int | None = None + # FIXME: Update description # Fires when a connection is fully established (and resubscription done). self._ready = asyncio.Event() + # FIXME: Rename? + self._run_task: asyncio.Task[None] | None = None + + # FIXME: Update description # Pending RPC request futures keyed by request id. - self._pending: dict[str, asyncio.Future[dict[str, Any]]] = {} + self._pending: dict[RequestId, asyncio.Future[dict[str, Any]]] = {} + # FIXME: Update description # Active subscriptions keyed by topic_id. self._subscriptions: dict[TopicId, TopicSubscription] = {} @@ -123,5 +133,13 @@ async def __aenter__(self) -> "StreamRpcClient": async def __aexit__(self, exc_type, exc_value, traceback) -> None: await self.close() + def _get_next_request_id(self) -> RequestId: + self._next_request_id += 1 + return RequestId(self._next_request_id) + async def _rpc(self, method: str, **kwargs: Any) -> dict[str, Any]: + """ + Send an RPC request and wait for its response. + """ + raise NotImplementedError diff --git a/x10/errors.py b/x10/errors.py index 048aa595..5823ff25 100644 --- a/x10/errors.py +++ b/x10/errors.py @@ -14,6 +14,40 @@ class StreamRpcError(SdkError): pass +class StreamRpcServerError(StreamRpcError): + PARSE_ERROR = -32700 + INVALID_REQUEST = -32600 + METHOD_NOT_FOUND = -32601 + INVALID_PARAMS = -32602 + INTERNAL_ERROR = -32603 + UNAUTHORIZED = -32001 + + def __init__(self, code: int, message: str, data: object = None) -> None: + super().__init__(f"[{code}] {message}") + + self.code = code + self.message = message + self.data = data + + +class StreamRpcConnectionError(StreamRpcError): + """ + WebSocket connection is unavailable. + """ + + +class StreamRpcTimeoutError(StreamRpcError): + """ + RPC request times out waiting for a response. + """ + + +class StreamRpcParseError(StreamRpcError): + """ + Incoming message cannot be parsed as JSON. + """ + + class ApiError(SdkError): pass From a194bedaab38e91d00527da3dcfb6db2669d2e18 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Fri, 26 Jun 2026 15:01:29 +0400 Subject: [PATCH 10/41] Add stream RPC client --- x10/clients/streamrpc/streamrpc_client.py | 33 ++++++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index 0890b11f..ab432dc0 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -1,8 +1,9 @@ import asyncio +import json from typing import Any, Callable, Coroutine, TypeAlias, TypeVar import websockets -from errors import StreamRpcError +from errors import StreamRpcConnectionError, StreamRpcError, StreamRpcTimeoutError from x10.clients.streamrpc.subscription import ( StreamMessageHandler, @@ -13,7 +14,7 @@ from x10.utils.log import get_logger LOGGER = get_logger(__name__) - +DEFAULT_REQUEST_TIMEOUT_SECONDS = 10 T = TypeVar("T") RequestId: TypeAlias = str @@ -103,6 +104,7 @@ def __init__( self._on_sequence_break = on_sequence_break self._ws: websockets.WebSocketClientProtocol | None = None + self._request_timeout = DEFAULT_REQUEST_TIMEOUT_SECONDS # FIXME: Replace with state? self._is_stopped = False self._next_request_id = 0 @@ -120,7 +122,7 @@ def __init__( # FIXME: Update description # Pending RPC request futures keyed by request id. - self._pending: dict[RequestId, asyncio.Future[dict[str, Any]]] = {} + self._pending_requests: dict[RequestId, asyncio.Future[dict[str, Any]]] = {} # FIXME: Update description # Active subscriptions keyed by topic_id. @@ -142,4 +144,27 @@ async def _rpc(self, method: str, **kwargs: Any) -> dict[str, Any]: Send an RPC request and wait for its response. """ - raise NotImplementedError + if self._ws is None: + raise StreamRpcConnectionError("WebSocket connection is not open") + + request_id = self._get_next_request_id() + request: dict[str, Any] = {"method": method, "id": request_id, "jsonrpc": "2.0"} + + if kwargs: + request.update(kwargs) + + loop = asyncio.get_running_loop() + request_result: asyncio.Future[dict[str, Any]] = loop.create_future() + self._pending_requests[request_id] = request_result + + try: + await self._ws.send(json.dumps(request)) + # Shield the future so that cancelling the outer `wait_for` does not + # cancel the future itself (it is cleaned up in the `finally` block). + return await asyncio.wait_for(asyncio.shield(request_result), timeout=self._request_timeout) + except asyncio.TimeoutError as exc: + raise StreamRpcTimeoutError( + f"RPC request timed out: {method} (id={request_id}) after {self._request_timeout}s" + ) from exc + finally: + self._pending_requests.pop(request_id, None) From 0a06d953b68bcb7e4c0d7114820ef5c6fe16337f Mon Sep 17 00:00:00 2001 From: alex101xela Date: Fri, 26 Jun 2026 15:53:49 +0400 Subject: [PATCH 11/41] Add stream RPC client --- .../cases/stream/subscribe_to_rpc_stream.py | 2 +- x10/clients/streamrpc/streamrpc_client.py | 32 ++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/examples/cases/stream/subscribe_to_rpc_stream.py b/examples/cases/stream/subscribe_to_rpc_stream.py index 9249f726..9f0de777 100644 --- a/examples/cases/stream/subscribe_to_rpc_stream.py +++ b/examples/cases/stream/subscribe_to_rpc_stream.py @@ -23,7 +23,7 @@ async def subscribe_to_rpc_stream(stop_event: asyncio.Event): client_config = get_config_by_name(env_config.client_config_name) async with create_stream_rpc_client(client_config) as client: - await client.subscribe(TradesParams(market="BTC-USD"), on_trade) + await client.subscribe(params=TradesParams(market="BTC-USD"), handler=on_trade) await stop_event.wait() diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index ab432dc0..1aa9359b 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -15,6 +15,7 @@ LOGGER = get_logger(__name__) DEFAULT_REQUEST_TIMEOUT_SECONDS = 10 +CONNECTION_LOOP_TASK_NAME = "x10-rpc-connection-loop" T = TypeVar("T") RequestId: TypeAlias = str @@ -38,8 +39,34 @@ class StreamRpcClient: """ async def connect(self): + """ + Starts the client's connection management loop and waits for the first connection to be established. + :raises StreamRpcConnectionError: If the initial connection fails (reconnect is not attempted). + """ + + if self._connection_loop_task is not None: + LOGGER.debug("Connection loop already running") + return + LOGGER.debug("Connecting to %s", self._api_url) + loop = asyncio.get_running_loop() + + self._is_stopped = False + self._connection_loop_task = loop.create_task(self._run_connection_loop(), name=CONNECTION_LOOP_TASK_NAME) + + try: + await asyncio.wait_for(self._ready.wait(), timeout=self._request_timeout) + except asyncio.TimeoutError as exc: + self._is_stopped = True + + if self._connection_loop_task: + self._connection_loop_task.cancel() + + raise StreamRpcConnectionError( + f"Connection to {self._api_url} timed out after {self._request_timeout}s" + ) from exc + async def close(self): raise NotImplementedError @@ -118,7 +145,7 @@ def __init__( self._ready = asyncio.Event() # FIXME: Rename? - self._run_task: asyncio.Task[None] | None = None + self._connection_loop_task: asyncio.Task[None] | None = None # FIXME: Update description # Pending RPC request futures keyed by request id. @@ -168,3 +195,6 @@ async def _rpc(self, method: str, **kwargs: Any) -> dict[str, Any]: ) from exc finally: self._pending_requests.pop(request_id, None) + + async def _run_connection_loop(self): + pass From 9e2d539e0a5fac2e704e4d6ccbe43e72fcf6cd39 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Fri, 26 Jun 2026 16:28:01 +0400 Subject: [PATCH 12/41] Add stream RPC client --- x10/clients/streamrpc/streamrpc_client.py | 104 +++++++++++++++++++++- 1 file changed, 101 insertions(+), 3 deletions(-) diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index 1aa9359b..1dadd1b1 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -1,9 +1,10 @@ import asyncio import json +import random from typing import Any, Callable, Coroutine, TypeAlias, TypeVar import websockets -from errors import StreamRpcConnectionError, StreamRpcError, StreamRpcTimeoutError +from websockets import ConnectionClosed from x10.clients.streamrpc.subscription import ( StreamMessageHandler, @@ -11,10 +12,11 @@ TopicId, TopicSubscription, ) +from x10.errors import StreamRpcConnectionError, StreamRpcError, StreamRpcTimeoutError +from x10.utils.http import USER_AGENT, RequestHeader from x10.utils.log import get_logger LOGGER = get_logger(__name__) -DEFAULT_REQUEST_TIMEOUT_SECONDS = 10 CONNECTION_LOOP_TASK_NAME = "x10-rpc-connection-loop" T = TypeVar("T") @@ -131,7 +133,9 @@ def __init__( self._on_sequence_break = on_sequence_break self._ws: websockets.WebSocketClientProtocol | None = None - self._request_timeout = DEFAULT_REQUEST_TIMEOUT_SECONDS + self._request_timeout = 10 + self._reconnect_initial_delay = 1 + self._reconnect_max_delay = 10 # FIXME: Replace with state? self._is_stopped = False self._next_request_id = 0 @@ -196,5 +200,99 @@ async def _rpc(self, method: str, **kwargs: Any) -> dict[str, Any]: finally: self._pending_requests.pop(request_id, None) + def _fail_pending(self, exc: Exception) -> None: + raise NotImplementedError + + # FIXME: Create a class for connection loop? async def _run_connection_loop(self): + """ + Background task that maintains the connection (including reconnections) + and dispatches incoming messages. + """ + + reconnect_delay = self._reconnect_initial_delay + is_first_connection_attempt = True + + extra_headers: dict[str, str] = { + RequestHeader.USER_AGENT: USER_AGENT, + } + + if self._api_key is not None: + extra_headers[RequestHeader.API_KEY] = self._api_key + + async def handle_lost_connection(exc: Exception) -> bool: + nonlocal reconnect_delay + + self._ws = None + self._ready.clear() + self._fail_pending(StreamRpcConnectionError(f"Connection lost: {exc}")) + + LOGGER.warning("Connection lost: %s", exc) + + if self._is_stopped: + return False + + jitter = random.uniform(0.0, 1.0) + reconnect_after = min(reconnect_delay + jitter, self._reconnect_max_delay) + + LOGGER.debug("Reconnecting in %.1fs…", reconnect_after) + + await asyncio.sleep(reconnect_after) + reconnect_delay = min(reconnect_delay * 1.5, self._reconnect_max_delay) + + return True + + while not self._is_stopped: + try: + self._ws = await websockets.connect(self._api_url, extra_headers=extra_headers) + + LOGGER.debug("Connected to %s", self._api_url) + + # `seq` restarts at 0 on each new connection + self._last_seq = None + reconnect_delay = self._reconnect_initial_delay + + # await self._resubscribe() + # self._ready.set() + # + # if not first_attempt and self._on_reconnect: + # await self._on_reconnect(list(self._subscriptions)) + # + # first_attempt = False + # + # async for raw in ws: + # if isinstance(raw, str): + # self._dispatch_raw(raw) + except asyncio.CancelledError: + break + except (ConnectionClosed, OSError, asyncio.TimeoutError) as exc: + should_break = not await handle_lost_connection(exc) + + if should_break: + break + except Exception as exc: + LOGGER.exception("Unexpected error in connection loop: %s", exc) + + self._ws = None + self._ready.clear() + self._fail_pending(StreamRpcConnectionError(str(exc))) + + if self._is_stopped: + break + + await asyncio.sleep(self._reconnect_initial_delay) + + # self._ws = None + # self._ready.clear() + # self._fail_pending(RpcConnectionError("Client stopped")) + # logger.info("Connection loop exited") + + async def _resubscribe(self) -> None: + raise NotImplementedError + + # FIXME: Create a dispatcher class? + def _dispatch_raw(self, raw: str) -> None: + pass + + async def _dispatch_envelope(self, msg: dict[str, Any], sub_id: str) -> None: pass From d120ff3b79f4224f652d7fb2158036c8da522a10 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 12:27:14 +0400 Subject: [PATCH 13/41] Add stream RPC client --- .../cases/stream/subscribe_to_rpc_stream.py | 4 +- x10/clients/streamrpc/streamrpc_client.py | 169 ++++++++++++++---- 2 files changed, 140 insertions(+), 33 deletions(-) diff --git a/examples/cases/stream/subscribe_to_rpc_stream.py b/examples/cases/stream/subscribe_to_rpc_stream.py index 9f0de777..734df214 100644 --- a/examples/cases/stream/subscribe_to_rpc_stream.py +++ b/examples/cases/stream/subscribe_to_rpc_stream.py @@ -24,9 +24,11 @@ async def subscribe_to_rpc_stream(stop_event: asyncio.Event): async with create_stream_rpc_client(client_config) as client: await client.subscribe(params=TradesParams(market="BTC-USD"), handler=on_trade) + await client.subscribe(params=TradesParams(market="ETH-USD"), handler=on_trade) + await asyncio.sleep(5) + await client.unsubscribe(TradesParams(market="ETH-USD").topic_id) await stop_event.wait() - # await c.ping() # print("Ping OK") # await c.subscribe(TradesParams(market="BTC-USD"), on_trade) diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index 1dadd1b1..e076284f 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -12,7 +12,12 @@ TopicId, TopicSubscription, ) -from x10.errors import StreamRpcConnectionError, StreamRpcError, StreamRpcTimeoutError +from x10.errors import ( + StreamRpcConnectionError, + StreamRpcError, + StreamRpcServerError, + StreamRpcTimeoutError, +) from x10.utils.http import USER_AGENT, RequestHeader from x10.utils.log import get_logger @@ -70,13 +75,43 @@ async def connect(self): ) from exc async def close(self): - raise NotImplementedError + """ + Stops the client and close the WebSocket connection. + """ + + self._is_stopped = True + self._ready.clear() + self._fail_pending(StreamRpcConnectionError("Client disconnected")) + + if self._ws is not None: + await self._ws.close() + self._ws = None + + if self._connection_loop_task is not None: + self._connection_loop_task.cancel() + + try: + await self._connection_loop_task + except (asyncio.CancelledError, Exception): + pass + + self._connection_loop_task = None async def ping(self): - raise NotImplementedError + """ + Sends a ping and wait for the server's acknowledgement. + """ + + await self._rpc("ping") async def list_subscriptions(self): - raise NotImplementedError + """ + Return the list of active subscription IDs as reported by the server. + """ + + result = await self._rpc("list-subscriptions") + # FIXME: Simplify? + return result.get("subscriptions") or [] async def subscribe(self, *, params: SubscribeParams[T], handler: StreamMessageHandler): """ @@ -201,7 +236,15 @@ async def _rpc(self, method: str, **kwargs: Any) -> dict[str, Any]: self._pending_requests.pop(request_id, None) def _fail_pending(self, exc: Exception) -> None: - raise NotImplementedError + """ + Resolve all pending RPC futures with an exception. + """ + + for request_result in list(self._pending_requests.values()): + if not request_result.done(): + request_result.set_exception(exc) + + self._pending_requests.clear() # FIXME: Create a class for connection loop? async def _run_connection_loop(self): @@ -244,25 +287,26 @@ async def handle_lost_connection(exc: Exception) -> bool: while not self._is_stopped: try: - self._ws = await websockets.connect(self._api_url, extra_headers=extra_headers) - - LOGGER.debug("Connected to %s", self._api_url) - - # `seq` restarts at 0 on each new connection - self._last_seq = None - reconnect_delay = self._reconnect_initial_delay - - # await self._resubscribe() - # self._ready.set() - # - # if not first_attempt and self._on_reconnect: - # await self._on_reconnect(list(self._subscriptions)) - # - # first_attempt = False - # - # async for raw in ws: - # if isinstance(raw, str): - # self._dispatch_raw(raw) + async with websockets.connect(self._api_url, extra_headers=extra_headers) as ws: + self._ws = ws + + LOGGER.debug("Connected to %s", self._api_url) + + # `seq` restarts at 0 on each new connection + self._last_seq = None + reconnect_delay = self._reconnect_initial_delay + + await self._resubscribe() + self._ready.set() + + if not is_first_connection_attempt and self._on_reconnect: + await self._on_reconnect(list(self._subscriptions)) + + is_first_connection_attempt = False + + async for raw in ws: + if isinstance(raw, str): + self._dispatch_raw(raw) except asyncio.CancelledError: break except (ConnectionClosed, OSError, asyncio.TimeoutError) as exc: @@ -282,17 +326,78 @@ async def handle_lost_connection(exc: Exception) -> bool: await asyncio.sleep(self._reconnect_initial_delay) - # self._ws = None - # self._ready.clear() - # self._fail_pending(RpcConnectionError("Client stopped")) - # logger.info("Connection loop exited") + self._ws = None + self._ready.clear() + self._fail_pending(StreamRpcConnectionError("Client stopped")) + + LOGGER.debug("Connection loop exited") async def _resubscribe(self) -> None: - raise NotImplementedError + """ + Replay all active subscriptions after a reconnection. + """ + + if self._ws is None or not self._subscriptions: + return + + LOGGER.debug("Resubscribing to %d topic(s)…", len(self._subscriptions)) + + for topic_id, subscription in list(self._subscriptions.items()): + request_id = self._get_next_request_id() + request = { + "method": "subscribe", + "id": request_id, + "jsonrpc": "2.0", + "params": subscription.params.to_dict(), + } + try: + await self._ws.send(json.dumps(request)) + except Exception: + LOGGER.exception("Failed to resubscribe to %s", topic_id) # FIXME: Create a dispatcher class? def _dispatch_raw(self, raw: str) -> None: - pass + """ + Parse a raw WebSocket text frame and route it to the right handler. + """ + + try: + msg: dict[str, Any] = json.loads(raw) + except json.JSONDecodeError: + LOGGER.warning("Received invalid JSON (%.120s…)", raw) + return + + # (1) JSON-RPC response + request_id: RequestId | None = msg.get("id") + + if request_id is not None: + request_result = self._pending_requests.get(str(request_id)) + + if not request_result: + LOGGER.warning("Received response for unknown request id=%s", request_id) + return + + err = msg.get("error") + + if err: + request_result.set_exception( + StreamRpcServerError(code=err["code"], message=err["message"], data=err.get("data")) + ) + else: + request_result.set_result(msg["result"]) + + return + + # (2) Stream data + subscription_id: str | None = msg.get("subscription") + + if subscription_id is not None: + asyncio.ensure_future(self._dispatch_envelope(msg, subscription_id)) + return + + # (3) Unknown message + LOGGER.error("Unrecognised message shape: %s", raw) - async def _dispatch_envelope(self, msg: dict[str, Any], sub_id: str) -> None: - pass + async def _dispatch_envelope(self, msg: dict[str, Any], subscription_id: str) -> None: + print(subscription_id) + print(msg) From 057a82bcad2e63ca579e155766c3e3328341f72d Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 12:41:15 +0400 Subject: [PATCH 14/41] Add stream RPC client --- x10/clients/streamrpc/streamrpc_client.py | 70 +++++++++++++++++++++-- x10/models/stream_rpc.py | 2 +- 2 files changed, 67 insertions(+), 5 deletions(-) diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index e076284f..b16aa8f2 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -6,6 +6,7 @@ import websockets from websockets import ConnectionClosed +from x10.models.stream_rpc import StreamMessageEnvelope from x10.clients.streamrpc.subscription import ( StreamMessageHandler, SubscribeParams, @@ -371,6 +372,7 @@ def _dispatch_raw(self, raw: str) -> None: request_id: RequestId | None = msg.get("id") if request_id is not None: + # FIXME: Create a class instance? request_result = self._pending_requests.get(str(request_id)) if not request_result: @@ -389,15 +391,75 @@ def _dispatch_raw(self, raw: str) -> None: return # (2) Stream data + # FIXME: Create a class instance? subscription_id: str | None = msg.get("subscription") if subscription_id is not None: - asyncio.ensure_future(self._dispatch_envelope(msg, subscription_id)) + asyncio.ensure_future(self._dispatch_message(msg, subscription_id)) return # (3) Unknown message LOGGER.error("Unrecognised message shape: %s", raw) - async def _dispatch_envelope(self, msg: dict[str, Any], subscription_id: str) -> None: - print(subscription_id) - print(msg) + async def _dispatch_message(self, msg: dict[str, Any], subscription_id: str) -> None: + """ + Deserialize a stream message and invoke the registered handler. + """ + + subscription = self._subscriptions.get(subscription_id) + + if subscription is None: + LOGGER.warning("Received message for unknown subscription id=%s", subscription_id) + return + + msg_seq = msg['seq'] + + if self._last_seq is not None and msg_seq != self._last_seq + 1: + LOGGER.warning( + "Sequence break detected for subscription %s: last_seq=%s, msg_seq=%s", + subscription_id, + self._last_seq, + msg_seq, + ) + + if self._on_sequence_break: + try: + result = await self._on_sequence_break(subscription_id, self._last_seq, msg_seq) + + if asyncio.iscoroutine(result): + await result + except Exception: + LOGGER.exception("Unhandled exception in `on_sequence_break` callback") + + self._last_seq = msg_seq + + msg_data = msg['data'] + msg_type = msg['type'] + + try: + deserialized_data = subscription.params.deserialize_data(msg_data, msg_type) + except Exception as exc: + LOGGER.exception( + "Failed to deserialize message for subscription %s (type=%s, seq=%s): %s", + subscription_id, + msg_type, + msg_seq, + exc, + ) + return + + enveloped_data = StreamMessageEnvelope( + type=msg_type, + data=deserialized_data, + ts=msg['ts'], + seq=msg_seq, + subscription=subscription_id, + ) + + try: + result = subscription.handler(enveloped_data) + + if asyncio.iscoroutine(result): + await result + except Exception: + LOGGER.exception("Unhandled exception in handler for subscription %s", subscription_id) diff --git a/x10/models/stream_rpc.py b/x10/models/stream_rpc.py index 08077f88..fb9cf3c4 100644 --- a/x10/models/stream_rpc.py +++ b/x10/models/stream_rpc.py @@ -4,7 +4,7 @@ T = TypeVar("T") -# FIXME: Not a model? +# FIXME: Not a model? Is it really used? @dataclass(frozen=True) class StreamMessageEnvelope(Generic[T]): type: str From 71fd53cb4f43616f802f4bed9d14d5f2870260fd Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 12:42:06 +0400 Subject: [PATCH 15/41] Add stream RPC client --- examples/cases/stream/subscribe_to_rpc_stream.py | 1 + x10/clients/streamrpc/streamrpc_client.py | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/examples/cases/stream/subscribe_to_rpc_stream.py b/examples/cases/stream/subscribe_to_rpc_stream.py index 734df214..14dd611d 100644 --- a/examples/cases/stream/subscribe_to_rpc_stream.py +++ b/examples/cases/stream/subscribe_to_rpc_stream.py @@ -29,6 +29,7 @@ async def subscribe_to_rpc_stream(stop_event: asyncio.Event): await client.unsubscribe(TradesParams(market="ETH-USD").topic_id) await stop_event.wait() + # await c.ping() # print("Ping OK") # await c.subscribe(TradesParams(market="BTC-USD"), on_trade) diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index b16aa8f2..c47dcc3e 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -6,7 +6,6 @@ import websockets from websockets import ConnectionClosed -from x10.models.stream_rpc import StreamMessageEnvelope from x10.clients.streamrpc.subscription import ( StreamMessageHandler, SubscribeParams, @@ -19,6 +18,7 @@ StreamRpcServerError, StreamRpcTimeoutError, ) +from x10.models.stream_rpc import StreamMessageEnvelope from x10.utils.http import USER_AGENT, RequestHeader from x10.utils.log import get_logger @@ -412,7 +412,7 @@ async def _dispatch_message(self, msg: dict[str, Any], subscription_id: str) -> LOGGER.warning("Received message for unknown subscription id=%s", subscription_id) return - msg_seq = msg['seq'] + msg_seq = msg["seq"] if self._last_seq is not None and msg_seq != self._last_seq + 1: LOGGER.warning( @@ -433,8 +433,8 @@ async def _dispatch_message(self, msg: dict[str, Any], subscription_id: str) -> self._last_seq = msg_seq - msg_data = msg['data'] - msg_type = msg['type'] + msg_data = msg["data"] + msg_type = msg["type"] try: deserialized_data = subscription.params.deserialize_data(msg_data, msg_type) @@ -451,7 +451,7 @@ async def _dispatch_message(self, msg: dict[str, Any], subscription_id: str) -> enveloped_data = StreamMessageEnvelope( type=msg_type, data=deserialized_data, - ts=msg['ts'], + ts=msg["ts"], seq=msg_seq, subscription=subscription_id, ) From 7b2b3f0413883d44adbff7fde4368e6616bf4d0b Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 12:55:57 +0400 Subject: [PATCH 16/41] Add stream RPC client --- .../cases/stream/subscribe_to_rpc_stream.py | 15 ++++-- x10/clients/streamrpc/subscription.py | 46 +++++++++++++++++++ 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/examples/cases/stream/subscribe_to_rpc_stream.py b/examples/cases/stream/subscribe_to_rpc_stream.py index 14dd611d..8082f968 100644 --- a/examples/cases/stream/subscribe_to_rpc_stream.py +++ b/examples/cases/stream/subscribe_to_rpc_stream.py @@ -4,8 +4,9 @@ from signal import SIGINT, SIGTERM from examples.utils import BTC_USD_MARKET, create_stream_rpc_client, init_env -from x10.clients.streamrpc.subscription import TradesParams +from x10.clients.streamrpc.subscription import AccountParams, TradesParams from x10.config import get_config_by_name +from x10.models.account import AccountStreamDataModel from x10.models.stream_rpc import StreamMessageEnvelope from x10.models.trade import PublicTradeModel @@ -13,9 +14,13 @@ MARKET_NAME = BTC_USD_MARKET -def on_trade(env: StreamMessageEnvelope[list[PublicTradeModel]]) -> None: - for t in env.data: - print(f"[trade] {t.market} {t.side.value} {t.qty} @ {t.price} (seq={env.seq})") +def on_trade(message: StreamMessageEnvelope[list[PublicTradeModel]]) -> None: + for t in message.data: + print(f"[trade] {t.market} {t.side} {t.qty} @ {t.price} (seq={message.seq})") + + +def on_account(message: StreamMessageEnvelope[AccountStreamDataModel]) -> None: + print(message.data) async def subscribe_to_rpc_stream(stop_event: asyncio.Event): @@ -25,6 +30,8 @@ async def subscribe_to_rpc_stream(stop_event: asyncio.Event): async with create_stream_rpc_client(client_config) as client: await client.subscribe(params=TradesParams(market="BTC-USD"), handler=on_trade) await client.subscribe(params=TradesParams(market="ETH-USD"), handler=on_trade) + # FIXME: Change account + await client.subscribe(params=AccountParams(account="3375", api_key=env_config.api_key), handler=on_account) await asyncio.sleep(5) await client.unsubscribe(TradesParams(market="ETH-USD").topic_id) await stop_event.wait() diff --git a/x10/clients/streamrpc/subscription.py b/x10/clients/streamrpc/subscription.py index 8deb925b..6ca11ba9 100644 --- a/x10/clients/streamrpc/subscription.py +++ b/x10/clients/streamrpc/subscription.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from typing import Any, Callable, Coroutine, Generic, TypeAlias, TypeVar +from x10.models.account import AccountStreamDataModel from x10.models.stream_rpc import StreamMessageEnvelope from x10.models.trade import PublicTradeModel @@ -62,3 +63,48 @@ def deserialize_data(self, data: list[dict[str, Any]], msg_type: str) -> list[Pu class TopicSubscription: params: SubscribeParams[Any] handler: StreamMessageHandler + + +class AccountParams(SubscribeParams[AccountStreamDataModel]): + """ + Subscribe to the private account stream. + """ + + def __init__( + self, + *, + account: str, + api_key: str, + ) -> None: + self.account = account + self.api_key = api_key + + @property + def topic_id(self) -> str: + return f"account.{self.account}" + + def to_dict(self) -> dict[str, Any]: + return { + "scope": "account", + "selector": {"account": self.account}, + "apiKey": self.api_key, + } + + def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> AccountStreamDataModel: + match msg_type: + # case "ACCOUNT.ORDER": + # return Order.from_dict(data) + # case "ACCOUNT.POSITION": + # return Position.from_dict(data) + # case "ACCOUNT.BALANCE": + # return Balance.from_dict(data) + # case "ACCOUNT.WITHDRAWAL": + # return Withdrawal.from_dict(data) + # case "ACCOUNT.DEPOSIT": + # return DepositUpdate.from_dict(data) + # case "ACCOUNT.TRADE": + # return Trade.from_dict(data) + # case "ACCOUNT.SPOT_BALANCE": + # return SpotBalance.from_dict(data) + case _: + raise ValueError(f"Unknown account stream message type: {msg_type!r}") From 45a5689d4d654c59488fdf16e32cffb52085f8c6 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 13:56:58 +0400 Subject: [PATCH 17/41] Add stream RPC client --- .../cases/stream/subscribe_to_rpc_stream.py | 22 ++++--- x10/clients/streamrpc/subscription.py | 63 ++++++++++++++++++- 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/examples/cases/stream/subscribe_to_rpc_stream.py b/examples/cases/stream/subscribe_to_rpc_stream.py index 8082f968..16ef6e4f 100644 --- a/examples/cases/stream/subscribe_to_rpc_stream.py +++ b/examples/cases/stream/subscribe_to_rpc_stream.py @@ -3,6 +3,8 @@ from asyncio import run from signal import SIGINT, SIGTERM +from clients.streamrpc.subscription import OrderBookParams + from examples.utils import BTC_USD_MARKET, create_stream_rpc_client, init_env from x10.clients.streamrpc.subscription import AccountParams, TradesParams from x10.config import get_config_by_name @@ -15,12 +17,15 @@ def on_trade(message: StreamMessageEnvelope[list[PublicTradeModel]]) -> None: - for t in message.data: - print(f"[trade] {t.market} {t.side} {t.qty} @ {t.price} (seq={message.seq})") + print(message) + + +def on_orderbook(message: StreamMessageEnvelope) -> None: + print(message) def on_account(message: StreamMessageEnvelope[AccountStreamDataModel]) -> None: - print(message.data) + print(message) async def subscribe_to_rpc_stream(stop_event: asyncio.Event): @@ -28,12 +33,13 @@ async def subscribe_to_rpc_stream(stop_event: asyncio.Event): client_config = get_config_by_name(env_config.client_config_name) async with create_stream_rpc_client(client_config) as client: - await client.subscribe(params=TradesParams(market="BTC-USD"), handler=on_trade) - await client.subscribe(params=TradesParams(market="ETH-USD"), handler=on_trade) + # await client.subscribe(params=TradesParams(market="BTC-USD"), handler=on_trade) + # await client.subscribe(params=TradesParams(market="ETH-USD"), handler=on_trade) + await client.subscribe(params=OrderBookParams(market="ETH-USD"), handler=on_orderbook) # FIXME: Change account - await client.subscribe(params=AccountParams(account="3375", api_key=env_config.api_key), handler=on_account) - await asyncio.sleep(5) - await client.unsubscribe(TradesParams(market="ETH-USD").topic_id) + # await client.subscribe(params=AccountParams(account="3375", api_key=env_config.api_key), handler=on_account) + # await asyncio.sleep(5) + # await client.unsubscribe(TradesParams(market="ETH-USD").topic_id) await stop_event.wait() diff --git a/x10/clients/streamrpc/subscription.py b/x10/clients/streamrpc/subscription.py index 6ca11ba9..34ae878b 100644 --- a/x10/clients/streamrpc/subscription.py +++ b/x10/clients/streamrpc/subscription.py @@ -2,7 +2,12 @@ from dataclasses import dataclass from typing import Any, Callable, Coroutine, Generic, TypeAlias, TypeVar +from pydantic import AliasChoices, Field + +from x10.errors import ValidationError from x10.models.account import AccountStreamDataModel +from x10.models.base import X10BaseModel +from x10.models.orderbook import OrderbookUpdateModel from x10.models.stream_rpc import StreamMessageEnvelope from x10.models.trade import PublicTradeModel @@ -65,7 +70,59 @@ class TopicSubscription: handler: StreamMessageHandler -class AccountParams(SubscribeParams[AccountStreamDataModel]): +# FIXME +class OrderbookUpdateModel2(OrderbookUpdateModel): + type: str = Field(validation_alias=AliasChoices("market", "m"), serialization_alias="m") + depth: str = Field(validation_alias=AliasChoices("depth", "d"), serialization_alias="d") + + +class OrderBookParams(SubscribeParams[OrderbookUpdateModel]): + """ + Subscribe to order book snapshots and delta updates. + + :param market: Market symbol or ``None`` for all markets. + :param depth: ``"full"`` (default) for the full order book, or ``"1"`` for best bid/ask only. + :param rfq_only: If ``True``, only include RFQ (request-for-quote) levels. Only valid when ``depth="full"``. + """ + + def __init__(self, market: str | None = None, depth: str = "full", rfq_only: bool = False) -> None: + if depth not in ("full", "1"): + raise ValidationError(f"depth must be 'full' or '1', got {depth!r}") + + if rfq_only and depth != "full": + raise ValueError("rfq_only is only valid when depth='full'") + + self.market = market + self.depth = depth + self.rfq_only = rfq_only + + @property + def topic_id(self) -> str: + if self.depth == "1": + return f"orderbooks.1.{self.market or 'all'}" + + return f"orderbooks.{self.market or 'all'}{'.rfq' if self.rfq_only else ''}" + + def to_dict(self) -> dict[str, Any]: + return { + "scope": "orderbooks", + "selector": { + "market": self.market, + "depth": self.depth, + "rfqOnly": self.rfq_only, + }, + } + + def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> OrderbookUpdateModel2: + return OrderbookUpdateModel2.model_validate(data) + + +# FIXME +class AccountStreamDataModel2(X10BaseModel): + pass + + +class AccountParams(SubscribeParams[AccountStreamDataModel2]): """ Subscribe to the private account stream. """ @@ -74,6 +131,7 @@ def __init__( self, *, account: str, + # FIXME: BE auth is broken api_key: str, ) -> None: self.account = account @@ -90,7 +148,8 @@ def to_dict(self) -> dict[str, Any]: "apiKey": self.api_key, } - def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> AccountStreamDataModel: + def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> AccountStreamDataModel2: + # return [PublicTradeModel.model_validate(item) for item in data] match msg_type: # case "ACCOUNT.ORDER": # return Order.from_dict(data) From aa4ddd62b6b0afd3590614b29986d89b11394011 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 14:07:40 +0400 Subject: [PATCH 18/41] Add stream RPC client --- x10/clients/streamrpc/streamrpc_client.py | 1 + x10/clients/streamrpc/subscription.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index c47dcc3e..213563b4 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -261,6 +261,7 @@ async def _run_connection_loop(self): RequestHeader.USER_AGENT: USER_AGENT, } + # FIXME: Remove? if self._api_key is not None: extra_headers[RequestHeader.API_KEY] = self._api_key diff --git a/x10/clients/streamrpc/subscription.py b/x10/clients/streamrpc/subscription.py index 34ae878b..3895a70c 100644 --- a/x10/clients/streamrpc/subscription.py +++ b/x10/clients/streamrpc/subscription.py @@ -70,7 +70,7 @@ class TopicSubscription: handler: StreamMessageHandler -# FIXME +# FIXME: Rename class OrderbookUpdateModel2(OrderbookUpdateModel): type: str = Field(validation_alias=AliasChoices("market", "m"), serialization_alias="m") depth: str = Field(validation_alias=AliasChoices("depth", "d"), serialization_alias="d") @@ -117,7 +117,7 @@ def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> Orderb return OrderbookUpdateModel2.model_validate(data) -# FIXME +# FIXME: Rename class AccountStreamDataModel2(X10BaseModel): pass From d0840876c7bef235d409bc6ebd7d87f05984be58 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 14:17:30 +0400 Subject: [PATCH 19/41] Add stream RPC client --- .../cases/stream/subscribe_to_rpc_stream.py | 16 ++++--------- x10/clients/streamrpc/subscription.py | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/examples/cases/stream/subscribe_to_rpc_stream.py b/examples/cases/stream/subscribe_to_rpc_stream.py index 16ef6e4f..21aeb464 100644 --- a/examples/cases/stream/subscribe_to_rpc_stream.py +++ b/examples/cases/stream/subscribe_to_rpc_stream.py @@ -3,7 +3,8 @@ from asyncio import run from signal import SIGINT, SIGTERM -from clients.streamrpc.subscription import OrderBookParams +from clients.streamrpc.subscription import FundingRateParams, OrderBookParams +from x10_rpc.params import FundingRatesParams from examples.utils import BTC_USD_MARKET, create_stream_rpc_client, init_env from x10.clients.streamrpc.subscription import AccountParams, TradesParams @@ -16,15 +17,7 @@ MARKET_NAME = BTC_USD_MARKET -def on_trade(message: StreamMessageEnvelope[list[PublicTradeModel]]) -> None: - print(message) - - -def on_orderbook(message: StreamMessageEnvelope) -> None: - print(message) - - -def on_account(message: StreamMessageEnvelope[AccountStreamDataModel]) -> None: +def on_message(message: StreamMessageEnvelope) -> None: print(message) @@ -35,7 +28,8 @@ async def subscribe_to_rpc_stream(stop_event: asyncio.Event): async with create_stream_rpc_client(client_config) as client: # await client.subscribe(params=TradesParams(market="BTC-USD"), handler=on_trade) # await client.subscribe(params=TradesParams(market="ETH-USD"), handler=on_trade) - await client.subscribe(params=OrderBookParams(market="ETH-USD"), handler=on_orderbook) + # await client.subscribe(params=OrderBookParams(market="ETH-USD"), handler=on_message) + await client.subscribe(params=FundingRateParams(market="ETH-USD"), handler=on_message) # FIXME: Change account # await client.subscribe(params=AccountParams(account="3375", api_key=env_config.api_key), handler=on_account) # await asyncio.sleep(5) diff --git a/x10/clients/streamrpc/subscription.py b/x10/clients/streamrpc/subscription.py index 3895a70c..90420062 100644 --- a/x10/clients/streamrpc/subscription.py +++ b/x10/clients/streamrpc/subscription.py @@ -2,6 +2,7 @@ from dataclasses import dataclass from typing import Any, Callable, Coroutine, Generic, TypeAlias, TypeVar +from models.funding_rate import FundingRateModel from pydantic import AliasChoices, Field from x10.errors import ValidationError @@ -117,6 +118,28 @@ def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> Orderb return OrderbookUpdateModel2.model_validate(data) +class FundingRateParams(SubscribeParams[FundingRateModel]): + """ + Subscribe to funding rate updates for a market (or all markets). + + :param market: Market symbol or ``None`` for all markets. + """ + + def __init__(self, market: str | None = None) -> None: + self.market = market + + @property + def topic_id(self) -> str: + return f"funding-rates.{self.market or 'all'}" + + def to_dict(self) -> dict[str, Any]: + return {"scope": "funding-rates", "selector": {"market": self.market}} + + # FIXME: Remove `None` from `msg_type` + def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> FundingRateModel: + return FundingRateModel.model_validate(data) + + # FIXME: Rename class AccountStreamDataModel2(X10BaseModel): pass From ac4b788911934bde509df02af76ad505d47b013f Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 14:25:00 +0400 Subject: [PATCH 20/41] Add stream RPC client --- .../cases/stream/subscribe_to_rpc_stream.py | 8 +++- x10/clients/streamrpc/subscription.py | 43 +++++++++++++++++-- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/examples/cases/stream/subscribe_to_rpc_stream.py b/examples/cases/stream/subscribe_to_rpc_stream.py index 21aeb464..98fd651d 100644 --- a/examples/cases/stream/subscribe_to_rpc_stream.py +++ b/examples/cases/stream/subscribe_to_rpc_stream.py @@ -3,7 +3,11 @@ from asyncio import run from signal import SIGINT, SIGTERM -from clients.streamrpc.subscription import FundingRateParams, OrderBookParams +from clients.streamrpc.subscription import ( + FundingRatesParams, + OrderbooksParams, + PricesParams, +) from x10_rpc.params import FundingRatesParams from examples.utils import BTC_USD_MARKET, create_stream_rpc_client, init_env @@ -29,7 +33,7 @@ async def subscribe_to_rpc_stream(stop_event: asyncio.Event): # await client.subscribe(params=TradesParams(market="BTC-USD"), handler=on_trade) # await client.subscribe(params=TradesParams(market="ETH-USD"), handler=on_trade) # await client.subscribe(params=OrderBookParams(market="ETH-USD"), handler=on_message) - await client.subscribe(params=FundingRateParams(market="ETH-USD"), handler=on_message) + await client.subscribe(params=PricesParams(price_type="index", market="ETH-USD"), handler=on_message) # FIXME: Change account # await client.subscribe(params=AccountParams(account="3375", api_key=env_config.api_key), handler=on_account) # await asyncio.sleep(5) diff --git a/x10/clients/streamrpc/subscription.py b/x10/clients/streamrpc/subscription.py index 90420062..99323cd4 100644 --- a/x10/clients/streamrpc/subscription.py +++ b/x10/clients/streamrpc/subscription.py @@ -1,5 +1,6 @@ from abc import ABC, abstractmethod from dataclasses import dataclass +from decimal import Decimal from typing import Any, Callable, Coroutine, Generic, TypeAlias, TypeVar from models.funding_rate import FundingRateModel @@ -77,7 +78,7 @@ class OrderbookUpdateModel2(OrderbookUpdateModel): depth: str = Field(validation_alias=AliasChoices("depth", "d"), serialization_alias="d") -class OrderBookParams(SubscribeParams[OrderbookUpdateModel]): +class OrderbooksParams(SubscribeParams[OrderbookUpdateModel]): """ Subscribe to order book snapshots and delta updates. @@ -88,10 +89,10 @@ class OrderBookParams(SubscribeParams[OrderbookUpdateModel]): def __init__(self, market: str | None = None, depth: str = "full", rfq_only: bool = False) -> None: if depth not in ("full", "1"): - raise ValidationError(f"depth must be 'full' or '1', got {depth!r}") + raise ValidationError(f"`depth` must be `full` or `1`, got {depth!r}") if rfq_only and depth != "full": - raise ValueError("rfq_only is only valid when depth='full'") + raise ValidationError("`rfq_only` is only valid when depth is `full`") self.market = market self.depth = depth @@ -118,7 +119,7 @@ def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> Orderb return OrderbookUpdateModel2.model_validate(data) -class FundingRateParams(SubscribeParams[FundingRateModel]): +class FundingRatesParams(SubscribeParams[FundingRateModel]): """ Subscribe to funding rate updates for a market (or all markets). @@ -140,6 +141,40 @@ def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> Fundin return FundingRateModel.model_validate(data) +class PriceModel(X10BaseModel): + market: str = Field(validation_alias=AliasChoices("market", "m"), serialization_alias="m") + price: Decimal = Field(validation_alias=AliasChoices("price", "p"), serialization_alias="p") + ts: int + + +class PricesParams(SubscribeParams[PriceModel]): + """ + Subscribe to index / mark price updates for a market (or all markets) + + :param market: Market symbol or ``None`` for all markets. + """ + + def __init__(self, price_type: str, market: str | None = None) -> None: + if price_type not in ("mark", "index"): + raise ValidationError(f"`price_type` must be `mark` or `index`, got {price_type!r}") + + self.price_type = price_type + self.market = market + + @property + def topic_id(self) -> str: + return f"prices.{self.price_type}.{self.market or 'all'}" + + def to_dict(self) -> dict[str, Any]: + return { + "scope": "prices", + "selector": {"type": self.price_type, "market": self.market}, + } + + def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> PriceModel: + return PriceModel.model_validate(data) + + # FIXME: Rename class AccountStreamDataModel2(X10BaseModel): pass From b7bd8479c12715eb6fd962dc2cb36d585de7ee59 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 14:32:24 +0400 Subject: [PATCH 21/41] Add stream RPC client --- .../cases/stream/subscribe_to_rpc_stream.py | 6 +++- x10/clients/streamrpc/subscription.py | 35 ++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/examples/cases/stream/subscribe_to_rpc_stream.py b/examples/cases/stream/subscribe_to_rpc_stream.py index 98fd651d..e48c79d7 100644 --- a/examples/cases/stream/subscribe_to_rpc_stream.py +++ b/examples/cases/stream/subscribe_to_rpc_stream.py @@ -4,6 +4,7 @@ from signal import SIGINT, SIGTERM from clients.streamrpc.subscription import ( + CandlesParams, FundingRatesParams, OrderbooksParams, PricesParams, @@ -33,7 +34,10 @@ async def subscribe_to_rpc_stream(stop_event: asyncio.Event): # await client.subscribe(params=TradesParams(market="BTC-USD"), handler=on_trade) # await client.subscribe(params=TradesParams(market="ETH-USD"), handler=on_trade) # await client.subscribe(params=OrderBookParams(market="ETH-USD"), handler=on_message) - await client.subscribe(params=PricesParams(price_type="index", market="ETH-USD"), handler=on_message) + # await client.subscribe(params=PricesParams(price_type="index", market="ETH-USD"), handler=on_message) + await client.subscribe( + params=CandlesParams(candle_type="index", market="ETH-USD", interval="PT1M"), handler=on_message + ) # FIXME: Change account # await client.subscribe(params=AccountParams(account="3375", api_key=env_config.api_key), handler=on_account) # await asyncio.sleep(5) diff --git a/x10/clients/streamrpc/subscription.py b/x10/clients/streamrpc/subscription.py index 99323cd4..0a381d67 100644 --- a/x10/clients/streamrpc/subscription.py +++ b/x10/clients/streamrpc/subscription.py @@ -3,6 +3,7 @@ from decimal import Decimal from typing import Any, Callable, Coroutine, Generic, TypeAlias, TypeVar +from models.candle import CandleModel from models.funding_rate import FundingRateModel from pydantic import AliasChoices, Field @@ -149,8 +150,9 @@ class PriceModel(X10BaseModel): class PricesParams(SubscribeParams[PriceModel]): """ - Subscribe to index / mark price updates for a market (or all markets) + Subscribe to mark / index price updates for a market (or all markets) + :param price_type: ``"mark"`` or ``"index"``. :param market: Market symbol or ``None`` for all markets. """ @@ -175,6 +177,37 @@ def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> PriceM return PriceModel.model_validate(data) +class CandlesParams(SubscribeParams[list[CandleModel]]): + """ + Subscribe to candles OHLC (`mark` or `index`) / OHLCV (`last`) for a market and interval. + + :param candle_type: ``"mark"``, ``"index"``, or ``"last"``. + :param market: Market symbol. + :param interval: ISO-8601 duration. + """ + + def __init__(self, candle_type: str, market: str, interval: str) -> None: + if candle_type not in ("mark", "index", "last"): + raise ValidationError(f"`candle_type` must be `mark`, `index`, or `last`, got {candle_type!r}") + + self.candle_type = candle_type + self.market = market + self.interval = interval + + @property + def topic_id(self) -> str: + return f"candles.{self.candle_type}.{self.market}.{self.interval}" + + def to_dict(self) -> dict[str, Any]: + return { + "scope": "candles", + "selector": {"type": self.candle_type, "market": self.market, "interval": self.interval}, + } + + def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> list[CandleModel]: + return [CandleModel.model_validate(item) for item in data] + + # FIXME: Rename class AccountStreamDataModel2(X10BaseModel): pass From e82d12d814e9f809cc692c9eb2c9dff62b2cfb81 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 14:55:15 +0400 Subject: [PATCH 22/41] Add stream RPC client --- x10/clients/streamrpc/subscription.py | 118 +++++++++++++++++++++----- 1 file changed, 96 insertions(+), 22 deletions(-) diff --git a/x10/clients/streamrpc/subscription.py b/x10/clients/streamrpc/subscription.py index 0a381d67..10810c5a 100644 --- a/x10/clients/streamrpc/subscription.py +++ b/x10/clients/streamrpc/subscription.py @@ -1,10 +1,14 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from decimal import Decimal -from typing import Any, Callable, Coroutine, Generic, TypeAlias, TypeVar +from typing import Any, Callable, Coroutine, Generic, Optional, TypeAlias, TypeVar +from models.balance import BalanceModel, SpotBalanceModel from models.candle import CandleModel from models.funding_rate import FundingRateModel +from models.order import OpenOrderModel +from models.position import PositionModel +from models.vault import DepositRequestModel from pydantic import AliasChoices, Field from x10.errors import ValidationError @@ -12,7 +16,7 @@ from x10.models.base import X10BaseModel from x10.models.orderbook import OrderbookUpdateModel from x10.models.stream_rpc import StreamMessageEnvelope -from x10.models.trade import PublicTradeModel +from x10.models.trade import AccountTradeModel, PublicTradeModel T = TypeVar("T") TopicId: TypeAlias = str @@ -208,12 +212,83 @@ def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> list[C return [CandleModel.model_validate(item) for item in data] -# FIXME: Rename -class AccountStreamDataModel2(X10BaseModel): +class StreamResponseModel(X10BaseModel, Generic[T]): + type: str = None + data: T + error: Optional[str] = None + ts: int + seq: int + subscription: str + + +class AccountStreamDataModelPosition(X10BaseModel): + isSnapshot: bool + positions: list[PositionModel] + + +class AccountStreamDataModelOrder(X10BaseModel): + isSnapshot: bool + orders: list[OpenOrderModel] + + +class AccountStreamDataModelTrade(X10BaseModel): + isSnapshot: bool + trades: list[AccountTradeModel] + + +class AccountStreamDataModelBalance(X10BaseModel): + isSnapshot: bool + balance: BalanceModel + + +class AccountStreamDataModelSpotBalance(X10BaseModel): + isSnapshot: bool + spotBalances: list[SpotBalanceModel] + + +class DepositStatusUpdateModel(X10BaseModel): + pass + # assetId: zodLong(), + # amount: zodDecimal(), + # timestamp: z.number(), + # status: z.enum(['CREATED', 'PROCESSED', 'REJECTED']), + + +class AccountStreamDataModelDeposit(X10BaseModel): + isSnapshot: bool + deposit: DepositStatusUpdateModel + + +# export const WithdrawalStatusUpdateSchema = z.object({ +# id: zodLong(), +# assetId: zodLong(), +# amount: zodDecimal(), +# status: z.enum(['CREATED', 'REJECTED', 'IN_PROGRESS', 'READY_FOR_CLAIM', 'COMPLETED']), +# reason: z.string().optional(), +# }) + + +class WithdrawalStatusUpdateModel(X10BaseModel): pass -class AccountParams(SubscribeParams[AccountStreamDataModel2]): +class AccountStreamDataModelWithdrawal(X10BaseModel): + isSnapshot: bool + withdrawal: WithdrawalStatusUpdateModel + + +X: TypeAlias = ( + AccountStreamDataModelPosition + | AccountStreamDataModelOrder + | AccountStreamDataModelTrade + | AccountStreamDataModelBalance + | AccountStreamDataModelSpotBalance + | AccountStreamDataModelDeposit + | AccountStreamDataModelWithdrawal +) + + +class AccountParams(SubscribeParams[X]): """ Subscribe to the private account stream. """ @@ -239,22 +314,21 @@ def to_dict(self) -> dict[str, Any]: "apiKey": self.api_key, } - def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> AccountStreamDataModel2: - # return [PublicTradeModel.model_validate(item) for item in data] + def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> X: match msg_type: - # case "ACCOUNT.ORDER": - # return Order.from_dict(data) - # case "ACCOUNT.POSITION": - # return Position.from_dict(data) - # case "ACCOUNT.BALANCE": - # return Balance.from_dict(data) - # case "ACCOUNT.WITHDRAWAL": - # return Withdrawal.from_dict(data) - # case "ACCOUNT.DEPOSIT": - # return DepositUpdate.from_dict(data) - # case "ACCOUNT.TRADE": - # return Trade.from_dict(data) - # case "ACCOUNT.SPOT_BALANCE": - # return SpotBalance.from_dict(data) + case "ACCOUNT.POSITION": + return AccountStreamDataModelOrder.model_validate(data) + case "ACCOUNT.ORDER": + return AccountStreamDataModelOrder.model_validate(data) + case "ACCOUNT.TRADE": + return AccountStreamDataModelTrade.model_validate(data) + case "ACCOUNT.BALANCE": + return AccountStreamDataModelBalance.model_validate(data) + case "ACCOUNT.SPOT_BALANCE": + return AccountStreamDataModelSpotBalance.model_validate(data) + case "ACCOUNT.DEPOSIT": + return AccountStreamDataModelDeposit.model_validate(data) + case "ACCOUNT.WITHDRAWAL": + return AccountStreamDataModelWithdrawal.model_validate(data) case _: - raise ValueError(f"Unknown account stream message type: {msg_type!r}") + raise ValidationError(f"Unknown account stream message type: {msg_type!r}") From 861130129cbeb998a1525e00879e9a1eab75ab93 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 14:56:32 +0400 Subject: [PATCH 23/41] Add stream RPC client --- x10/clients/streamrpc/subscription.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/x10/clients/streamrpc/subscription.py b/x10/clients/streamrpc/subscription.py index 10810c5a..7e57ec7a 100644 --- a/x10/clients/streamrpc/subscription.py +++ b/x10/clients/streamrpc/subscription.py @@ -288,7 +288,8 @@ class AccountStreamDataModelWithdrawal(X10BaseModel): ) -class AccountParams(SubscribeParams[X]): +# FIXME: Mark as not supported yet due to auth issues +class _AccountParams(SubscribeParams[X]): """ Subscribe to the private account stream. """ From c79ae1c177d8ad4b323ea22cf875fc2d90a138bf Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 14:56:55 +0400 Subject: [PATCH 24/41] Add stream RPC client --- tests/clients/test_streamrpc_client.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/clients/test_streamrpc_client.py b/tests/clients/test_streamrpc_client.py index bc7b86de..bdefba21 100644 --- a/tests/clients/test_streamrpc_client.py +++ b/tests/clients/test_streamrpc_client.py @@ -1,2 +1,3 @@ def test_streamrpc_client(): + # FIXME: Add tests pass From a4047b0d369d3400a23b11c80337b276de0693d7 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 16:18:09 +0400 Subject: [PATCH 25/41] Add stream RPC client --- x10/clients/streamrpc/subscription.py | 35 ++++++--------------------- x10/models/withdrawal.py | 18 ++++++++++++++ 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/x10/clients/streamrpc/subscription.py b/x10/clients/streamrpc/subscription.py index 7e57ec7a..f4e3f428 100644 --- a/x10/clients/streamrpc/subscription.py +++ b/x10/clients/streamrpc/subscription.py @@ -3,20 +3,20 @@ from decimal import Decimal from typing import Any, Callable, Coroutine, Generic, Optional, TypeAlias, TypeVar -from models.balance import BalanceModel, SpotBalanceModel -from models.candle import CandleModel -from models.funding_rate import FundingRateModel -from models.order import OpenOrderModel -from models.position import PositionModel -from models.vault import DepositRequestModel from pydantic import AliasChoices, Field from x10.errors import ValidationError -from x10.models.account import AccountStreamDataModel +from x10.models.balance import BalanceModel, SpotBalanceModel from x10.models.base import X10BaseModel +from x10.models.candle import CandleModel +from x10.models.deposit import DepositStatusUpdateModel +from x10.models.funding_rate import FundingRateModel +from x10.models.order import OpenOrderModel from x10.models.orderbook import OrderbookUpdateModel +from x10.models.position import PositionModel from x10.models.stream_rpc import StreamMessageEnvelope from x10.models.trade import AccountTradeModel, PublicTradeModel +from x10.models.withdrawal import WithdrawalStatusUpdateModel T = TypeVar("T") TopicId: TypeAlias = str @@ -246,32 +246,11 @@ class AccountStreamDataModelSpotBalance(X10BaseModel): spotBalances: list[SpotBalanceModel] -class DepositStatusUpdateModel(X10BaseModel): - pass - # assetId: zodLong(), - # amount: zodDecimal(), - # timestamp: z.number(), - # status: z.enum(['CREATED', 'PROCESSED', 'REJECTED']), - - class AccountStreamDataModelDeposit(X10BaseModel): isSnapshot: bool deposit: DepositStatusUpdateModel -# export const WithdrawalStatusUpdateSchema = z.object({ -# id: zodLong(), -# assetId: zodLong(), -# amount: zodDecimal(), -# status: z.enum(['CREATED', 'REJECTED', 'IN_PROGRESS', 'READY_FOR_CLAIM', 'COMPLETED']), -# reason: z.string().optional(), -# }) - - -class WithdrawalStatusUpdateModel(X10BaseModel): - pass - - class AccountStreamDataModelWithdrawal(X10BaseModel): isSnapshot: bool withdrawal: WithdrawalStatusUpdateModel diff --git a/x10/models/withdrawal.py b/x10/models/withdrawal.py index 5886477c..33d8b4eb 100644 --- a/x10/models/withdrawal.py +++ b/x10/models/withdrawal.py @@ -1,8 +1,18 @@ from decimal import Decimal +from strenum import StrEnum + from x10.models.base import HexValue, SettlementSignatureModel, X10BaseModel +class WithdrawalStatus(StrEnum): + CREATED = "CREATED" + REJECTED = "REJECTED" + IN_PROGRESS = "IN_PROGRESS" + READY_FOR_CLAIM = "READY_FOR_CLAIM" + COMPLETED = "COMPLETED" + + class TimestampModel(X10BaseModel): seconds: int @@ -25,3 +35,11 @@ class WithdrawalRequestModel(X10BaseModel): chain_id: str quote_id: str | None = None asset: str + + +class WithdrawalStatusUpdateModel(X10BaseModel): + id: int + asset_id: int + amount: Decimal + status: WithdrawalStatus + reason: str | None = None From ab7c0ef8ec09db4aeca140c794c4d175f5acd1b7 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 16:18:26 +0400 Subject: [PATCH 26/41] Add stream RPC client --- x10/models/deposit.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 x10/models/deposit.py diff --git a/x10/models/deposit.py b/x10/models/deposit.py new file mode 100644 index 00000000..2356439a --- /dev/null +++ b/x10/models/deposit.py @@ -0,0 +1,18 @@ +from decimal import Decimal + +from strenum import StrEnum + +from x10.models.base import X10BaseModel + + +class DepositStatus(StrEnum): + CREATED = "CREATED" + PROCESSED = "PROCESSED" + REJECTED = "REJECTED" + + +class DepositStatusUpdateModel(X10BaseModel): + asset_id: int + amount: Decimal + timestamp: int + status: DepositStatus From d18a5f87eea026b12fd9f1a8514b5bc463047ce7 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 16:29:28 +0400 Subject: [PATCH 27/41] Add stream RPC client --- x10/clients/streamrpc/subscription.py | 149 ++++++++------------------ x10/models/stream_rpc.py | 65 ++++++++++- 2 files changed, 104 insertions(+), 110 deletions(-) diff --git a/x10/clients/streamrpc/subscription.py b/x10/clients/streamrpc/subscription.py index f4e3f428..cb303d45 100644 --- a/x10/clients/streamrpc/subscription.py +++ b/x10/clients/streamrpc/subscription.py @@ -1,26 +1,28 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from decimal import Decimal from typing import Any, Callable, Coroutine, Generic, Optional, TypeAlias, TypeVar -from pydantic import AliasChoices, Field - from x10.errors import ValidationError -from x10.models.balance import BalanceModel, SpotBalanceModel -from x10.models.base import X10BaseModel from x10.models.candle import CandleModel -from x10.models.deposit import DepositStatusUpdateModel from x10.models.funding_rate import FundingRateModel -from x10.models.order import OpenOrderModel from x10.models.orderbook import OrderbookUpdateModel -from x10.models.position import PositionModel -from x10.models.stream_rpc import StreamMessageEnvelope -from x10.models.trade import AccountTradeModel, PublicTradeModel -from x10.models.withdrawal import WithdrawalStatusUpdateModel +from x10.models.stream_rpc import ( + StreamRpcAccountBalanceModel, + StreamRpcAccountDepositUpdateModel, + StreamRpcAccountOrdersModel, + StreamRpcAccountPositionsModel, + StreamRpcAccountSpotBalancesModel, + StreamRpcAccountTradesModel, + StreamRpcAccountWithdrawalUpdateModel, + StreamRpcOrderbookUpdateModel, + StreamRpcPriceModel, + StreamRpcResponseModel, +) +from x10.models.trade import PublicTradeModel T = TypeVar("T") TopicId: TypeAlias = str -StreamMessageHandler = Callable[[StreamMessageEnvelope[Any]], Coroutine[Any, Any, None] | None] +StreamMessageHandler = Callable[[StreamRpcResponseModel[Any]], Coroutine[Any, Any, None] | None] class SubscribeParams(ABC, Generic[T]): @@ -57,7 +59,7 @@ class TradesParams(SubscribeParams[list[PublicTradeModel]]): Subscribe to public trade events for a market (or all markets). """ - def __init__(self, market: str | None = None) -> None: + def __init__(self, *, market: str | None = None) -> None: self.market = market @property @@ -77,12 +79,6 @@ class TopicSubscription: handler: StreamMessageHandler -# FIXME: Rename -class OrderbookUpdateModel2(OrderbookUpdateModel): - type: str = Field(validation_alias=AliasChoices("market", "m"), serialization_alias="m") - depth: str = Field(validation_alias=AliasChoices("depth", "d"), serialization_alias="d") - - class OrderbooksParams(SubscribeParams[OrderbookUpdateModel]): """ Subscribe to order book snapshots and delta updates. @@ -92,7 +88,7 @@ class OrderbooksParams(SubscribeParams[OrderbookUpdateModel]): :param rfq_only: If ``True``, only include RFQ (request-for-quote) levels. Only valid when ``depth="full"``. """ - def __init__(self, market: str | None = None, depth: str = "full", rfq_only: bool = False) -> None: + def __init__(self, *, market: str | None = None, depth: str = "full", rfq_only: bool = False) -> None: if depth not in ("full", "1"): raise ValidationError(f"`depth` must be `full` or `1`, got {depth!r}") @@ -120,8 +116,8 @@ def to_dict(self) -> dict[str, Any]: }, } - def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> OrderbookUpdateModel2: - return OrderbookUpdateModel2.model_validate(data) + def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> StreamRpcOrderbookUpdateModel: + return StreamRpcOrderbookUpdateModel.model_validate(data) class FundingRatesParams(SubscribeParams[FundingRateModel]): @@ -131,7 +127,7 @@ class FundingRatesParams(SubscribeParams[FundingRateModel]): :param market: Market symbol or ``None`` for all markets. """ - def __init__(self, market: str | None = None) -> None: + def __init__(self, *, market: str | None = None) -> None: self.market = market @property @@ -146,13 +142,7 @@ def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> Fundin return FundingRateModel.model_validate(data) -class PriceModel(X10BaseModel): - market: str = Field(validation_alias=AliasChoices("market", "m"), serialization_alias="m") - price: Decimal = Field(validation_alias=AliasChoices("price", "p"), serialization_alias="p") - ts: int - - -class PricesParams(SubscribeParams[PriceModel]): +class PricesParams(SubscribeParams[StreamRpcPriceModel]): """ Subscribe to mark / index price updates for a market (or all markets) @@ -160,7 +150,7 @@ class PricesParams(SubscribeParams[PriceModel]): :param market: Market symbol or ``None`` for all markets. """ - def __init__(self, price_type: str, market: str | None = None) -> None: + def __init__(self, *, price_type: str, market: str | None = None) -> None: if price_type not in ("mark", "index"): raise ValidationError(f"`price_type` must be `mark` or `index`, got {price_type!r}") @@ -177,8 +167,8 @@ def to_dict(self) -> dict[str, Any]: "selector": {"type": self.price_type, "market": self.market}, } - def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> PriceModel: - return PriceModel.model_validate(data) + def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> StreamRpcPriceModel: + return StreamRpcPriceModel.model_validate(data) class CandlesParams(SubscribeParams[list[CandleModel]]): @@ -190,7 +180,7 @@ class CandlesParams(SubscribeParams[list[CandleModel]]): :param interval: ISO-8601 duration. """ - def __init__(self, candle_type: str, market: str, interval: str) -> None: + def __init__(self, *, candle_type: str, market: str, interval: str) -> None: if candle_type not in ("mark", "index", "last"): raise ValidationError(f"`candle_type` must be `mark`, `index`, or `last`, got {candle_type!r}") @@ -212,76 +202,26 @@ def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> list[C return [CandleModel.model_validate(item) for item in data] -class StreamResponseModel(X10BaseModel, Generic[T]): - type: str = None - data: T - error: Optional[str] = None - ts: int - seq: int - subscription: str - - -class AccountStreamDataModelPosition(X10BaseModel): - isSnapshot: bool - positions: list[PositionModel] - - -class AccountStreamDataModelOrder(X10BaseModel): - isSnapshot: bool - orders: list[OpenOrderModel] - - -class AccountStreamDataModelTrade(X10BaseModel): - isSnapshot: bool - trades: list[AccountTradeModel] - - -class AccountStreamDataModelBalance(X10BaseModel): - isSnapshot: bool - balance: BalanceModel - - -class AccountStreamDataModelSpotBalance(X10BaseModel): - isSnapshot: bool - spotBalances: list[SpotBalanceModel] - - -class AccountStreamDataModelDeposit(X10BaseModel): - isSnapshot: bool - deposit: DepositStatusUpdateModel - - -class AccountStreamDataModelWithdrawal(X10BaseModel): - isSnapshot: bool - withdrawal: WithdrawalStatusUpdateModel - - -X: TypeAlias = ( - AccountStreamDataModelPosition - | AccountStreamDataModelOrder - | AccountStreamDataModelTrade - | AccountStreamDataModelBalance - | AccountStreamDataModelSpotBalance - | AccountStreamDataModelDeposit - | AccountStreamDataModelWithdrawal +StreamRpcAccountUpdateType: TypeAlias = ( + StreamRpcAccountPositionsModel + | StreamRpcAccountOrdersModel + | StreamRpcAccountTradesModel + | StreamRpcAccountBalanceModel + | StreamRpcAccountSpotBalancesModel + | StreamRpcAccountDepositUpdateModel + | StreamRpcAccountWithdrawalUpdateModel ) -# FIXME: Mark as not supported yet due to auth issues -class _AccountParams(SubscribeParams[X]): +class _AccountParams(SubscribeParams[StreamRpcAccountUpdateType]): """ + NOT SUPPORTED DUE TO AUTH ISSUES. TO BE FIXED IN THE UPCOMING VERSIONS. + Subscribe to the private account stream. """ - def __init__( - self, - *, - account: str, - # FIXME: BE auth is broken - api_key: str, - ) -> None: + def __init__(self, *, account: str) -> None: self.account = account - self.api_key = api_key @property def topic_id(self) -> str: @@ -291,24 +231,23 @@ def to_dict(self) -> dict[str, Any]: return { "scope": "account", "selector": {"account": self.account}, - "apiKey": self.api_key, } - def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> X: + def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> StreamRpcAccountUpdateType: match msg_type: case "ACCOUNT.POSITION": - return AccountStreamDataModelOrder.model_validate(data) + return StreamRpcAccountPositionsModel.model_validate(data) case "ACCOUNT.ORDER": - return AccountStreamDataModelOrder.model_validate(data) + return StreamRpcAccountOrdersModel.model_validate(data) case "ACCOUNT.TRADE": - return AccountStreamDataModelTrade.model_validate(data) + return StreamRpcAccountTradesModel.model_validate(data) case "ACCOUNT.BALANCE": - return AccountStreamDataModelBalance.model_validate(data) + return StreamRpcAccountBalanceModel.model_validate(data) case "ACCOUNT.SPOT_BALANCE": - return AccountStreamDataModelSpotBalance.model_validate(data) + return StreamRpcAccountSpotBalancesModel.model_validate(data) case "ACCOUNT.DEPOSIT": - return AccountStreamDataModelDeposit.model_validate(data) + return StreamRpcAccountDepositUpdateModel.model_validate(data) case "ACCOUNT.WITHDRAWAL": - return AccountStreamDataModelWithdrawal.model_validate(data) + return StreamRpcAccountWithdrawalUpdateModel.model_validate(data) case _: raise ValidationError(f"Unknown account stream message type: {msg_type!r}") diff --git a/x10/models/stream_rpc.py b/x10/models/stream_rpc.py index fb9cf3c4..6e661aab 100644 --- a/x10/models/stream_rpc.py +++ b/x10/models/stream_rpc.py @@ -1,15 +1,70 @@ -from dataclasses import dataclass +from decimal import Decimal from typing import Generic, TypeVar +from pydantic import AliasChoices, Field + +from x10.models.balance import BalanceModel, SpotBalanceModel +from x10.models.base import X10BaseModel +from x10.models.deposit import DepositStatusUpdateModel +from x10.models.order import OpenOrderModel +from x10.models.orderbook import OrderbookUpdateModel +from x10.models.position import PositionModel +from x10.models.trade import AccountTradeModel +from x10.models.withdrawal import WithdrawalStatusUpdateModel + T = TypeVar("T") -# FIXME: Not a model? Is it really used? -@dataclass(frozen=True) -class StreamMessageEnvelope(Generic[T]): +class StreamRpcResponseModel(X10BaseModel, Generic[T]): type: str data: T + error: str = None ts: int seq: int subscription: str - error: str | None = None + + +class StreamRpcOrderbookUpdateModel(OrderbookUpdateModel): + type: str = Field(validation_alias=AliasChoices("market", "m"), serialization_alias="m") + depth: str = Field(validation_alias=AliasChoices("depth", "d"), serialization_alias="d") + + +class StreamRpcPriceModel(X10BaseModel): + market: str = Field(validation_alias=AliasChoices("market", "m"), serialization_alias="m") + price: Decimal = Field(validation_alias=AliasChoices("price", "p"), serialization_alias="p") + ts: int + + +class StreamRpcAccountPositionsModel(X10BaseModel): + isSnapshot: bool + positions: list[PositionModel] + + +class StreamRpcAccountOrdersModel(X10BaseModel): + isSnapshot: bool + orders: list[OpenOrderModel] + + +class StreamRpcAccountTradesModel(X10BaseModel): + isSnapshot: bool + trades: list[AccountTradeModel] + + +class StreamRpcAccountBalanceModel(X10BaseModel): + isSnapshot: bool + balance: BalanceModel + + +class StreamRpcAccountSpotBalancesModel(X10BaseModel): + isSnapshot: bool + spotBalances: list[SpotBalanceModel] + + +class StreamRpcAccountDepositUpdateModel(X10BaseModel): + isSnapshot: bool + deposit: DepositStatusUpdateModel + + +class StreamRpcAccountWithdrawalUpdateModel(X10BaseModel): + isSnapshot: bool + withdrawal: WithdrawalStatusUpdateModel From cdd1084ae83e371d4e94a29aefe631f0a8964dba Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 16:34:20 +0400 Subject: [PATCH 28/41] Add stream RPC client --- examples/cases/stream/subscribe_to_rpc_stream.py | 15 ++++++++------- .../clients/test_streamrpc_subscription_params.py | 3 +++ x10/clients/streamrpc/streamrpc_client.py | 6 +++--- .../{subscription.py => subscription_params.py} | 2 +- 4 files changed, 15 insertions(+), 11 deletions(-) create mode 100644 tests/clients/test_streamrpc_subscription_params.py rename x10/clients/streamrpc/{subscription.py => subscription_params.py} (99%) diff --git a/examples/cases/stream/subscribe_to_rpc_stream.py b/examples/cases/stream/subscribe_to_rpc_stream.py index e48c79d7..e057fd39 100644 --- a/examples/cases/stream/subscribe_to_rpc_stream.py +++ b/examples/cases/stream/subscribe_to_rpc_stream.py @@ -3,29 +3,30 @@ from asyncio import run from signal import SIGINT, SIGTERM -from clients.streamrpc.subscription import ( +from x10_rpc.params import FundingRatesParams + +from examples.utils import BTC_USD_MARKET, create_stream_rpc_client, init_env +from x10.clients.streamrpc.subscription_params import ( CandlesParams, FundingRatesParams, OrderbooksParams, PricesParams, + TradesParams, ) -from x10_rpc.params import FundingRatesParams - -from examples.utils import BTC_USD_MARKET, create_stream_rpc_client, init_env -from x10.clients.streamrpc.subscription import AccountParams, TradesParams from x10.config import get_config_by_name from x10.models.account import AccountStreamDataModel -from x10.models.stream_rpc import StreamMessageEnvelope +from x10.models.stream_rpc import StreamRpcResponseModel from x10.models.trade import PublicTradeModel LOGGER = logging.getLogger() MARKET_NAME = BTC_USD_MARKET -def on_message(message: StreamMessageEnvelope) -> None: +def on_message(message: StreamRpcResponseModel) -> None: print(message) +# FIXME: Cleanup async def subscribe_to_rpc_stream(stop_event: asyncio.Event): env_config = init_env() client_config = get_config_by_name(env_config.client_config_name) diff --git a/tests/clients/test_streamrpc_subscription_params.py b/tests/clients/test_streamrpc_subscription_params.py new file mode 100644 index 00000000..59fcd76f --- /dev/null +++ b/tests/clients/test_streamrpc_subscription_params.py @@ -0,0 +1,3 @@ +def test_streamrpc_subscription_params(): + # FIXME: Add tests + pass diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index 213563b4..1b3095fd 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -6,7 +6,7 @@ import websockets from websockets import ConnectionClosed -from x10.clients.streamrpc.subscription import ( +from x10.clients.streamrpc.subscription_params import ( StreamMessageHandler, SubscribeParams, TopicId, @@ -18,7 +18,7 @@ StreamRpcServerError, StreamRpcTimeoutError, ) -from x10.models.stream_rpc import StreamMessageEnvelope +from x10.models.stream_rpc import StreamRpcResponseModel from x10.utils.http import USER_AGENT, RequestHeader from x10.utils.log import get_logger @@ -449,7 +449,7 @@ async def _dispatch_message(self, msg: dict[str, Any], subscription_id: str) -> ) return - enveloped_data = StreamMessageEnvelope( + enveloped_data = StreamRpcResponseModel( type=msg_type, data=deserialized_data, ts=msg["ts"], diff --git a/x10/clients/streamrpc/subscription.py b/x10/clients/streamrpc/subscription_params.py similarity index 99% rename from x10/clients/streamrpc/subscription.py rename to x10/clients/streamrpc/subscription_params.py index cb303d45..04c8f5c7 100644 --- a/x10/clients/streamrpc/subscription.py +++ b/x10/clients/streamrpc/subscription_params.py @@ -1,6 +1,6 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Any, Callable, Coroutine, Generic, Optional, TypeAlias, TypeVar +from typing import Any, Callable, Coroutine, Generic, TypeAlias, TypeVar from x10.errors import ValidationError from x10.models.candle import CandleModel From 205ee3a4880bcf815707d54c0a1f350b095417c7 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 17:21:10 +0400 Subject: [PATCH 29/41] Add stream RPC client --- .../cases/stream/subscribe_to_rpc_stream.py | 2 + x10/clients/streamrpc/streamrpc_client.py | 144 +++--------------- x10/clients/streamrpc/streamrpc_dispatcher.py | 141 +++++++++++++++++ 3 files changed, 160 insertions(+), 127 deletions(-) create mode 100644 x10/clients/streamrpc/streamrpc_dispatcher.py diff --git a/examples/cases/stream/subscribe_to_rpc_stream.py b/examples/cases/stream/subscribe_to_rpc_stream.py index e057fd39..49903b50 100644 --- a/examples/cases/stream/subscribe_to_rpc_stream.py +++ b/examples/cases/stream/subscribe_to_rpc_stream.py @@ -36,9 +36,11 @@ async def subscribe_to_rpc_stream(stop_event: asyncio.Event): # await client.subscribe(params=TradesParams(market="ETH-USD"), handler=on_trade) # await client.subscribe(params=OrderBookParams(market="ETH-USD"), handler=on_message) # await client.subscribe(params=PricesParams(price_type="index", market="ETH-USD"), handler=on_message) + print(await client.list_subscriptions()) await client.subscribe( params=CandlesParams(candle_type="index", market="ETH-USD", interval="PT1M"), handler=on_message ) + print(await client.list_subscriptions()) # FIXME: Change account # await client.subscribe(params=AccountParams(account="3375", api_key=env_config.api_key), handler=on_account) # await asyncio.sleep(5) diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index 1b3095fd..07336485 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -1,24 +1,23 @@ import asyncio import json import random -from typing import Any, Callable, Coroutine, TypeAlias, TypeVar +from typing import Any, Callable, Coroutine, TypeVar import websockets +from clients.streamrpc.streamrpc_dispatcher import ( + OnSequenceBreakCallback, + PendingRequestsMap, +) from websockets import ConnectionClosed +from x10.clients.streamrpc.streamrpc_dispatcher import RequestId, StreamRpcDispatcher from x10.clients.streamrpc.subscription_params import ( StreamMessageHandler, SubscribeParams, TopicId, TopicSubscription, ) -from x10.errors import ( - StreamRpcConnectionError, - StreamRpcError, - StreamRpcServerError, - StreamRpcTimeoutError, -) -from x10.models.stream_rpc import StreamRpcResponseModel +from x10.errors import StreamRpcConnectionError, StreamRpcError, StreamRpcTimeoutError from x10.utils.http import USER_AGENT, RequestHeader from x10.utils.log import get_logger @@ -26,9 +25,7 @@ CONNECTION_LOOP_TASK_NAME = "x10-rpc-connection-loop" T = TypeVar("T") -RequestId: TypeAlias = str OnReconnectCallback = Callable[[list[str]], Coroutine[Any, Any, None]] -OnSequenceBreakCallback = Callable[[str, int, int], Coroutine[Any, Any, None]] class StreamRpcClient: @@ -111,8 +108,7 @@ async def list_subscriptions(self): """ result = await self._rpc("list-subscriptions") - # FIXME: Simplify? - return result.get("subscriptions") or [] + return result["subscriptions"] async def subscribe(self, *, params: SubscribeParams[T], handler: StreamMessageHandler): """ @@ -175,10 +171,6 @@ def __init__( # FIXME: Replace with state? self._is_stopped = False self._next_request_id = 0 - # FIXME: Update description - # Last observed connection-level sequence number; None until the first - # message arrives on a connection (also reset to None on each reconnect). - self._last_seq: int | None = None # FIXME: Update description # Fires when a connection is fully established (and resubscription done). @@ -189,12 +181,18 @@ def __init__( # FIXME: Update description # Pending RPC request futures keyed by request id. - self._pending_requests: dict[RequestId, asyncio.Future[dict[str, Any]]] = {} + self._pending_requests: PendingRequestsMap = {} # FIXME: Update description # Active subscriptions keyed by topic_id. self._subscriptions: dict[TopicId, TopicSubscription] = {} + self._dispatcher = StreamRpcDispatcher( + pending_requests=self._pending_requests, + subscriptions=self._subscriptions, + on_sequence_break=on_sequence_break, + ) + async def __aenter__(self) -> "StreamRpcClient": await self.connect() return self @@ -295,7 +293,7 @@ async def handle_lost_connection(exc: Exception) -> bool: LOGGER.debug("Connected to %s", self._api_url) # `seq` restarts at 0 on each new connection - self._last_seq = None + self._dispatcher.reset_last_seq() reconnect_delay = self._reconnect_initial_delay await self._resubscribe() @@ -308,7 +306,7 @@ async def handle_lost_connection(exc: Exception) -> bool: async for raw in ws: if isinstance(raw, str): - self._dispatch_raw(raw) + self._dispatcher.dispatch_raw(raw) except asyncio.CancelledError: break except (ConnectionClosed, OSError, asyncio.TimeoutError) as exc: @@ -356,111 +354,3 @@ async def _resubscribe(self) -> None: await self._ws.send(json.dumps(request)) except Exception: LOGGER.exception("Failed to resubscribe to %s", topic_id) - - # FIXME: Create a dispatcher class? - def _dispatch_raw(self, raw: str) -> None: - """ - Parse a raw WebSocket text frame and route it to the right handler. - """ - - try: - msg: dict[str, Any] = json.loads(raw) - except json.JSONDecodeError: - LOGGER.warning("Received invalid JSON (%.120s…)", raw) - return - - # (1) JSON-RPC response - request_id: RequestId | None = msg.get("id") - - if request_id is not None: - # FIXME: Create a class instance? - request_result = self._pending_requests.get(str(request_id)) - - if not request_result: - LOGGER.warning("Received response for unknown request id=%s", request_id) - return - - err = msg.get("error") - - if err: - request_result.set_exception( - StreamRpcServerError(code=err["code"], message=err["message"], data=err.get("data")) - ) - else: - request_result.set_result(msg["result"]) - - return - - # (2) Stream data - # FIXME: Create a class instance? - subscription_id: str | None = msg.get("subscription") - - if subscription_id is not None: - asyncio.ensure_future(self._dispatch_message(msg, subscription_id)) - return - - # (3) Unknown message - LOGGER.error("Unrecognised message shape: %s", raw) - - async def _dispatch_message(self, msg: dict[str, Any], subscription_id: str) -> None: - """ - Deserialize a stream message and invoke the registered handler. - """ - - subscription = self._subscriptions.get(subscription_id) - - if subscription is None: - LOGGER.warning("Received message for unknown subscription id=%s", subscription_id) - return - - msg_seq = msg["seq"] - - if self._last_seq is not None and msg_seq != self._last_seq + 1: - LOGGER.warning( - "Sequence break detected for subscription %s: last_seq=%s, msg_seq=%s", - subscription_id, - self._last_seq, - msg_seq, - ) - - if self._on_sequence_break: - try: - result = await self._on_sequence_break(subscription_id, self._last_seq, msg_seq) - - if asyncio.iscoroutine(result): - await result - except Exception: - LOGGER.exception("Unhandled exception in `on_sequence_break` callback") - - self._last_seq = msg_seq - - msg_data = msg["data"] - msg_type = msg["type"] - - try: - deserialized_data = subscription.params.deserialize_data(msg_data, msg_type) - except Exception as exc: - LOGGER.exception( - "Failed to deserialize message for subscription %s (type=%s, seq=%s): %s", - subscription_id, - msg_type, - msg_seq, - exc, - ) - return - - enveloped_data = StreamRpcResponseModel( - type=msg_type, - data=deserialized_data, - ts=msg["ts"], - seq=msg_seq, - subscription=subscription_id, - ) - - try: - result = subscription.handler(enveloped_data) - - if asyncio.iscoroutine(result): - await result - except Exception: - LOGGER.exception("Unhandled exception in handler for subscription %s", subscription_id) diff --git a/x10/clients/streamrpc/streamrpc_dispatcher.py b/x10/clients/streamrpc/streamrpc_dispatcher.py new file mode 100644 index 00000000..f0d2e2ce --- /dev/null +++ b/x10/clients/streamrpc/streamrpc_dispatcher.py @@ -0,0 +1,141 @@ +import asyncio +import json +from typing import Any, Callable, Coroutine, TypeAlias + +from x10.clients.streamrpc.subscription_params import TopicId, TopicSubscription +from x10.errors import StreamRpcServerError +from x10.models.stream_rpc import StreamRpcResponseModel +from x10.utils.log import get_logger + +LOGGER = get_logger(__name__) + +RequestId: TypeAlias = str +PendingRequestsMap: TypeAlias = dict[RequestId, asyncio.Future[dict[str, Any]]] +OnSequenceBreakCallback = Callable[[str, int, int], Coroutine[Any, Any, None]] + + +class StreamRpcDispatcher: + def __init__( + self, + *, + pending_requests: PendingRequestsMap, + subscriptions: dict[TopicId, TopicSubscription], + on_sequence_break: OnSequenceBreakCallback | None = None, + ) -> None: + # FIXME: Update description + # Last observed connection-level sequence number; None until the first + # message arrives on a connection (also reset to None on each reconnect). + self._last_seq: int | None = None + self._pending_requests = pending_requests + self._subscriptions = subscriptions + self._on_sequence_break = on_sequence_break + + def reset_last_seq(self) -> None: + self._last_seq = None + + def dispatch_raw(self, raw: str) -> None: + """ + Parse a raw WebSocket text frame and route it to the right handler. + """ + + try: + msg: dict[str, Any] = json.loads(raw) + except json.JSONDecodeError: + LOGGER.warning("Received invalid JSON (%.120s…)", raw) + return + + # (1) JSON-RPC response + request_id: RequestId | None = msg.get("id") + + if request_id is not None: + # FIXME: Create a class instance? + request_result = self._pending_requests.get(str(request_id)) + + if not request_result: + LOGGER.warning("Received response for unknown request id=%s", request_id) + return + + err = msg.get("error") + + if err: + request_result.set_exception( + StreamRpcServerError(code=err["code"], message=err["message"], data=err.get("data")) + ) + else: + request_result.set_result(msg["result"]) + + return + + # (2) Stream data + # FIXME: Create a class instance? + subscription_id: str | None = msg.get("subscription") + + if subscription_id is not None: + asyncio.ensure_future(self._dispatch_message(msg, subscription_id)) + return + + # (3) Unknown message + LOGGER.error("Unrecognised message shape: %s", raw) + + async def _dispatch_message(self, msg: dict[str, Any], subscription_id: str) -> None: + """ + Deserialize a stream message and invoke the registered handler. + """ + + subscription = self._subscriptions.get(subscription_id) + + if subscription is None: + LOGGER.warning("Received message for unknown subscription id=%s", subscription_id) + return + + msg_seq = msg["seq"] + + if self._last_seq is not None and msg_seq != self._last_seq + 1: + LOGGER.warning( + "Sequence break detected for subscription %s: last_seq=%s, msg_seq=%s", + subscription_id, + self._last_seq, + msg_seq, + ) + + if self._on_sequence_break: + try: + result = await self._on_sequence_break(subscription_id, self._last_seq, msg_seq) + + if asyncio.iscoroutine(result): + await result + except Exception: + LOGGER.exception("Unhandled exception in `on_sequence_break` callback") + + self._last_seq = msg_seq + + msg_data = msg["data"] + msg_type = msg["type"] + + try: + deserialized_data = subscription.params.deserialize_data(msg_data, msg_type) + except Exception as exc: + LOGGER.exception( + "Failed to deserialize message for subscription %s (type=%s, seq=%s): %s", + subscription_id, + msg_type, + msg_seq, + exc, + ) + return + + enveloped_data = StreamRpcResponseModel( + type=msg_type, + data=deserialized_data, + ts=msg["ts"], + seq=msg_seq, + subscription=subscription_id, + ) + + try: + result = subscription.handler(enveloped_data) + + if asyncio.iscoroutine(result): + await result + except Exception: + LOGGER.exception("Unhandled exception in handler for subscription %s", subscription_id) From e8dbd9bbdd47844a1425e8101ca540f053d4c428 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 17:32:52 +0400 Subject: [PATCH 30/41] Add stream RPC client --- x10/clients/streamrpc/streamrpc_client.py | 26 +++++-------------- x10/clients/streamrpc/streamrpc_dispatcher.py | 6 +---- x10/clients/streamrpc/subscription_params.py | 10 +++---- 3 files changed, 12 insertions(+), 30 deletions(-) diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index 07336485..bde9608e 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -36,7 +36,6 @@ class StreamRpcClient: Supports automatic reconnection and transparent re-subscription after connection loss. :param api_url: Full WebSocket URL. - :param api_key: API key for private topics. :param on_reconnect: Optional async callback invoked after a successful reconnection. :param on_sequence_break: Optional callback invoked when a gap is detected in the connection-level ``seq`` counter, indicating that one or more stream @@ -164,27 +163,19 @@ def __init__( self._on_reconnect = on_reconnect self._on_sequence_break = on_sequence_break - self._ws: websockets.WebSocketClientProtocol | None = None self._request_timeout = 10 self._reconnect_initial_delay = 1 self._reconnect_max_delay = 10 - # FIXME: Replace with state? self._is_stopped = False self._next_request_id = 0 - # FIXME: Update description - # Fires when a connection is fully established (and resubscription done). + self._ws: websockets.WebSocketClientProtocol | None = None + # Fires when a connection is established (and re-subscription is done). self._ready = asyncio.Event() - - # FIXME: Rename? self._connection_loop_task: asyncio.Task[None] | None = None - - # FIXME: Update description - # Pending RPC request futures keyed by request id. + # Pending RPC requests (as futures) keyed by request id. self._pending_requests: PendingRequestsMap = {} - - # FIXME: Update description - # Active subscriptions keyed by topic_id. + # Active subscriptions keyed by topic id. self._subscriptions: dict[TopicId, TopicSubscription] = {} self._dispatcher = StreamRpcDispatcher( @@ -245,7 +236,6 @@ def _fail_pending(self, exc: Exception) -> None: self._pending_requests.clear() - # FIXME: Create a class for connection loop? async def _run_connection_loop(self): """ Background task that maintains the connection (including reconnections) @@ -259,10 +249,6 @@ async def _run_connection_loop(self): RequestHeader.USER_AGENT: USER_AGENT, } - # FIXME: Remove? - if self._api_key is not None: - extra_headers[RequestHeader.API_KEY] = self._api_key - async def handle_lost_connection(exc: Exception) -> bool: nonlocal reconnect_delay @@ -310,9 +296,9 @@ async def handle_lost_connection(exc: Exception) -> bool: except asyncio.CancelledError: break except (ConnectionClosed, OSError, asyncio.TimeoutError) as exc: - should_break = not await handle_lost_connection(exc) + should_try_to_reconnect = await handle_lost_connection(exc) - if should_break: + if not should_try_to_reconnect: break except Exception as exc: LOGGER.exception("Unexpected error in connection loop: %s", exc) diff --git a/x10/clients/streamrpc/streamrpc_dispatcher.py b/x10/clients/streamrpc/streamrpc_dispatcher.py index f0d2e2ce..484a7398 100644 --- a/x10/clients/streamrpc/streamrpc_dispatcher.py +++ b/x10/clients/streamrpc/streamrpc_dispatcher.py @@ -22,9 +22,7 @@ def __init__( subscriptions: dict[TopicId, TopicSubscription], on_sequence_break: OnSequenceBreakCallback | None = None, ) -> None: - # FIXME: Update description - # Last observed connection-level sequence number; None until the first - # message arrives on a connection (also reset to None on each reconnect). + # Last observed connection-level sequence (reset to `None` on each re-connect). self._last_seq: int | None = None self._pending_requests = pending_requests self._subscriptions = subscriptions @@ -48,7 +46,6 @@ def dispatch_raw(self, raw: str) -> None: request_id: RequestId | None = msg.get("id") if request_id is not None: - # FIXME: Create a class instance? request_result = self._pending_requests.get(str(request_id)) if not request_result: @@ -67,7 +64,6 @@ def dispatch_raw(self, raw: str) -> None: return # (2) Stream data - # FIXME: Create a class instance? subscription_id: str | None = msg.get("subscription") if subscription_id is not None: diff --git a/x10/clients/streamrpc/subscription_params.py b/x10/clients/streamrpc/subscription_params.py index 04c8f5c7..c59d43cd 100644 --- a/x10/clients/streamrpc/subscription_params.py +++ b/x10/clients/streamrpc/subscription_params.py @@ -116,7 +116,7 @@ def to_dict(self) -> dict[str, Any]: }, } - def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> StreamRpcOrderbookUpdateModel: + def deserialize_data(self, data: dict[str, Any], msg_type: str) -> StreamRpcOrderbookUpdateModel: return StreamRpcOrderbookUpdateModel.model_validate(data) @@ -138,7 +138,7 @@ def to_dict(self) -> dict[str, Any]: return {"scope": "funding-rates", "selector": {"market": self.market}} # FIXME: Remove `None` from `msg_type` - def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> FundingRateModel: + def deserialize_data(self, data: dict[str, Any], msg_type: str) -> FundingRateModel: return FundingRateModel.model_validate(data) @@ -167,7 +167,7 @@ def to_dict(self) -> dict[str, Any]: "selector": {"type": self.price_type, "market": self.market}, } - def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> StreamRpcPriceModel: + def deserialize_data(self, data: dict[str, Any], msg_type: str) -> StreamRpcPriceModel: return StreamRpcPriceModel.model_validate(data) @@ -198,7 +198,7 @@ def to_dict(self) -> dict[str, Any]: "selector": {"type": self.candle_type, "market": self.market, "interval": self.interval}, } - def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> list[CandleModel]: + def deserialize_data(self, data: dict[str, Any], msg_type: str) -> list[CandleModel]: return [CandleModel.model_validate(item) for item in data] @@ -233,7 +233,7 @@ def to_dict(self) -> dict[str, Any]: "selector": {"account": self.account}, } - def deserialize_data(self, data: dict[str, Any], msg_type: str | None) -> StreamRpcAccountUpdateType: + def deserialize_data(self, data: dict[str, Any], msg_type: str) -> StreamRpcAccountUpdateType: match msg_type: case "ACCOUNT.POSITION": return StreamRpcAccountPositionsModel.model_validate(data) From 5f2d9011968eb190817b5097b97b7bc4bd68cf11 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 17:37:58 +0400 Subject: [PATCH 31/41] Add stream RPC client --- .../cases/stream/subscribe_to_rpc_stream.py | 43 ++++++------------- .../test_streamrpc_subscription_params.py | 3 -- x10/clients/streamrpc/streamrpc_client.py | 9 ++-- 3 files changed, 19 insertions(+), 36 deletions(-) delete mode 100644 tests/clients/test_streamrpc_subscription_params.py diff --git a/examples/cases/stream/subscribe_to_rpc_stream.py b/examples/cases/stream/subscribe_to_rpc_stream.py index 49903b50..300a68b4 100644 --- a/examples/cases/stream/subscribe_to_rpc_stream.py +++ b/examples/cases/stream/subscribe_to_rpc_stream.py @@ -3,61 +3,46 @@ from asyncio import run from signal import SIGINT, SIGTERM -from x10_rpc.params import FundingRatesParams - from examples.utils import BTC_USD_MARKET, create_stream_rpc_client, init_env from x10.clients.streamrpc.subscription_params import ( CandlesParams, - FundingRatesParams, - OrderbooksParams, PricesParams, TradesParams, ) from x10.config import get_config_by_name -from x10.models.account import AccountStreamDataModel from x10.models.stream_rpc import StreamRpcResponseModel -from x10.models.trade import PublicTradeModel LOGGER = logging.getLogger() MARKET_NAME = BTC_USD_MARKET def on_message(message: StreamRpcResponseModel) -> None: - print(message) + LOGGER.info("Received message: %s", message) -# FIXME: Cleanup async def subscribe_to_rpc_stream(stop_event: asyncio.Event): env_config = init_env() client_config = get_config_by_name(env_config.client_config_name) async with create_stream_rpc_client(client_config) as client: - # await client.subscribe(params=TradesParams(market="BTC-USD"), handler=on_trade) - # await client.subscribe(params=TradesParams(market="ETH-USD"), handler=on_trade) - # await client.subscribe(params=OrderBookParams(market="ETH-USD"), handler=on_message) - # await client.subscribe(params=PricesParams(price_type="index", market="ETH-USD"), handler=on_message) - print(await client.list_subscriptions()) + await client.ping() + + subscriptions_before = await client.list_subscriptions() + + LOGGER.info("Active subscriptions: %s", subscriptions_before) + + await client.subscribe(params=TradesParams(market="BTC-USD"), handler=on_message) + await client.subscribe(params=TradesParams(market="ETH-USD"), handler=on_message) + await client.subscribe(params=PricesParams(price_type="index", market="ETH-USD"), handler=on_message) await client.subscribe( params=CandlesParams(candle_type="index", market="ETH-USD", interval="PT1M"), handler=on_message ) - print(await client.list_subscriptions()) - # FIXME: Change account - # await client.subscribe(params=AccountParams(account="3375", api_key=env_config.api_key), handler=on_account) - # await asyncio.sleep(5) - # await client.unsubscribe(TradesParams(market="ETH-USD").topic_id) - await stop_event.wait() + subscriptions_after = await client.list_subscriptions() + + LOGGER.info("Active subscriptions: %s", subscriptions_after) -# await c.ping() -# print("Ping OK") -# await c.subscribe(TradesParams(market="BTC-USD"), on_trade) -# await c.subscribe(TradesParams(market="ETH-USD"), on_trade) -# subs = await c.list_subscriptions() -# print(f"Active subscriptions: {subs}") -# await asyncio.sleep(10) -# await c.unsubscribe(TradesParams(market="BTC-USD").topic_id) -# await c.unsubscribe(TradesParams(market="ETH-USD").topic_id) -# print("Unsubscribed from trades") + await stop_event.wait() async def run_example(): diff --git a/tests/clients/test_streamrpc_subscription_params.py b/tests/clients/test_streamrpc_subscription_params.py deleted file mode 100644 index 59fcd76f..00000000 --- a/tests/clients/test_streamrpc_subscription_params.py +++ /dev/null @@ -1,3 +0,0 @@ -def test_streamrpc_subscription_params(): - # FIXME: Add tests - pass diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index bde9608e..769bb0a6 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -4,13 +4,14 @@ from typing import Any, Callable, Coroutine, TypeVar import websockets -from clients.streamrpc.streamrpc_dispatcher import ( +from websockets import ConnectionClosed + +from x10.clients.streamrpc.streamrpc_dispatcher import ( OnSequenceBreakCallback, PendingRequestsMap, + RequestId, + StreamRpcDispatcher, ) -from websockets import ConnectionClosed - -from x10.clients.streamrpc.streamrpc_dispatcher import RequestId, StreamRpcDispatcher from x10.clients.streamrpc.subscription_params import ( StreamMessageHandler, SubscribeParams, From 47b9019d9de5b5856ad49c38f345036f145d0a35 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 18:06:16 +0400 Subject: [PATCH 32/41] Add stream RPC client --- tests/clients/test_streamrpc_client.py | 87 ++++++++++++++++++++++- tests/fixtures/candle.py | 20 ++++++ x10/clients/streamrpc/streamrpc_client.py | 2 + 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/tests/clients/test_streamrpc_client.py b/tests/clients/test_streamrpc_client.py index bdefba21..c15ac53e 100644 --- a/tests/clients/test_streamrpc_client.py +++ b/tests/clients/test_streamrpc_client.py @@ -1,3 +1,84 @@ -def test_streamrpc_client(): - # FIXME: Add tests - pass +import asyncio +import json + +import pytest +import websockets +from hamcrest import assert_that, equal_to +from websockets import WebSocketServer + + +def get_url_from_server(server: WebSocketServer): + host, port = server.sockets[0].getsockname() # type: ignore[index] + return f"ws://{host}:{port}" + + +@pytest.mark.asyncio +async def test_candle_stream(): + from tests.fixtures.candle import create_candle_stream_rpc_message + from x10.clients.streamrpc.streamrpc_client import StreamRpcClient + from x10.clients.streamrpc.subscription_params import CandlesParams + + message_model = create_candle_stream_rpc_message() + received_messages: asyncio.Queue = asyncio.Queue() + + async def subscription_handler(msg): + await received_messages.put(msg) + + async def mock_server(websocket): + subscribe_msg_raw = await websocket.recv() + subscribe_msg = json.loads(subscribe_msg_raw) + + await websocket.send( + json.dumps( + { + "id": subscribe_msg["id"], + "result": {"subscription": message_model.subscription}, + } + ) + ) + + # Wait for the client's `subscribe` coroutine to register the + # subscription before the first stream message arrives. + await asyncio.sleep(0.1) + + await websocket.send(json.dumps(message_model.to_api_request_json())) + + unsubscribe_msg_raw = await websocket.recv() + unsubscribe_msg = json.loads(unsubscribe_msg_raw) + + await websocket.send( + json.dumps( + { + "id": unsubscribe_msg["id"], + "result": {"method": "unsubscribe", "status": "OK"}, + } + ) + ) + + async with websockets.serve(mock_server, "127.0.0.1", 0) as server: + client = StreamRpcClient(api_url=get_url_from_server(server)) + await client.connect() + + subscription_params = CandlesParams(candle_type="last", market="BTC-USD", interval="PT1M") + subscription_id = await client.subscribe(params=subscription_params, handler=subscription_handler) + + msg = await asyncio.wait_for(received_messages.get(), timeout=5) + + await client.unsubscribe(subscription_id) + await client.close() + + assert_that( + msg.to_api_request_json(), + equal_to( + { + "type": "CANDLE", + "data": [ + {"o": "3458.64", "l": "3399.07", "h": "3476.89", "c": "3414.85", "v": "3.938", "T": 1721106000000} + ], + "error": None, + "ts": 1721283121979, + "seq": 1, + "subscription": "candles.last.BTC-USD.PT1M", + } + ), + ) diff --git a/tests/fixtures/candle.py b/tests/fixtures/candle.py index 39a538fc..20e12b08 100644 --- a/tests/fixtures/candle.py +++ b/tests/fixtures/candle.py @@ -3,6 +3,7 @@ from x10.models.candle import CandleModel from x10.models.http import WrappedStreamResponseModel +from x10.models.stream_rpc import StreamRpcResponseModel def create_candle_stream_message(): @@ -20,3 +21,22 @@ def create_candle_stream_message(): ts=1721283121979, seq=1, ) + + +def create_candle_stream_rpc_message(): + return StreamRpcResponseModel( + type="CANDLE", + data=[ + CandleModel( + open=Decimal("3458.64"), + low=Decimal("3399.07"), + high=Decimal("3476.89"), + close=Decimal("3414.85"), + volume=Decimal("3.938"), + timestamp=1721106000000, + ) + ], + ts=1721283121979, + seq=1, + subscription="candles.last.BTC-USD.PT1M", + ) diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index 769bb0a6..3cf82419 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -31,6 +31,8 @@ class StreamRpcClient: """ + EXPERIMENTAL! NOT TO BE USED IN PRODUCTION! TO BE IMPROVED IN THE UPCOMING VERSIONS. + X10 WebSocket RPC client. Implements the JSON-RPC 2.0 like protocol over a WebSocket connection. From 09ca323ef1db437ae7770dec93fa90815f58ca0d Mon Sep 17 00:00:00 2001 From: alex101xela Date: Mon, 29 Jun 2026 18:11:17 +0400 Subject: [PATCH 33/41] Add stream RPC client --- x10/clients/streamrpc/streamrpc_client.py | 10 +++++++--- x10/clients/streamrpc/streamrpc_dispatcher.py | 6 +++++- x10/models/stream_rpc.py | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index 3cf82419..761f8a9a 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -45,6 +45,10 @@ class StreamRpcClient: messages were dropped. """ + _ws: websockets.WebSocketClientProtocol | None + _ready: asyncio.Event + _connection_loop_task: asyncio.Task[None] | None + async def connect(self): """ Starts the client's connection management loop and waits for the first connection to be established. @@ -167,15 +171,15 @@ def __init__( self._on_sequence_break = on_sequence_break self._request_timeout = 10 - self._reconnect_initial_delay = 1 + self._reconnect_initial_delay = 1.0 self._reconnect_max_delay = 10 self._is_stopped = False self._next_request_id = 0 - self._ws: websockets.WebSocketClientProtocol | None = None + self._ws = None # Fires when a connection is established (and re-subscription is done). self._ready = asyncio.Event() - self._connection_loop_task: asyncio.Task[None] | None = None + self._connection_loop_task = None # Pending RPC requests (as futures) keyed by request id. self._pending_requests: PendingRequestsMap = {} # Active subscriptions keyed by topic id. diff --git a/x10/clients/streamrpc/streamrpc_dispatcher.py b/x10/clients/streamrpc/streamrpc_dispatcher.py index 484a7398..26cb9534 100644 --- a/x10/clients/streamrpc/streamrpc_dispatcher.py +++ b/x10/clients/streamrpc/streamrpc_dispatcher.py @@ -96,7 +96,11 @@ async def _dispatch_message(self, msg: dict[str, Any], subscription_id: str) -> if self._on_sequence_break: try: - result = await self._on_sequence_break(subscription_id, self._last_seq, msg_seq) + result = await self._on_sequence_break( + subscription_id, + self._last_seq, + msg_seq, + ) # type: ignore[func-returns-value] if asyncio.iscoroutine(result): await result diff --git a/x10/models/stream_rpc.py b/x10/models/stream_rpc.py index 6e661aab..ac645b6c 100644 --- a/x10/models/stream_rpc.py +++ b/x10/models/stream_rpc.py @@ -18,7 +18,7 @@ class StreamRpcResponseModel(X10BaseModel, Generic[T]): type: str data: T - error: str = None + error: str | None = None ts: int seq: int subscription: str From fb72667a98b935b905988f671b88a1cd4ea38ba6 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Tue, 30 Jun 2026 12:11:19 +0400 Subject: [PATCH 34/41] Add stream RPC client --- tests/clients/test_streamrpc_client.py | 8 ++++---- x10/clients/streamrpc/streamrpc_client.py | 23 ++++++++++++++++------- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/tests/clients/test_streamrpc_client.py b/tests/clients/test_streamrpc_client.py index c15ac53e..44790b68 100644 --- a/tests/clients/test_streamrpc_client.py +++ b/tests/clients/test_streamrpc_client.py @@ -28,6 +28,8 @@ async def mock_server(websocket): subscribe_msg_raw = await websocket.recv() subscribe_msg = json.loads(subscribe_msg_raw) + assert_that(subscribe_msg["method"], equal_to("subscribe")) + await websocket.send( json.dumps( { @@ -37,15 +39,13 @@ async def mock_server(websocket): ) ) - # Wait for the client's `subscribe` coroutine to register the - # subscription before the first stream message arrives. - await asyncio.sleep(0.1) - await websocket.send(json.dumps(message_model.to_api_request_json())) unsubscribe_msg_raw = await websocket.recv() unsubscribe_msg = json.loads(unsubscribe_msg_raw) + assert_that(unsubscribe_msg["method"], equal_to("unsubscribe")) + await websocket.send( json.dumps( { diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index 761f8a9a..bcd761fb 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -130,13 +130,17 @@ async def subscribe(self, *, params: SubscribeParams[T], handler: StreamMessageH await self._ready.wait() - result = await self._rpc("subscribe", params=params.to_dict()) - topic_id: TopicId = result["subscription"] - self._subscriptions[topic_id] = TopicSubscription(params=params, handler=handler) + try: + self._subscriptions[params.topic_id] = TopicSubscription(params=params, handler=handler) + await self._rpc("subscribe", params=params.to_dict()) + except Exception: + LOGGER.error("Failed to subscribe to %s", params.topic_id) + self._subscriptions.pop(params.topic_id, None) + raise - LOGGER.debug("Subscribed to %s", topic_id) + LOGGER.debug("Subscribed to %s", params.topic_id) - return topic_id + return params.topic_id async def unsubscribe(self, topic_id: TopicId): """ @@ -152,8 +156,13 @@ async def unsubscribe(self, topic_id: TopicId): raise StreamRpcError(f"No active subscription: {topic_id}") await self._ready.wait() - await self._rpc("unsubscribe", params=subscription.params.to_dict()) - self._subscriptions.pop(topic_id, None) + + try: + self._subscriptions.pop(topic_id, None) + await self._rpc("unsubscribe", params=subscription.params.to_dict()) + except Exception: + LOGGER.error("Failed to unsubscribe from %s", topic_id) + raise LOGGER.debug("Unsubscribed from %s", topic_id) From 787aa8d849e049d2d2e369faaa7638acbb523ff0 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Tue, 30 Jun 2026 12:38:11 +0400 Subject: [PATCH 35/41] Add stream RPC client --- .../cases/stream/subscribe_to_rpc_stream.py | 3 +++ x10/clients/streamrpc/streamrpc_client.py | 17 +++++------------ x10/clients/streamrpc/streamrpc_dispatcher.py | 19 ++++++++----------- x10/clients/streamrpc/subscription_params.py | 2 +- x10/models/stream_rpc.py | 15 +++++++-------- 5 files changed, 24 insertions(+), 32 deletions(-) diff --git a/examples/cases/stream/subscribe_to_rpc_stream.py b/examples/cases/stream/subscribe_to_rpc_stream.py index 300a68b4..572fc327 100644 --- a/examples/cases/stream/subscribe_to_rpc_stream.py +++ b/examples/cases/stream/subscribe_to_rpc_stream.py @@ -40,6 +40,9 @@ async def subscribe_to_rpc_stream(stop_event: asyncio.Event): subscriptions_after = await client.list_subscriptions() + # FIXME + await client._resubscribe() + LOGGER.info("Active subscriptions: %s", subscriptions_after) await stop_event.wait() diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index bcd761fb..6aaad0b1 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -36,7 +36,7 @@ class StreamRpcClient: X10 WebSocket RPC client. Implements the JSON-RPC 2.0 like protocol over a WebSocket connection. - Supports automatic reconnection and transparent re-subscription after connection loss. + Supports automatic reconnection and transparent resubscription after connection loss. :param api_url: Full WebSocket URL. :param on_reconnect: Optional async callback invoked after a successful reconnection. @@ -186,7 +186,7 @@ def __init__( self._next_request_id = 0 self._ws = None - # Fires when a connection is established (and re-subscription is done). + # Fires when a connection is established (and resubscription is done). self._ready = asyncio.Event() self._connection_loop_task = None # Pending RPC requests (as futures) keyed by request id. @@ -342,17 +342,10 @@ async def _resubscribe(self) -> None: if self._ws is None or not self._subscriptions: return - LOGGER.debug("Resubscribing to %d topic(s)…", len(self._subscriptions)) + LOGGER.debug("Resubscribing to topic(s): %s", ", ".join(list(self._subscriptions.keys()))) - for topic_id, subscription in list(self._subscriptions.items()): - request_id = self._get_next_request_id() - request = { - "method": "subscribe", - "id": request_id, - "jsonrpc": "2.0", - "params": subscription.params.to_dict(), - } + for topic_id, subscription in self._subscriptions.items(): try: - await self._ws.send(json.dumps(request)) + await self._rpc("subscribe", params=subscription.params.to_dict()) except Exception: LOGGER.exception("Failed to resubscribe to %s", topic_id) diff --git a/x10/clients/streamrpc/streamrpc_dispatcher.py b/x10/clients/streamrpc/streamrpc_dispatcher.py index 26cb9534..b8258d4b 100644 --- a/x10/clients/streamrpc/streamrpc_dispatcher.py +++ b/x10/clients/streamrpc/streamrpc_dispatcher.py @@ -96,14 +96,10 @@ async def _dispatch_message(self, msg: dict[str, Any], subscription_id: str) -> if self._on_sequence_break: try: - result = await self._on_sequence_break( - subscription_id, - self._last_seq, - msg_seq, - ) # type: ignore[func-returns-value] - - if asyncio.iscoroutine(result): - await result + sequence_break_result = self._on_sequence_break(subscription_id, self._last_seq, msg_seq) + + if asyncio.iscoroutine(sequence_break_result): + await sequence_break_result except Exception: LOGGER.exception("Unhandled exception in `on_sequence_break` callback") @@ -127,15 +123,16 @@ async def _dispatch_message(self, msg: dict[str, Any], subscription_id: str) -> enveloped_data = StreamRpcResponseModel( type=msg_type, data=deserialized_data, + error=msg.get("error"), ts=msg["ts"], seq=msg_seq, subscription=subscription_id, ) try: - result = subscription.handler(enveloped_data) + subscription_handler_result = subscription.handler(enveloped_data) - if asyncio.iscoroutine(result): - await result + if asyncio.iscoroutine(subscription_handler_result): + await subscription_handler_result except Exception: LOGGER.exception("Unhandled exception in handler for subscription %s", subscription_id) diff --git a/x10/clients/streamrpc/subscription_params.py b/x10/clients/streamrpc/subscription_params.py index c59d43cd..262784d9 100644 --- a/x10/clients/streamrpc/subscription_params.py +++ b/x10/clients/streamrpc/subscription_params.py @@ -198,7 +198,7 @@ def to_dict(self) -> dict[str, Any]: "selector": {"type": self.candle_type, "market": self.market, "interval": self.interval}, } - def deserialize_data(self, data: dict[str, Any], msg_type: str) -> list[CandleModel]: + def deserialize_data(self, data: list[dict[str, Any]], msg_type: str) -> list[CandleModel]: return [CandleModel.model_validate(item) for item in data] diff --git a/x10/models/stream_rpc.py b/x10/models/stream_rpc.py index ac645b6c..9a86363d 100644 --- a/x10/models/stream_rpc.py +++ b/x10/models/stream_rpc.py @@ -25,7 +25,6 @@ class StreamRpcResponseModel(X10BaseModel, Generic[T]): class StreamRpcOrderbookUpdateModel(OrderbookUpdateModel): - type: str = Field(validation_alias=AliasChoices("market", "m"), serialization_alias="m") depth: str = Field(validation_alias=AliasChoices("depth", "d"), serialization_alias="d") @@ -36,35 +35,35 @@ class StreamRpcPriceModel(X10BaseModel): class StreamRpcAccountPositionsModel(X10BaseModel): - isSnapshot: bool + is_snapshot: bool positions: list[PositionModel] class StreamRpcAccountOrdersModel(X10BaseModel): - isSnapshot: bool + is_snapshot: bool orders: list[OpenOrderModel] class StreamRpcAccountTradesModel(X10BaseModel): - isSnapshot: bool + is_snapshot: bool trades: list[AccountTradeModel] class StreamRpcAccountBalanceModel(X10BaseModel): - isSnapshot: bool + is_snapshot: bool balance: BalanceModel class StreamRpcAccountSpotBalancesModel(X10BaseModel): - isSnapshot: bool + is_snapshot: bool spotBalances: list[SpotBalanceModel] class StreamRpcAccountDepositUpdateModel(X10BaseModel): - isSnapshot: bool + is_snapshot: bool deposit: DepositStatusUpdateModel class StreamRpcAccountWithdrawalUpdateModel(X10BaseModel): - isSnapshot: bool + is_snapshot: bool withdrawal: WithdrawalStatusUpdateModel From 4078e53a26ea068690c4de9aa8035ece02dcc865 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Tue, 30 Jun 2026 12:38:21 +0400 Subject: [PATCH 36/41] Add stream RPC client --- examples/cases/stream/subscribe_to_rpc_stream.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/examples/cases/stream/subscribe_to_rpc_stream.py b/examples/cases/stream/subscribe_to_rpc_stream.py index 572fc327..300a68b4 100644 --- a/examples/cases/stream/subscribe_to_rpc_stream.py +++ b/examples/cases/stream/subscribe_to_rpc_stream.py @@ -40,9 +40,6 @@ async def subscribe_to_rpc_stream(stop_event: asyncio.Event): subscriptions_after = await client.list_subscriptions() - # FIXME - await client._resubscribe() - LOGGER.info("Active subscriptions: %s", subscriptions_after) await stop_event.wait() From 91afaeeb75eaf9a39a81cc3f79b5a9af45d74f91 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Tue, 30 Jun 2026 12:43:51 +0400 Subject: [PATCH 37/41] Add stream RPC client --- x10/clients/streamrpc/subscription_params.py | 1 - 1 file changed, 1 deletion(-) diff --git a/x10/clients/streamrpc/subscription_params.py b/x10/clients/streamrpc/subscription_params.py index 262784d9..1db682d1 100644 --- a/x10/clients/streamrpc/subscription_params.py +++ b/x10/clients/streamrpc/subscription_params.py @@ -137,7 +137,6 @@ def topic_id(self) -> str: def to_dict(self) -> dict[str, Any]: return {"scope": "funding-rates", "selector": {"market": self.market}} - # FIXME: Remove `None` from `msg_type` def deserialize_data(self, data: dict[str, Any], msg_type: str) -> FundingRateModel: return FundingRateModel.model_validate(data) From d992741de110a3f0b866d808e40a34008c7beb8a Mon Sep 17 00:00:00 2001 From: alex101xela Date: Tue, 30 Jun 2026 12:46:44 +0400 Subject: [PATCH 38/41] Add stream RPC client --- x10/clients/streamrpc/streamrpc_client.py | 2 -- x10/models/stream_rpc.py | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index 6aaad0b1..510fe65a 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -170,12 +170,10 @@ def __init__( self, *, api_url: str, - api_key: str | None = None, on_reconnect: OnReconnectCallback | None = None, on_sequence_break: OnSequenceBreakCallback | None = None, ): self._api_url = api_url - self._api_key = api_key self._on_reconnect = on_reconnect self._on_sequence_break = on_sequence_break diff --git a/x10/models/stream_rpc.py b/x10/models/stream_rpc.py index 9a86363d..527c2c15 100644 --- a/x10/models/stream_rpc.py +++ b/x10/models/stream_rpc.py @@ -56,7 +56,7 @@ class StreamRpcAccountBalanceModel(X10BaseModel): class StreamRpcAccountSpotBalancesModel(X10BaseModel): is_snapshot: bool - spotBalances: list[SpotBalanceModel] + spot_balances: list[SpotBalanceModel] class StreamRpcAccountDepositUpdateModel(X10BaseModel): From c8bda94bc74d53d97cddf1396b0c3fd5b8833606 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Tue, 30 Jun 2026 13:01:11 +0400 Subject: [PATCH 39/41] Add stream RPC client --- x10/clients/streamrpc/streamrpc_client.py | 2 +- x10/clients/streamrpc/streamrpc_dispatcher.py | 36 ++++++++----------- 2 files changed, 16 insertions(+), 22 deletions(-) diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index 510fe65a..10f09ce6 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -52,7 +52,7 @@ class StreamRpcClient: async def connect(self): """ Starts the client's connection management loop and waits for the first connection to be established. - :raises StreamRpcConnectionError: If the initial connection fails (reconnect is not attempted). + :raises StreamRpcConnectionError: If the initial connection is not established before the connect timeout. """ if self._connection_loop_task is not None: diff --git a/x10/clients/streamrpc/streamrpc_dispatcher.py b/x10/clients/streamrpc/streamrpc_dispatcher.py index b8258d4b..02a3eba8 100644 --- a/x10/clients/streamrpc/streamrpc_dispatcher.py +++ b/x10/clients/streamrpc/streamrpc_dispatcher.py @@ -48,7 +48,7 @@ def dispatch_raw(self, raw: str) -> None: if request_id is not None: request_result = self._pending_requests.get(str(request_id)) - if not request_result: + if request_result is None: LOGGER.warning("Received response for unknown request id=%s", request_id) return @@ -84,53 +84,47 @@ async def _dispatch_message(self, msg: dict[str, Any], subscription_id: str) -> LOGGER.warning("Received message for unknown subscription id=%s", subscription_id) return - msg_seq = msg["seq"] + try: + msg_model: StreamRpcResponseModel[Any] = StreamRpcResponseModel.model_validate(msg) + except Exception as exc: + LOGGER.exception("Failed to validate message for subscription %s: %s", subscription_id, exc) + return - if self._last_seq is not None and msg_seq != self._last_seq + 1: + if self._last_seq is not None and msg_model.seq != self._last_seq + 1: LOGGER.warning( "Sequence break detected for subscription %s: last_seq=%s, msg_seq=%s", subscription_id, self._last_seq, - msg_seq, + msg_model.seq, ) if self._on_sequence_break: try: - sequence_break_result = self._on_sequence_break(subscription_id, self._last_seq, msg_seq) + sequence_break_result = self._on_sequence_break(subscription_id, self._last_seq, msg_model.seq) if asyncio.iscoroutine(sequence_break_result): await sequence_break_result except Exception: LOGGER.exception("Unhandled exception in `on_sequence_break` callback") - self._last_seq = msg_seq - - msg_data = msg["data"] - msg_type = msg["type"] + self._last_seq = msg_model.seq try: - deserialized_data = subscription.params.deserialize_data(msg_data, msg_type) + deserialized_data = subscription.params.deserialize_data(msg_model.data, msg_model.type) except Exception as exc: LOGGER.exception( "Failed to deserialize message for subscription %s (type=%s, seq=%s): %s", subscription_id, - msg_type, - msg_seq, + msg_model.type, + msg_model.seq, exc, ) return - enveloped_data = StreamRpcResponseModel( - type=msg_type, - data=deserialized_data, - error=msg.get("error"), - ts=msg["ts"], - seq=msg_seq, - subscription=subscription_id, - ) + msg_model_with_deserialized_data = msg_model.model_copy(update={"data": deserialized_data}) try: - subscription_handler_result = subscription.handler(enveloped_data) + subscription_handler_result = subscription.handler(msg_model_with_deserialized_data) if asyncio.iscoroutine(subscription_handler_result): await subscription_handler_result From 9b63f319860fd449232d740437acff0ec1c6bc69 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Tue, 30 Jun 2026 13:06:22 +0400 Subject: [PATCH 40/41] Add stream RPC client --- x10/clients/streamrpc/streamrpc_dispatcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/x10/clients/streamrpc/streamrpc_dispatcher.py b/x10/clients/streamrpc/streamrpc_dispatcher.py index 02a3eba8..5881fd7b 100644 --- a/x10/clients/streamrpc/streamrpc_dispatcher.py +++ b/x10/clients/streamrpc/streamrpc_dispatcher.py @@ -22,7 +22,7 @@ def __init__( subscriptions: dict[TopicId, TopicSubscription], on_sequence_break: OnSequenceBreakCallback | None = None, ) -> None: - # Last observed connection-level sequence (reset to `None` on each re-connect). + # Last observed connection-level sequence (reset to `None` on each reconnect). self._last_seq: int | None = None self._pending_requests = pending_requests self._subscriptions = subscriptions From b17c01da3b1a823d6a051bd9ba8d340a4337a320 Mon Sep 17 00:00:00 2001 From: alex101xela Date: Tue, 30 Jun 2026 13:12:59 +0400 Subject: [PATCH 41/41] Add stream RPC client --- tests/clients/test_streamrpc_client.py | 2 +- tests/fixtures/candle.py | 2 +- x10/clients/streamrpc/streamrpc_client.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/clients/test_streamrpc_client.py b/tests/clients/test_streamrpc_client.py index 44790b68..c53020a8 100644 --- a/tests/clients/test_streamrpc_client.py +++ b/tests/clients/test_streamrpc_client.py @@ -71,7 +71,7 @@ async def mock_server(websocket): msg.to_api_request_json(), equal_to( { - "type": "CANDLE", + "type": "CANDLES", "data": [ {"o": "3458.64", "l": "3399.07", "h": "3476.89", "c": "3414.85", "v": "3.938", "T": 1721106000000} ], diff --git a/tests/fixtures/candle.py b/tests/fixtures/candle.py index 20e12b08..639f8b10 100644 --- a/tests/fixtures/candle.py +++ b/tests/fixtures/candle.py @@ -25,7 +25,7 @@ def create_candle_stream_message(): def create_candle_stream_rpc_message(): return StreamRpcResponseModel( - type="CANDLE", + type="CANDLES", data=[ CandleModel( open=Decimal("3458.64"), diff --git a/x10/clients/streamrpc/streamrpc_client.py b/x10/clients/streamrpc/streamrpc_client.py index 10f09ce6..7abd2ca8 100644 --- a/x10/clients/streamrpc/streamrpc_client.py +++ b/x10/clients/streamrpc/streamrpc_client.py @@ -342,8 +342,9 @@ async def _resubscribe(self) -> None: LOGGER.debug("Resubscribing to topic(s): %s", ", ".join(list(self._subscriptions.keys()))) - for topic_id, subscription in self._subscriptions.items(): + for topic_id, subscription in list(self._subscriptions.items()): try: await self._rpc("subscribe", params=subscription.params.to_dict()) except Exception: LOGGER.exception("Failed to resubscribe to %s", topic_id) + self._subscriptions.pop(topic_id, None)