diff --git a/lighter/paper_client/client.py b/lighter/paper_client/client.py index 22909c8..40f502c 100644 --- a/lighter/paper_client/client.py +++ b/lighter/paper_client/client.py @@ -1,6 +1,7 @@ import asyncio from threading import RLock from typing import Any, Dict, List, Mapping, Optional +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit from lighter.api.order_api import OrderApi from lighter.api_client import ApiClient @@ -73,14 +74,20 @@ def __init__( self._account_tier = account_tier self.api_client = api_client - self.order_api = order_api if order_api is not None else OrderApi(api_client) + self.order_api = ( + order_api + if order_api is not None + else OrderApi(_ReadOnlyApiClientProxy(api_client)) + ) self.order_book_limit = order_book_limit self.account = new_paper_account(initial_collateral_usdc) self.market_configs: Dict[int, MarketConfig] = {} self.order_books: Dict[int, InMemoryOrderBook] = {} raw_ws_url = ws_url if ws_url is not None else self._default_ws_url(api_client, ws_path) - separator = "&" if "?" in raw_ws_url else "?" - self.ws_url = f"{raw_ws_url}{separator}encoding=json" + self.ws_url = _append_query_params( + raw_ws_url, + [("encoding", "json"), ("readonly", "true")], + ) self.initial_snapshot_timeout = initial_snapshot_timeout self._live_listeners: Dict[int, PaperOrderBookListener] = {} self._state_lock = asyncio.Lock() @@ -353,3 +360,32 @@ def _validate_perp_market_id(market_id: int) -> None: "paper trading only supports perp markets " f"(market_id < 2048), got {market_id}" ) + + +def _with_readonly(query_params): + query_params = list(query_params or []) + if not any(name == "readonly" for name, _ in query_params): + query_params.append(("readonly", "true")) + return query_params + + +def _append_query_params(url: str, query_params) -> str: + parts = urlsplit(url) + params = parse_qsl(parts.query, keep_blank_values=True) + names = {name for name, _ in params} + params.extend((name, value) for name, value in query_params if name not in names) + return urlunsplit(parts._replace(query=urlencode(params))) + + +class _ReadOnlyApiClientProxy: + """Lightweight proxy for ApiClient, ensuring readonly=true to generated REST URLs.""" + + def __init__(self, api_client: Optional[ApiClient]) -> None: + self._api_client = api_client if api_client is not None else ApiClient.get_default() + + def __getattr__(self, name: str) -> Any: + return getattr(self._api_client, name) + + def param_serialize(self, *args, **kwargs): + kwargs["query_params"] = _with_readonly(kwargs.get("query_params")) + return self._api_client.param_serialize(*args, **kwargs) diff --git a/test/paper_client/test_client.py b/test/paper_client/test_client.py index 8fedcfb..8d294f0 100644 --- a/test/paper_client/test_client.py +++ b/test/paper_client/test_client.py @@ -1,5 +1,7 @@ import unittest +from lighter.api_client import ApiClient +from lighter.configuration import Configuration from lighter.paper_client.accounting import apply_fill from lighter.paper_client import ( AccountTier, @@ -13,6 +15,54 @@ class TestPaperClient(unittest.IsolatedAsyncioTestCase): + async def test_paper_client_rest_requests_include_readonly(self) -> None: + api_client = ApiClient(Configuration(host="https://example.test")) + client = PaperClient(api_client, 5000.0) + + try: + _, orders_url, _, _, _ = client.order_api._order_book_orders_serialize( + 0, 100, None, None, None, 0 + ) + _, details_url, _, _, _ = client.order_api._order_book_details_serialize( + 0, None, None, None, None, 0 + ) + finally: + await api_client.close() + + self.assertEqual( + orders_url, + "https://example.test/api/v1/orderBookOrders" + "?market_id=0&limit=100&readonly=true", + ) + self.assertEqual( + details_url, + "https://example.test/api/v1/orderBookDetails" + "?market_id=0&readonly=true", + ) + + def test_paper_client_ws_url_includes_readonly(self) -> None: + client = PaperClient( + None, + 5000.0, + order_api=FakeOrderApi(), + ws_url="wss://example.test/stream", + ) + self.assertEqual( + client.ws_url, + "wss://example.test/stream?encoding=json&readonly=true", + ) + + client = PaperClient( + None, + 5000.0, + order_api=FakeOrderApi(), + ws_url="wss://example.test/stream?readonly=true", + ) + self.assertEqual( + client.ws_url, + "wss://example.test/stream?readonly=true&encoding=json", + ) + async def test_track_market_snapshot_and_market_buy_then_sell(self) -> None: order_api = FakeOrderApi() order_api.books[0] = book(