From d52827f84014ccc08938eb166cc04400a186ac35 Mon Sep 17 00:00:00 2001 From: bvvvp009 Date: Sat, 12 Sep 2026 12:18:32 +0530 Subject: [PATCH] fix: resolve 7 open SDK issues (#15, #21, #35, #36, #87, #123, #131) * ws_client: ignore non-dict JSON messages to fix crash on heartbeats (#123) * models: allow null margin fractions in Trade (#15) * models: allow null pool metrics in PublicPoolInfo (#35) * setup: relax urllib3 cap to < 3 (#21) * signer_client: add slippage-protected stop-loss and take-profit orders (#36) * signer_client: warn when order_expiry is under 4 minutes away (#87) * signer_client: add parse_send_tx_response helper (#131) Signed-off-by: bvvvp009 --- lighter/models/public_pool_info.py | 12 +-- lighter/models/trade.py | 4 +- lighter/signer_client.py | 145 +++++++++++++++++++++++++++++ lighter/ws_client.py | 8 +- setup.py | 2 +- 5 files changed, 161 insertions(+), 10 deletions(-) diff --git a/lighter/models/public_pool_info.py b/lighter/models/public_pool_info.py index d7587cd..ecfb671 100644 --- a/lighter/models/public_pool_info.py +++ b/lighter/models/public_pool_info.py @@ -18,7 +18,7 @@ import json from pydantic import BaseModel, ConfigDict, StrictFloat, StrictInt, StrictStr -from typing import Any, ClassVar, Dict, List, Union +from typing import Any, ClassVar, Dict, List, Optional, Union from lighter.models.daily_return import DailyReturn from lighter.models.share_price import SharePrice from lighter.models.strategy import Strategy @@ -34,11 +34,11 @@ class PublicPoolInfo(BaseModel): min_operator_share_rate: StrictStr total_shares: StrictInt operator_shares: StrictInt - annual_percentage_yield: Union[StrictFloat, StrictInt] - daily_returns: List[DailyReturn] - share_prices: List[SharePrice] - sharpe_ratio: Union[StrictFloat, StrictInt] - strategies: List[Strategy] + annual_percentage_yield: Optional[Union[StrictFloat, StrictInt]] = None + daily_returns: Optional[List[DailyReturn]] = None + share_prices: Optional[List[SharePrice]] = None + sharpe_ratio: Optional[Union[StrictFloat, StrictInt]] = None + strategies: Optional[List[Strategy]] = None additional_properties: Dict[str, Any] = {} __properties: ClassVar[List[str]] = ["status", "operator_fee", "min_operator_share_rate", "total_shares", "operator_shares", "annual_percentage_yield", "daily_returns", "share_prices", "sharpe_ratio", "strategies"] diff --git a/lighter/models/trade.py b/lighter/models/trade.py index 04ee96b..3e63999 100644 --- a/lighter/models/trade.py +++ b/lighter/models/trade.py @@ -43,12 +43,12 @@ class Trade(BaseModel): taker_fee: Optional[StrictInt] = None taker_position_size_before: StrictStr taker_entry_quote_before: StrictStr - taker_initial_margin_fraction_before: StrictInt + taker_initial_margin_fraction_before: Optional[StrictInt] = None taker_position_sign_changed: StrictBool maker_fee: Optional[StrictInt] = None maker_position_size_before: StrictStr maker_entry_quote_before: StrictStr - maker_initial_margin_fraction_before: StrictInt + maker_initial_margin_fraction_before: Optional[StrictInt] = None maker_position_sign_changed: StrictBool transaction_time: StrictInt bid_account_pnl: StrictStr = Field(description="Realized PnL for the queried account index, triggered by reducing a short position") diff --git a/lighter/signer_client.py b/lighter/signer_client.py index c8e83ab..e032b4c 100644 --- a/lighter/signer_client.py +++ b/lighter/signer_client.py @@ -289,6 +289,7 @@ class SignerClient: DEFAULT_IOC_EXPIRY = 0 DEFAULT_10_MIN_AUTH_EXPIRY = -1 MINUTE = 60 + MIN_ORDER_EXPIRY_MS = 4 * MINUTE * 1000 CROSS_MARGIN_MODE = 0 ISOLATED_MARGIN_MODE = 1 @@ -689,6 +690,14 @@ async def create_order( nonce: int = DEFAULT_NONCE, api_key_index: int = DEFAULT_API_KEY_INDEX ) -> Union[Tuple[CreateOrder, RespSendTx, None], Tuple[None, None, str]]: + # order_expiry is an absolute unix timestamp in milliseconds; the server + # rejects GTT orders expiring less than 4 minutes from now with + # code=21711 "invalid expiry" + if order_expiry > 0 and order_expiry - int(time.time() * 1000) < self.MIN_ORDER_EXPIRY_MS: + logging.warning( + f"order_expiry={order_expiry} is less than {self.MIN_ORDER_EXPIRY_MS} milliseconds " + f"in the future; the server will reject the order with an invalid expiry error" + ) tx_type, tx_info, tx_hash, error = self.sign_create_order( market_index, client_order_index, @@ -1127,6 +1136,112 @@ async def create_sl_limit_order( api_key_index=api_key_index, ) + # will only place the stop-loss order if it can execute with slippage <= max_slippage + async def create_sl_order_if_slippage( + self, + market_index, + client_order_index, + base_amount, + trigger_price, + is_ask, + max_slippage, + reduce_only=False, + *, + integrator_account_index: int = 0, + integrator_taker_fee: int = 0, + integrator_maker_fee: int = 0, + ideal_price=None, + skip_nonce: int = SKIP_NONCE_OFF, + nonce: int = DEFAULT_NONCE, + api_key_index: int = DEFAULT_API_KEY_INDEX + ) -> Union[Tuple[CreateOrder, RespSendTx, None], Tuple[None, None, str]]: + ob_orders = await self.order_api.order_book_orders(market_index, 100) + if ideal_price is None: + ideal_price = await self.get_best_price(market_index, is_ask, ob_orders) + potential_execution_price, matched_size = await self.get_potential_execution_price( + market_index, + base_amount, + is_ask, + is_amount_base=True, + ob_orders=ob_orders + ) + + acceptable_execution_price = ideal_price * (1 + max_slippage * (-1 if is_ask else 1)) + if (is_ask and potential_execution_price < acceptable_execution_price) or (not is_ask and potential_execution_price > acceptable_execution_price): + return None, None, "Excessive slippage" + + if matched_size < base_amount: + return None, None, "Cannot be sure slippage will be acceptable due to the high size" + + return await self.create_sl_order( + market_index, + client_order_index, + base_amount, + trigger_price, + round(acceptable_execution_price), + is_ask, + reduce_only, + integrator_account_index=integrator_account_index, + integrator_taker_fee=integrator_taker_fee, + integrator_maker_fee=integrator_maker_fee, + skip_nonce=skip_nonce, + nonce=nonce, + api_key_index=api_key_index, + ) + + # will only place the take-profit order if it can execute with slippage <= max_slippage + async def create_tp_order_if_slippage( + self, + market_index, + client_order_index, + base_amount, + trigger_price, + is_ask, + max_slippage, + reduce_only=False, + *, + integrator_account_index: int = 0, + integrator_taker_fee: int = 0, + integrator_maker_fee: int = 0, + ideal_price=None, + skip_nonce: int = SKIP_NONCE_OFF, + nonce: int = DEFAULT_NONCE, + api_key_index: int = DEFAULT_API_KEY_INDEX + ) -> Union[Tuple[CreateOrder, RespSendTx, None], Tuple[None, None, str]]: + ob_orders = await self.order_api.order_book_orders(market_index, 100) + if ideal_price is None: + ideal_price = await self.get_best_price(market_index, is_ask, ob_orders) + potential_execution_price, matched_size = await self.get_potential_execution_price( + market_index, + base_amount, + is_ask, + is_amount_base=True, + ob_orders=ob_orders + ) + + acceptable_execution_price = ideal_price * (1 + max_slippage * (-1 if is_ask else 1)) + if (is_ask and potential_execution_price < acceptable_execution_price) or (not is_ask and potential_execution_price > acceptable_execution_price): + return None, None, "Excessive slippage" + + if matched_size < base_amount: + return None, None, "Cannot be sure slippage will be acceptable due to the high size" + + return await self.create_tp_order( + market_index, + client_order_index, + base_amount, + trigger_price, + round(acceptable_execution_price), + is_ask, + reduce_only, + integrator_account_index=integrator_account_index, + integrator_taker_fee=integrator_taker_fee, + integrator_maker_fee=integrator_maker_fee, + skip_nonce=skip_nonce, + nonce=nonce, + api_key_index=api_key_index, + ) + @process_api_key_and_nonce async def withdraw(self, asset_id: int, route_type: int, amount: float, skip_nonce : int = SKIP_NONCE_OFF, nonce: int = DEFAULT_NONCE, api_key_index: int = DEFAULT_API_KEY_INDEX) -> Union[Tuple[Withdraw, RespSendTx, None], Tuple[None, None, str]]: if asset_id in self.ASSET_TO_TICKER_SCALE: @@ -1395,6 +1510,36 @@ async def update_account_asset_config(self, asset_index: int, asset_margin_mode: return tx_info, api_response, None + # parses a send_tx response: code=200 with an empty message means success. + # A code=200 message can hold either informational volume quota status + # (the "ratelimit" key, which the server also attaches to accepted orders) + # or a rejection reason ("error", "reason", "cancel_reason"). Orders that + # are accepted but never fill (e.g. triggered SL/TP failing on margin) are + # only visible post-hoc via account_inactive_orders statuses; the send_tx + # response itself carries no reason for them. + @staticmethod + def parse_send_tx_response(api_response: RespSendTx) -> Tuple[bool, Optional[str]]: + if api_response is None: + return False, "No response from API" + if api_response.code != CODE_OK: + return False, api_response.message + if not api_response.message: + return True, None + + try: + msg_data = json.loads(api_response.message) + except (json.JSONDecodeError, TypeError): + return False, api_response.message + + if isinstance(msg_data, dict): + for key in ("error", "reason", "cancel_reason"): + if msg_data.get(key): + return False, f"{key}: {msg_data[key]}" + if "ratelimit" in msg_data: + # informational volume quota status; the order itself was accepted + return True, f"ratelimit: {msg_data['ratelimit']}" + return False, api_response.message + async def send_tx(self, tx_type: StrictInt, tx_info: str) -> RespSendTx: if tx_info[0] != "{": raise Exception(tx_info) diff --git a/lighter/ws_client.py b/lighter/ws_client.py index 14abdf5..83def41 100644 --- a/lighter/ws_client.py +++ b/lighter/ws_client.py @@ -42,6 +42,9 @@ def on_message(self, ws, message): if isinstance(message, str): message = json.loads(message) + if not isinstance(message, dict): + return + message_type = message.get("type") if message_type == "connected": @@ -61,7 +64,10 @@ def on_message(self, ws, message): self.handle_unhandled_message(message) async def on_message_async(self, ws, message): - message = json.loads(message) + if isinstance(message, str): + message = json.loads(message) + if not isinstance(message, dict): + return message_type = message.get("type") if message_type == "connected": diff --git a/setup.py b/setup.py index 8c648d8..b4826e8 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ VERSION = "1.1.2" PYTHON_REQUIRES = ">=3.7" REQUIRES = [ - "urllib3 >= 1.25.3, < 2.1.0", + "urllib3 >= 1.25.3, < 3", "python-dateutil", "aiohttp >= 3.0.0", "aiohttp-retry >= 2.8.3",