From c7231a96cc81336fc9f35836f0af9bfd46f8d629 Mon Sep 17 00:00:00 2001 From: mihaimarcu Date: Wed, 19 Aug 2026 21:19:37 +0300 Subject: [PATCH] rem_paper_trading --- examples/README.md | 17 - .../paper-trading/paper_trading_health.py | 168 ------- examples/paper-trading/paper_trading_live.py | 64 --- .../paper-trading/paper_trading_snapshot.py | 55 --- lighter/__init__.py | 19 +- lighter/paper_client/__init__.py | 34 -- lighter/paper_client/accounting.py | 152 ------- lighter/paper_client/client.py | 355 --------------- lighter/paper_client/live.py | 164 ------- lighter/paper_client/matching.py | 79 ---- lighter/paper_client/order_book.py | 153 ------- lighter/paper_client/order_book_depth.py | 123 ------ lighter/paper_client/price_level.py | 100 ----- lighter/paper_client/risk.py | 214 --------- lighter/paper_client/types.py | 158 ------- pyproject.toml | 1 - test/paper_client/__init__.py | 0 test/paper_client/helpers.py | 126 ------ test/paper_client/test_accounting.py | 93 ---- test/paper_client/test_client.py | 250 ----------- test/paper_client/test_live.py | 412 ------------------ test/paper_client/test_matching.py | 77 ---- test/paper_client/test_order_book.py | 304 ------------- test/paper_client/test_risk.py | 168 ------- 24 files changed, 1 insertion(+), 3285 deletions(-) delete mode 100644 examples/paper-trading/paper_trading_health.py delete mode 100644 examples/paper-trading/paper_trading_live.py delete mode 100644 examples/paper-trading/paper_trading_snapshot.py delete mode 100644 lighter/paper_client/__init__.py delete mode 100644 lighter/paper_client/accounting.py delete mode 100644 lighter/paper_client/client.py delete mode 100644 lighter/paper_client/live.py delete mode 100644 lighter/paper_client/matching.py delete mode 100644 lighter/paper_client/order_book.py delete mode 100644 lighter/paper_client/order_book_depth.py delete mode 100644 lighter/paper_client/price_level.py delete mode 100644 lighter/paper_client/risk.py delete mode 100644 lighter/paper_client/types.py delete mode 100644 test/paper_client/__init__.py delete mode 100644 test/paper_client/helpers.py delete mode 100644 test/paper_client/test_accounting.py delete mode 100644 test/paper_client/test_client.py delete mode 100644 test/paper_client/test_live.py delete mode 100644 test/paper_client/test_matching.py delete mode 100644 test/paper_client/test_order_book.py delete mode 100644 test/paper_client/test_risk.py diff --git a/examples/README.md b/examples/README.md index 172319b1..596a961d 100644 --- a/examples/README.md +++ b/examples/README.md @@ -152,23 +152,6 @@ Spot assets (like ETH) need to have both the from and to route set to `spot`. You can get all `asset_id`s by following the example below: - `spot_get_order_books.py` -## Paper Trading (simulated, no API keys required) -Paper trading lets you simulate trades against real order book data without submitting transactions. - -- `paper_trading_snapshot.py` - - fetches a one-time order book snapshot and simulates buy/sell trades - - prints fills, collateral, and trade history - -- `paper_trading_live.py` - - subscribes to real-time order book updates via WebSocket - - simulates trades against continuously updated book state - - the paper client uses its own internal WebSocket listener (not `lighter.WsClient`) - -- `paper_trading_health.py` - - opens positions across multiple markets - - compares conservative vs aggressive leverage on the same two-market portfolio - - inspects account health, margin usage, leverage, and liquidation prices - ## Setup steps for mainnet - deposit money on Lighter to create an account first - change the URL to `mainnet.zklighter.elliot.ai` diff --git a/examples/paper-trading/paper_trading_health.py b/examples/paper-trading/paper_trading_health.py deleted file mode 100644 index 22c67abd..00000000 --- a/examples/paper-trading/paper_trading_health.py +++ /dev/null @@ -1,168 +0,0 @@ -"""Paper trading — cross-market health & liquidation inspection. - -Demonstrates how account health and liquidation prices change for the -same two-market portfolio under different collateral levels. Runs two -cross-margin scenarios: - 1. Conservative — large collateral, modest ETH + BTC exposure - 2. Aggressive — smaller collateral, larger ETH + BTC exposure -""" - -import asyncio -import lighter - -ETH_MARKET_ID = 0 -BTC_MARKET_ID = 1 -MARKETS = [ - (ETH_MARKET_ID, "ETH-PERP"), - (BTC_MARKET_ID, "BTC-PERP"), -] - - -def round_size(size: float, decimals: int) -> float: - return round(size, decimals) - - -def size_for_notional( - paper: lighter.PaperClient, - market_id: int, - notional_usdc: float, -) -> float: - config = paper.market_configs[market_id] - raw_size = notional_usdc / config.last_trade_price - return max(round_size(raw_size, config.size_decimals), config.min_base_amount) - - -async def track_markets(paper: lighter.PaperClient, markets: list[tuple[int, str]]) -> None: - for market_id, _ in markets: - await paper.track_market_snapshot(market_id) - - -def print_health(paper: lighter.PaperClient, markets: list[tuple[int, str]]) -> None: - health = paper.get_health() - print(f" Health status: {health.status.name}") - print(f" Total account value: {health.total_account_value:.2f} USDC") - print(f" Initial margin req: {health.initial_margin_requirement:.2f} USDC") - print(f" Maintenance margin: {health.maintenance_margin_requirement:.2f} USDC") - print(f" Margin usage: {health.margin_usage:.2f}%") - print(f" Leverage: {health.leverage:.2f}x") - - for market_id, label in markets: - position = paper.get_position(market_id) - if position is not None and position.size != 0: - liq_price = paper.get_liquidation_price(market_id) - liq_str = f"${liq_price:.2f}" if liq_price > 0 else "n/a (fully collateralized)" - side = "LONG" if position.size > 0 else "SHORT" - print( - f" {label} {side} {abs(position.size):g}" - f" entry=${position.avg_entry_price:.2f}" - f" mark=${position.mark_price:.2f}" - f" unrealized_pnl={position.unrealized_pnl:.2f}" - f" liq_price={liq_str}" - ) - - print(f" Portfolio value: {paper.get_portfolio_value():.2f} USDC") - print(f" Collateral: {paper.get_collateral():.2f} USDC") - - -async def main(): - api_client = lighter.ApiClient( - configuration=lighter.Configuration( - host="https://mainnet.zklighter.elliot.ai", - ), - ) - - # ── Scenario 1: Conservative (low leverage) ────────────────────── - # $10,000 collateral backing a modest ETH long + BTC short. - # The portfolio stays lightly levered with wide or nonexistent - # liquidation thresholds. - print("=" * 60) - print("SCENARIO 1: Conservative — $10,000 collateral, ETH + BTC portfolio") - print("=" * 60) - - conservative = lighter.PaperClient(api_client, initial_collateral_usdc=10_000) - await track_markets(conservative, MARKETS) - - conservative_eth_size = size_for_notional(conservative, ETH_MARKET_ID, 1_500) - conservative_btc_size = size_for_notional(conservative, BTC_MARKET_ID, 1_000) - - await conservative.create_paper_order( - lighter.PaperOrderRequest( - market_id=ETH_MARKET_ID, - side=lighter.PaperOrderSide.BUY, - base_amount=conservative_eth_size, - ) - ) - await conservative.create_paper_order( - lighter.PaperOrderRequest( - market_id=BTC_MARKET_ID, - side=lighter.PaperOrderSide.SELL, - base_amount=conservative_btc_size, - ) - ) - print( - f"Opened {conservative_eth_size:g} ETH long and " - f"{conservative_btc_size:g} BTC short." - ) - print_health(conservative, MARKETS) - - # ── Scenario 2: Aggressive (high leverage) ─────────────────────── - # $1,500 collateral backing the same market mix at much larger size. - # Cross-margin still shares collateral across both markets, but - # liquidation prices should move much closer to the current marks. - print() - print("=" * 60) - print("SCENARIO 2: Aggressive — $1,500 collateral, same markets at larger size") - print("=" * 60) - - aggressive = lighter.PaperClient(api_client, initial_collateral_usdc=1_500) - await track_markets(aggressive, MARKETS) - - aggressive_eth_size = size_for_notional(aggressive, ETH_MARKET_ID, 6_000) - aggressive_btc_size = size_for_notional(aggressive, BTC_MARKET_ID, 3_000) - - await aggressive.create_paper_order( - lighter.PaperOrderRequest( - market_id=ETH_MARKET_ID, - side=lighter.PaperOrderSide.BUY, - base_amount=aggressive_eth_size, - ) - ) - await aggressive.create_paper_order( - lighter.PaperOrderRequest( - market_id=BTC_MARKET_ID, - side=lighter.PaperOrderSide.SELL, - base_amount=aggressive_btc_size, - ) - ) - print( - f"Opened {aggressive_eth_size:g} ETH long and " - f"{aggressive_btc_size:g} BTC short." - ) - print_health(aggressive, MARKETS) - - # Show the contrast - print() - print("-" * 60) - print("COMPARISON") - for market_id, label in MARKETS: - cons_liq = conservative.get_liquidation_price(market_id) - aggr_liq = aggressive.get_liquidation_price(market_id) - aggr_pos = aggressive.get_position(market_id) - cons_liq_str = "n/a (can't be liquidated)" if cons_liq == 0 else f"${cons_liq:.2f}" - print(f" {label} conservative liq: {cons_liq_str}") - if aggr_pos is None: - reason = "already liquidated" if aggressive.get_health().has_been_liquidated else "no open position" - print(f" {label} aggressive: {reason}") - else: - distance = abs(aggr_pos.mark_price - aggr_liq) - print( - f" {label} aggressive liq: ${aggr_liq:.2f} " - f"(${distance:.2f} from mark)" - ) - print("-" * 60) - - await api_client.close() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/paper-trading/paper_trading_live.py b/examples/paper-trading/paper_trading_live.py deleted file mode 100644 index 0f56d5de..00000000 --- a/examples/paper-trading/paper_trading_live.py +++ /dev/null @@ -1,64 +0,0 @@ -"""Paper trading — live mode. - -Subscribes to real-time order book updates via WebSocket and simulates -trades against continuously updated book state. - -The paper client manages its own internal WebSocket listener and -sorted order book. -""" - -import asyncio -import lighter - - -async def main(): - api_client = lighter.ApiClient( - configuration=lighter.Configuration( - host="https://mainnet.zklighter.elliot.ai", - ), - ) - - paper = lighter.PaperClient(api_client, initial_collateral_usdc=10_000) - - # Start live tracking — connects a WebSocket and waits for the initial - # order book snapshot before returning. - await paper.track_market(market_id=0) # ETH-PERP - print("Live tracking started for ETH-PERP") - - # Wait a moment to accumulate order book updates - await asyncio.sleep(2) - - # Place a market buy - result = await paper.create_paper_order( - lighter.PaperOrderRequest( - market_id=0, - side=lighter.PaperOrderSide.BUY, - base_amount=0.1, - ) - ) - print(f"BUY filled={result.filled_size} avg_price={result.avg_price:.2f}") - - # Let the book update for a bit, then close with a sell - await asyncio.sleep(2) - - result = await paper.create_paper_order( - lighter.PaperOrderRequest( - market_id=0, - side=lighter.PaperOrderSide.SELL, - base_amount=0.1, - ) - ) - print(f"SELL filled={result.filled_size} avg_price={result.avg_price:.2f}") - - # Print final account state - account = paper.get_account() - print(f"\nCollateral: {account.collateral:.2f} USDC") - print(f"Portfolio value: {paper.get_portfolio_value():.2f} USDC") - - # Stop tracking and clean up - await paper.close() - await api_client.close() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/examples/paper-trading/paper_trading_snapshot.py b/examples/paper-trading/paper_trading_snapshot.py deleted file mode 100644 index 327625bc..00000000 --- a/examples/paper-trading/paper_trading_snapshot.py +++ /dev/null @@ -1,55 +0,0 @@ -"""Paper trading — snapshot mode. - -Fetches a one-time order book snapshot and simulates trades against it. -No API keys or signing required; only read-only API access is used. -""" - -import asyncio -import lighter - - -async def main(): - api_client = lighter.ApiClient( - configuration=lighter.Configuration( - host="https://mainnet.zklighter.elliot.ai", - ), - ) - - paper = lighter.PaperClient(api_client, initial_collateral_usdc=10_000) - - # Load a snapshot of the ETH-PERP order book (market_id=0) - await paper.track_market_snapshot(market_id=0) - - # Simulate a market buy for 0.5 ETH - result = await paper.create_paper_order( - lighter.PaperOrderRequest( - market_id=0, - side=lighter.PaperOrderSide.BUY, - base_amount=0.5, - ) - ) - print(f"BUY filled={result.filled_size} avg_price={result.avg_price:.2f} fee={result.total_fee:.4f}") - - # Simulate a market sell to close the position - result = await paper.create_paper_order( - lighter.PaperOrderRequest( - market_id=0, - side=lighter.PaperOrderSide.SELL, - base_amount=0.5, - ) - ) - print(f"SELL filled={result.filled_size} avg_price={result.avg_price:.2f} fee={result.total_fee:.4f}") - - # Print account summary - account = paper.get_account() - print(f"\nCollateral: {account.collateral:.2f} USDC") - print(f"Trades: {len(account.trades)}") - for trade in account.trades: - side = "BUY" if trade.side == lighter.PaperOrderSide.BUY else "SELL" - print(f" {side} {trade.size} @ {trade.price:.2f} pnl={trade.realized_pnl:.4f}") - - await api_client.close() - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/lighter/__init__.py b/lighter/__init__.py index b43facbf..941282f4 100644 --- a/lighter/__init__.py +++ b/lighter/__init__.py @@ -183,21 +183,4 @@ # manual additions from lighter.ws_client import WsClient -from lighter.signer_client import SignerClient, create_api_key -from lighter.paper_client import ( - AccountTier, - InMemoryOrderBook, - MarketConfig, - OrderBookLevel, - PaperAccount, - PaperAccountHealth, - PaperClient, - PaperFill, - PaperHealthStatus, - PaperOrderRequest, - PaperOrderResult, - PaperOrderSide, - PaperOrderType, - PaperPosition, - PaperTrade, -) \ No newline at end of file +from lighter.signer_client import SignerClient, create_api_key \ No newline at end of file diff --git a/lighter/paper_client/__init__.py b/lighter/paper_client/__init__.py deleted file mode 100644 index f03e3f9a..00000000 --- a/lighter/paper_client/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -from lighter.paper_client.client import PaperClient -from lighter.paper_client.order_book import InMemoryOrderBook, OrderBookLevel -from lighter.paper_client.types import ( - AccountTier, - MarketConfig, - PaperAccount, - PaperAccountHealth, - PaperFill, - PaperHealthStatus, - PaperOrderRequest, - PaperOrderResult, - PaperOrderSide, - PaperOrderType, - PaperPosition, - PaperTrade, -) - -__all__ = [ - "AccountTier", - "InMemoryOrderBook", - "MarketConfig", - "OrderBookLevel", - "PaperAccount", - "PaperAccountHealth", - "PaperClient", - "PaperFill", - "PaperHealthStatus", - "PaperOrderRequest", - "PaperOrderResult", - "PaperOrderSide", - "PaperOrderType", - "PaperPosition", - "PaperTrade", -] diff --git a/lighter/paper_client/accounting.py b/lighter/paper_client/accounting.py deleted file mode 100644 index 6f8b1eea..00000000 --- a/lighter/paper_client/accounting.py +++ /dev/null @@ -1,152 +0,0 @@ -from dataclasses import replace -from math import fabs -from typing import Dict, Optional - -from lighter.paper_client.types import ( - PaperAccount, - PaperOrderSide, - PaperPosition, - PaperTrade, - utc_now, -) - - -def new_paper_account(collateral_usdc: float) -> PaperAccount: - return PaperAccount( - initial_collateral=collateral_usdc, - collateral=collateral_usdc, - ) - - -def copy_position(position: Optional[PaperPosition]) -> Optional[PaperPosition]: - return replace(position) if position is not None else None - - -def copy_account(account: PaperAccount) -> PaperAccount: - return PaperAccount( - initial_collateral=account.initial_collateral, - collateral=account.collateral, - positions={ - market_id: replace(position) - for market_id, position in account.positions.items() - }, - trades=list(account.trades), - has_been_liquidated=account.has_been_liquidated, - ) - - -def apply_fill( - account: PaperAccount, - market_id: int, - side: PaperOrderSide, - fill_size: float, - fill_price: float, - fee: float, - *, - is_liquidation: bool = False, -) -> float: - position = account.positions.get(market_id) - if position is None: - position = PaperPosition(market_id=market_id) - account.positions[market_id] = position - - old_position = position.size - old_abs_size = fabs(old_position) - old_entry_quote = position.entry_quote - - position_delta = fill_size if side == PaperOrderSide.BUY else -fill_size - new_position = old_position + position_delta - new_abs_size = fabs(new_position) - - old_sign = sign(old_position) - new_sign = sign(new_position) - realized_pnl = 0.0 - new_entry_quote = 0.0 - - if old_sign == 0: - new_entry_quote = fill_size * fill_price - elif old_sign != new_sign and new_sign != 0: - realized_pnl = _full_close_pnl(old_sign, old_abs_size, old_entry_quote, fill_price) - new_entry_quote = new_abs_size * fill_price - elif old_sign != new_sign and new_sign == 0: - realized_pnl = _full_close_pnl(old_sign, old_abs_size, old_entry_quote, fill_price) - elif new_abs_size > old_abs_size: - new_entry_quote = old_entry_quote + fill_size * fill_price - else: - if old_abs_size > 0: - new_entry_quote = old_entry_quote * (new_abs_size / old_abs_size) - closed_size = old_abs_size - new_abs_size - avg_entry = old_entry_quote / old_abs_size - if old_sign > 0: - realized_pnl = closed_size * fill_price - closed_size * avg_entry - else: - realized_pnl = closed_size * avg_entry - closed_size * fill_price - - position.size = new_position - position.entry_quote = new_entry_quote - position.avg_entry_price = new_entry_quote / new_abs_size if new_abs_size > 0 else 0 - position.realized_pnl += realized_pnl - - account.collateral += realized_pnl - account.collateral -= fee - account.trades.append( - PaperTrade( - market_id=market_id, - side=side, - size=fill_size, - price=fill_price, - fee=fee, - realized_pnl=realized_pnl, - is_liquidation=is_liquidation, - timestamp=utc_now(), - ) - ) - if is_liquidation: - account.has_been_liquidated = True - - if abs(new_position) < 1e-12: - del account.positions[market_id] - - return realized_pnl - - -def compute_unrealized_pnl(position: Optional[PaperPosition], mark_price: float) -> float: - if position is None or position.size == 0: - return 0.0 - - abs_size = fabs(position.size) - position_value = abs_size * mark_price - if position.size > 0: - return position_value - position.entry_quote - return position.entry_quote - position_value - - -def compute_total_account_value( - account: PaperAccount, - mark_prices: Dict[int, float], -) -> float: - total = account.collateral - for market_id, position in account.positions.items(): - mark_price = mark_prices.get(market_id) - if mark_price is not None: - total += compute_unrealized_pnl(position, mark_price) - return total - - -def sign(value: float) -> int: - if value > 0: - return 1 - if value < 0: - return -1 - return 0 - - -def _full_close_pnl( - old_sign: int, - old_abs_size: float, - old_entry_quote: float, - fill_price: float, -) -> float: - if old_sign > 0: - return old_abs_size * fill_price - old_entry_quote - return old_entry_quote - old_abs_size * fill_price diff --git a/lighter/paper_client/client.py b/lighter/paper_client/client.py deleted file mode 100644 index 22909c86..00000000 --- a/lighter/paper_client/client.py +++ /dev/null @@ -1,355 +0,0 @@ -import asyncio -from threading import RLock -from typing import Any, Dict, List, Mapping, Optional - -from lighter.api.order_api import OrderApi -from lighter.api_client import ApiClient -from lighter.configuration import Configuration -from lighter.models.perps_order_book_detail import PerpsOrderBookDetail -from lighter.paper_client.order_book import InMemoryOrderBook -from lighter.paper_client.accounting import ( - copy_account, - copy_position, - new_paper_account, - compute_total_account_value, - apply_fill, -) -from lighter.paper_client.live import PaperOrderBookListener -from lighter.paper_client.matching import simulate_match, validate_order -from lighter.paper_client.risk import ( - check_and_liquidate, - compute_health, - compute_liquidation_price, - update_position_metrics, -) -from lighter.paper_client.types import ( - AccountTier, - MarketConfig, - PaperAccount, - PaperAccountHealth, - PaperOrderRequest, - PaperOrderResult, - PaperPosition, - PaperTrade, - utc_now, -) - - -class PaperClient: - """Local simulation client for testing trading strategies against real Lighter order book data. - - Maintains a virtual account with simulated collateral, executes taker-only fills - against live or snapshot order book state, and tracks positions, PnL, and account - health locally. - - Limitations: - - Perp markets only — spot markets are not supported. - - Taker-only — supports MARKET and IOC order types, no resting limit orders. - - Cross-margin only — isolated margin is not simulated. - - No funding simulation — funding rate payments are not applied to positions. - - Read-only simulation — no transactions are submitted to the exchange; all state is local. - """ - - def __init__( - self, - api_client: Optional[ApiClient], - initial_collateral_usdc: float, - *, - order_book_limit: int = 100, - order_api: Optional[OrderApi] = None, - ws_url: Optional[str] = None, - ws_path: str = "/stream", - initial_snapshot_timeout: float = 10.0, - account_tier: AccountTier = AccountTier.STANDARD, - ) -> None: - if initial_collateral_usdc <= 0: - raise ValueError( - "initial collateral must be positive, " - f"got {initial_collateral_usdc}" - ) - if order_book_limit < 1 or order_book_limit > 250: - raise ValueError("order_book_limit must be between 1 and 250") - - 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_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.initial_snapshot_timeout = initial_snapshot_timeout - self._live_listeners: Dict[int, PaperOrderBookListener] = {} - self._state_lock = asyncio.Lock() - self._state_snapshot_lock = RLock() - - async def track_market_snapshot(self, market_id: int) -> None: - self._validate_perp_market_id(market_id) - await self._ensure_market_config(market_id) - await self.refresh_order_book(market_id) - - async def track_market(self, market_id: int) -> None: - self._validate_perp_market_id(market_id) - await self._ensure_market_config(market_id) - - if market_id in self._live_listeners: - return - - self.order_books.setdefault(market_id, InMemoryOrderBook()) - listener = PaperOrderBookListener( - market_id, - self.ws_url, - self._handle_live_order_book_message, - initial_snapshot_timeout=self.initial_snapshot_timeout, - ) - self._live_listeners[market_id] = listener - try: - await listener.start() - except Exception: - self._live_listeners.pop(market_id, None) - raise - - async def stop_tracking(self, market_id: int) -> None: - listener = self._live_listeners.pop(market_id, None) - if listener is not None: - await listener.stop() - - async def close(self) -> None: - listeners = list(self._live_listeners.values()) - self._live_listeners.clear() - await asyncio.gather( - *(listener.stop() for listener in listeners), - return_exceptions=True, - ) - - async def refresh_order_book(self, market_id: int) -> None: - if market_id not in self.market_configs: - raise ValueError(f"market {market_id} not tracked") - - snapshot = await self.order_api.order_book_orders( - market_id=market_id, - limit=self.order_book_limit, - ) - async with self._state_lock: - with self._state_snapshot_lock: - book = self.order_books.get(market_id) - if book is None: - book = InMemoryOrderBook() - self.order_books[market_id] = book - - book.apply_snapshot(snapshot) - self._check_liquidation_and_update_metrics() - - async def create_paper_order( - self, - request: PaperOrderRequest, - ) -> PaperOrderResult: - async with self._state_lock: - with self._state_snapshot_lock: - config = self.market_configs.get(request.market_id) - if config is None: - raise ValueError( - f"market {request.market_id} not tracked, " - "call track_market or track_market_snapshot first" - ) - - book = self.order_books.get(request.market_id) - if book is None: - raise ValueError(f"no order book for market {request.market_id}") - - validate_order(request, config) - fills, unfilled = simulate_match( - request, list(book.asks), list(book.bids), config - ) - - total_filled_size = 0.0 - total_quote = 0.0 - total_fee = 0.0 - for fill in fills: - apply_fill( - self.account, - request.market_id, - request.side, - fill.size, - fill.price, - fill.fee, - ) - total_filled_size += fill.size - total_quote += fill.size * fill.price - total_fee += fill.fee - - avg_price = ( - total_quote / total_filled_size if total_filled_size > 0 else 0.0 - ) - self._check_liquidation_and_update_metrics() - - return PaperOrderResult( - order_type=request.order_type, - side=request.side, - market_id=request.market_id, - fills=fills, - filled_size=total_filled_size, - avg_price=avg_price, - total_fee=total_fee, - quote_amount=total_quote, - unfilled=unfilled, - timestamp=utc_now(), - ) - - def get_health(self) -> PaperAccountHealth: - with self._state_snapshot_lock: - return compute_health( - self.account, - self._get_mark_prices(), - self.market_configs, - ) - - def get_liquidation_price(self, market_id: int) -> float: - with self._state_snapshot_lock: - return compute_liquidation_price( - self.account, - market_id, - self._get_mark_prices(), - self.market_configs, - ) - - def get_position(self, market_id: int) -> Optional[PaperPosition]: - with self._state_snapshot_lock: - return copy_position(self.account.positions.get(market_id)) - - def get_account(self) -> PaperAccount: - with self._state_snapshot_lock: - return copy_account(self.account) - - def get_collateral(self) -> float: - with self._state_snapshot_lock: - return self.account.collateral - - def get_trades(self) -> List[PaperTrade]: - with self._state_snapshot_lock: - return list(self.account.trades) - - def get_portfolio_value(self) -> float: - with self._state_snapshot_lock: - mark_prices = self._get_mark_prices() - if self.account.positions and not mark_prices: - raise ValueError("no mark prices available") - return compute_total_account_value(self.account, mark_prices) - - async def _handle_live_order_book_message( - self, - market_id: int, - order_book: Mapping[str, Any], - is_snapshot: bool, - ) -> None: - async with self._state_lock: - with self._state_snapshot_lock: - book = self.order_books.setdefault(market_id, InMemoryOrderBook()) - if is_snapshot: - book.apply_snapshot(order_book) - else: - book.apply_delta(order_book) - self._check_liquidation_and_update_metrics() - - def _check_liquidation_and_update_metrics(self) -> List[int]: - liquidated_markets = check_and_liquidate( - self.account, - self._get_mark_prices(), - self.market_configs, - ) - update_position_metrics( - self.account, - self._get_mark_prices(), - self.market_configs, - ) - return liquidated_markets - - def _get_mark_prices(self) -> Dict[int, float]: - prices: Dict[int, float] = {} - for market_id, book in self.order_books.items(): - mark_price = book.mid_price - if mark_price is None: - config = self.market_configs.get(market_id) - if config is not None and config.last_trade_price > 0: - mark_price = config.last_trade_price - if mark_price is not None and mark_price > 0: - prices[market_id] = mark_price - return prices - - def _update_position_metrics(self) -> None: - update_position_metrics( - self.account, - self._get_mark_prices(), - self.market_configs, - ) - - async def _ensure_market_config(self, market_id: int) -> None: - if market_id not in self.market_configs: - await self._fetch_market_config(market_id) - - async def _fetch_market_config(self, market_id: int) -> None: - details = await self.order_api.order_book_details(market_id=market_id) - for detail in details.order_book_details: - if detail.market_id != market_id: - continue - with self._state_snapshot_lock: - self.market_configs[market_id] = self._market_config_from_detail( - detail, self._account_tier - ) - return - raise ValueError(f"perps order book detail not found for market {market_id}") - - @staticmethod - def _market_config_from_detail( - detail: PerpsOrderBookDetail, - tier: AccountTier, - ) -> MarketConfig: - if detail.market_type != "perp": - raise ValueError( - "paper trading only supports perp markets, " - f"got {detail.market_type!r}" - ) - return MarketConfig( - market_id=detail.market_id, - symbol=detail.symbol, - size_decimals=detail.size_decimals, - price_decimals=detail.price_decimals, - default_initial_margin_fraction=detail.default_initial_margin_fraction, - min_initial_margin_fraction=detail.min_initial_margin_fraction, - maintenance_margin_fraction=detail.maintenance_margin_fraction, - closeout_margin_fraction=detail.closeout_margin_fraction, - taker_fee=tier.taker_fee, - maker_fee=tier.maker_fee, - min_base_amount=float(detail.min_base_amount), - min_quote_amount=float(detail.min_quote_amount), - last_trade_price=float(detail.last_trade_price), - ) - - @staticmethod - def _default_ws_url(api_client: Optional[ApiClient], ws_path: str) -> str: - if api_client is not None: - http_url = api_client.configuration.host - else: - http_url = Configuration.get_default().host - - if http_url.startswith("https://"): - ws_url = "wss://" + http_url[len("https://") :] - elif http_url.startswith("http://"): - ws_url = "ws://" + http_url[len("http://") :] - elif http_url.startswith("wss://") or http_url.startswith("ws://"): - ws_url = http_url - else: - ws_url = "wss://" + http_url - - return f"{ws_url.rstrip('/')}/{ws_path.lstrip('/')}" - - @staticmethod - def _validate_perp_market_id(market_id: int) -> None: - if market_id >= 2048: - raise ValueError( - "paper trading only supports perp markets " - f"(market_id < 2048), got {market_id}" - ) diff --git a/lighter/paper_client/live.py b/lighter/paper_client/live.py deleted file mode 100644 index ed00d6e5..00000000 --- a/lighter/paper_client/live.py +++ /dev/null @@ -1,164 +0,0 @@ -import asyncio -import json -from contextlib import suppress -from typing import Any, Awaitable, Callable, Mapping, Optional - -try: - from websockets.asyncio.client import connect as websocket_connect -except ImportError: # pragma: no cover - compatibility with websockets 12.x - from websockets.client import connect as websocket_connect - - -OrderBookMessageHandler = Callable[[int, Mapping[str, Any], bool], Awaitable[None]] - - -class PaperOrderBookListener: - def __init__( - self, - market_id: int, - ws_url: str, - on_order_book_message: OrderBookMessageHandler, - *, - initial_snapshot_timeout: float = 10.0, - ) -> None: - self.market_id = market_id - self.ws_url = ws_url - self.on_order_book_message = on_order_book_message - self.initial_snapshot_timeout = initial_snapshot_timeout - - self._task: Optional[asyncio.Task] = None - self._websocket = None - self._initial_snapshot: Optional[asyncio.Future] = None - self._subscribed = False - - async def start(self) -> None: - if self._task is not None and not self._task.done(): - return - - loop = asyncio.get_running_loop() - self._initial_snapshot = loop.create_future() - self._task = asyncio.create_task(self._run()) - self._task.add_done_callback(self._consume_task_exception) - - try: - await asyncio.wait_for( - self._initial_snapshot, - timeout=self.initial_snapshot_timeout, - ) - except Exception: - await self.stop() - raise - - async def stop(self) -> None: - task = self._task - websocket = self._websocket - - if websocket is not None: - with suppress(Exception): - await websocket.close() - - if task is not None: - if not task.done(): - task.cancel() - with suppress(asyncio.CancelledError, Exception): - await task - - self._task = None - self._websocket = None - self._subscribed = False - - async def _run(self) -> None: - try: - async with websocket_connect(self.ws_url) as websocket: - self._websocket = websocket - - async for raw_message in websocket: - await self._handle_raw_message(raw_message) - except asyncio.CancelledError: - raise - except Exception as exc: - self._fail_initial_snapshot(exc) - raise - finally: - self._websocket = None - self._fail_initial_snapshot( - RuntimeError( - f"websocket closed before market {self.market_id} snapshot" - ) - ) - - async def _handle_raw_message(self, raw_message: Any) -> None: - if isinstance(raw_message, bytes): - raise TypeError( - "received binary websocket frame; paper client only supports " - "JSON encoding (ensure ws URL includes ?encoding=json)" - ) - message = json.loads(raw_message) - message_type = message.get("type") - - if message_type == "connected": - await self._send_subscribe() - return - - if message_type == "ping": - await self._send_json({"type": "pong"}) - return - - if message_type not in ("subscribed/order_book", "update/order_book"): - return - - if self._message_market_id(message) != self.market_id: - return - - order_book = message.get("order_book") - if not isinstance(order_book, Mapping): - raise ValueError("order book websocket message missing order_book payload") - - is_snapshot = message_type == "subscribed/order_book" - await self.on_order_book_message(self.market_id, order_book, is_snapshot) - if is_snapshot: - self._resolve_initial_snapshot() - - async def _send_subscribe(self) -> None: - if self._subscribed: - return - await self._send_json( - {"type": "subscribe", "channel": f"order_book/{self.market_id}"} - ) - self._subscribed = True - - async def _send_json(self, message: Mapping[str, Any]) -> None: - if self._websocket is None: - return - await self._websocket.send(json.dumps(message)) - - def _resolve_initial_snapshot(self) -> None: - if self._initial_snapshot is not None and not self._initial_snapshot.done(): - self._initial_snapshot.set_result(None) - - def _fail_initial_snapshot(self, exc: Exception) -> None: - if self._initial_snapshot is not None and not self._initial_snapshot.done(): - self._initial_snapshot.set_exception(exc) - - @staticmethod - def _consume_task_exception(task: asyncio.Task) -> None: - if task.cancelled(): - return - with suppress(Exception): - task.exception() - - @staticmethod - def _message_market_id(message: Mapping[str, Any]) -> Optional[int]: - channel = message.get("channel") - if not isinstance(channel, str): - return None - - for separator in (":", "/"): - prefix = f"order_book{separator}" - if channel.startswith(prefix): - try: - return int(channel[len(prefix) :]) - except ValueError: - return None - - return None diff --git a/lighter/paper_client/matching.py b/lighter/paper_client/matching.py deleted file mode 100644 index 6500ee46..00000000 --- a/lighter/paper_client/matching.py +++ /dev/null @@ -1,79 +0,0 @@ -from math import fabs, pow -from typing import Iterable, List, Tuple - -from lighter.paper_client.order_book import OrderBookLevel -from lighter.paper_client.types import ( - MarketConfig, - PaperFill, - PaperOrderRequest, - PaperOrderSide, - PaperOrderType, -) - - -def simulate_match( - request: PaperOrderRequest, - asks: Iterable[OrderBookLevel], - bids: Iterable[OrderBookLevel], - config: MarketConfig, -) -> Tuple[List[PaperFill], float]: - remaining = request.base_amount - fills: List[PaperFill] = [] - levels = asks if request.side == PaperOrderSide.BUY else bids - - for level in levels: - if remaining <= 0: - break - - try: - level_price = level.price_float - level_size = level.size_float - except ValueError: - continue - - if level_price <= 0 or level_size <= 0: - continue - - if request.order_type == PaperOrderType.IOC: - if request.side == PaperOrderSide.BUY and level_price > request.price: - break - if request.side == PaperOrderSide.SELL and level_price < request.price: - break - - fill_size = min(remaining, level_size) - fills.append( - PaperFill( - price=level_price, - size=fill_size, - fee=fill_size * level_price * config.taker_fee, - ) - ) - remaining -= fill_size - - return fills, remaining - - -def validate_order(request: PaperOrderRequest, config: MarketConfig) -> None: - if request.base_amount <= 0: - raise ValueError(f"base amount must be positive, got {request.base_amount}") - - if not _fits_decimals(request.base_amount, config.size_decimals): - raise ValueError( - f"base amount {request.base_amount} exceeds " - f"{config.size_decimals} size decimals for {config.symbol}" - ) - - if request.order_type == PaperOrderType.IOC: - if request.price <= 0: - raise ValueError(f"IoC order requires positive price, got {request.price}") - if not _fits_decimals(request.price, config.price_decimals): - raise ValueError( - f"price {request.price} exceeds " - f"{config.price_decimals} price decimals for {config.symbol}" - ) - - -def _fits_decimals(value: float, decimals: int) -> bool: - multiplier = pow(10, decimals) - rounded = round(value * multiplier) / multiplier - return fabs(rounded - value) <= 1e-12 diff --git a/lighter/paper_client/order_book.py b/lighter/paper_client/order_book.py deleted file mode 100644 index 8338ca64..00000000 --- a/lighter/paper_client/order_book.py +++ /dev/null @@ -1,153 +0,0 @@ -from dataclasses import dataclass, field -from typing import Any, Iterable, List, Mapping, Optional, Tuple, Union - -from lighter.paper_client.order_book_depth import OrderBookDepth -from lighter.models.order_book_orders import OrderBookOrders -from lighter.paper_client.price_level import PriceLevel -from lighter.models.simple_order import SimpleOrder - - -OrderBookLevelLike = Union["OrderBookLevel", PriceLevel, SimpleOrder, Mapping[str, Any]] - - -@dataclass(frozen=True) -class OrderBookLevel: - price: str - size: str - - @classmethod - def from_any(cls, level: OrderBookLevelLike) -> "OrderBookLevel": - if isinstance(level, cls): - return level - - if isinstance(level, PriceLevel): - return cls(price=level.price, size=level.size) - - if isinstance(level, SimpleOrder): - return cls(price=level.price, size=level.remaining_base_amount) - - if isinstance(level, Mapping): - price = level.get("price") - size = level.get("size", level.get("remaining_base_amount")) - if price is None or size is None: - raise ValueError("Order book levels must include price and size data.") - return cls(price=str(price), size=str(size)) - - raise TypeError(f"Unsupported order book level type: {type(level)!r}") - - @property - def price_float(self) -> float: - return float(self.price) - - @property - def size_float(self) -> float: - return float(self.size) - - def to_dict(self) -> Mapping[str, str]: - return {"price": self.price, "size": self.size} - - -@dataclass -class InMemoryOrderBook: - """Sorted in-memory order book built for ``PaperClient`` to simulate - taker fills against order book data. Keeps asks sorted ascending and - bids sorted descending so the best prices are always at index 0. - """ - - asks: List[OrderBookLevel] = field(default_factory=list) - bids: List[OrderBookLevel] = field(default_factory=list) - offset: Optional[int] = None - - def __post_init__(self) -> None: - self.asks = self._sorted_levels(self.asks, is_asks=True) - self.bids = self._sorted_levels(self.bids, is_asks=False) - - def apply_snapshot( - self, - snapshot: Union[OrderBookOrders, OrderBookDepth, Mapping[str, Any]], - ) -> None: - asks, bids, offset = self._extract_book_data(snapshot) - self.asks = self._sorted_levels(asks, is_asks=True) - self.bids = self._sorted_levels(bids, is_asks=False) - self.offset = offset - - def apply_delta( - self, delta: Union[OrderBookDepth, Mapping[str, Any]] - ) -> None: - asks, bids, offset = self._extract_book_data(delta) - self.asks = self._merge_levels(self.asks, asks, is_asks=True) - self.bids = self._merge_levels(self.bids, bids, is_asks=False) - self.offset = offset - - @property - def best_ask(self) -> Optional[OrderBookLevel]: - return self.asks[0] if self.asks else None - - @property - def best_bid(self) -> Optional[OrderBookLevel]: - return self.bids[0] if self.bids else None - - @property - def mid_price(self) -> Optional[float]: - if self.best_ask is None or self.best_bid is None: - return None - return (self.best_ask.price_float + self.best_bid.price_float) / 2 - - def to_dict(self) -> Mapping[str, Any]: - return { - "asks": [level.to_dict() for level in self.asks], - "bids": [level.to_dict() for level in self.bids], - "offset": self.offset, - } - - @staticmethod - def _extract_book_data( - payload: Union[OrderBookOrders, OrderBookDepth, Mapping[str, Any]] - ) -> Tuple[Iterable[OrderBookLevelLike], Iterable[OrderBookLevelLike], Optional[int]]: - if isinstance(payload, OrderBookOrders): - return payload.asks, payload.bids, None - - if isinstance(payload, OrderBookDepth): - return payload.asks, payload.bids, payload.offset - - if not isinstance(payload, Mapping): - raise TypeError(f"Unsupported order book payload type: {type(payload)!r}") - - asks = payload.get("asks", []) - bids = payload.get("bids", []) - offset = payload.get("offset") - return asks, bids, offset - - @classmethod - def _sorted_levels( - cls, levels: Iterable[OrderBookLevelLike], is_asks: bool - ) -> List[OrderBookLevel]: - normalized_levels = [OrderBookLevel.from_any(level) for level in levels] - normalized_levels = [ - level for level in normalized_levels if level.size_float > 0 - ] - return sorted( - normalized_levels, - key=lambda level: level.price_float, - reverse=not is_asks, - ) - - @classmethod - def _merge_levels( - cls, - existing_levels: Iterable[OrderBookLevel], - new_levels: Iterable[OrderBookLevelLike], - is_asks: bool, - ) -> List[OrderBookLevel]: - merged_by_price = { - level.price: level for level in cls._sorted_levels(existing_levels, is_asks) - } - - for new_level in new_levels: - normalized_level = OrderBookLevel.from_any(new_level) - if normalized_level.size_float == 0: - merged_by_price.pop(normalized_level.price, None) - continue - merged_by_price[normalized_level.price] = normalized_level - - return cls._sorted_levels(merged_by_price.values(), is_asks=is_asks) diff --git a/lighter/paper_client/order_book_depth.py b/lighter/paper_client/order_book_depth.py deleted file mode 100644 index 41cf1e65..00000000 --- a/lighter/paper_client/order_book_depth.py +++ /dev/null @@ -1,123 +0,0 @@ -# coding: utf-8 - -""" - - - No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - - The version of the OpenAPI document: - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - -from __future__ import annotations -import pprint -import re # noqa: F401 -import json - -from pydantic import BaseModel, ConfigDict, StrictInt, StrictStr -from typing import Any, ClassVar, Dict, List, Optional -from lighter.paper_client.price_level import PriceLevel -from typing import Optional, Set -from typing_extensions import Self - - -class OrderBookDepth(BaseModel): - """ - OrderBookDepth - """ # noqa: E501 - code: StrictInt - message: Optional[StrictStr] = None - asks: List[PriceLevel] - bids: List[PriceLevel] - offset: StrictInt - nonce: StrictInt - additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["code", "message", "asks", "bids", "offset", "nonce"] - - model_config = ConfigDict( - populate_by_name=True, - validate_assignment=True, - protected_namespaces=(), - ) - - def to_str(self) -> str: - """Returns the string representation of the model using alias""" - return pprint.pformat(self.model_dump(by_alias=True)) - - def to_json(self) -> str: - """Returns the JSON representation of the model using alias""" - # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead - return json.dumps(self.to_dict()) - - @classmethod - def from_json(cls, json_str: str) -> Optional[Self]: - """Create an instance of OrderBookDepth from a JSON string""" - return cls.from_dict(json.loads(json_str)) - - def to_dict(self) -> Dict[str, Any]: - """Return the dictionary representation of the model using alias. - - This has the following differences from calling pydantic's - `self.model_dump(by_alias=True)`: - - * `None` is only added to the output dict for nullable fields that - were set at model initialization. Other fields with value `None` - are ignored. - * Fields in `self.additional_properties` are added to the output dict. - """ - excluded_fields: Set[str] = set([ - "additional_properties", - ]) - - _dict = self.model_dump( - by_alias=True, - exclude=excluded_fields, - exclude_none=True, - ) - # override the default output from pydantic by calling `to_dict()` of each item in asks (list) - _items = [] - if self.asks: - for _item in self.asks: - if _item: - _items.append(_item.to_dict()) - _dict['asks'] = _items - # override the default output from pydantic by calling `to_dict()` of each item in bids (list) - _items = [] - if self.bids: - for _item in self.bids: - if _item: - _items.append(_item.to_dict()) - _dict['bids'] = _items - # puts key-value pairs in additional_properties in the top level - if self.additional_properties is not None: - for _key, _value in self.additional_properties.items(): - _dict[_key] = _value - - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of OrderBookDepth from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_construct(**{ - "code": obj.get("code"), - "message": obj.get("message"), - "asks": [PriceLevel.from_dict(_item) for _item in obj["asks"]] if obj.get("asks") is not None else None, - "bids": [PriceLevel.from_dict(_item) for _item in obj["bids"]] if obj.get("bids") is not None else None, - "offset": obj.get("offset"), - "nonce": obj.get("nonce") - }) - # store additional fields in additional_properties - for _key in obj.keys(): - if _key not in cls.__properties: - _obj.additional_properties[_key] = obj.get(_key) - - return _obj - diff --git a/lighter/paper_client/price_level.py b/lighter/paper_client/price_level.py deleted file mode 100644 index 07ea69fb..00000000 --- a/lighter/paper_client/price_level.py +++ /dev/null @@ -1,100 +0,0 @@ -# coding: utf-8 - -""" - - - No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) - - The version of the OpenAPI document: - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - -from __future__ import annotations -import pprint -import re # noqa: F401 -import json - -from pydantic import BaseModel, ConfigDict, StrictStr -from typing import Any, ClassVar, Dict, List -from typing import Optional, Set -from typing_extensions import Self - - -class PriceLevel(BaseModel): - """ - PriceLevel - """ # noqa: E501 - price: StrictStr - size: StrictStr - additional_properties: Dict[str, Any] = {} - __properties: ClassVar[List[str]] = ["price", "size"] - - model_config = ConfigDict( - populate_by_name=True, - validate_assignment=True, - protected_namespaces=(), - ) - - def to_str(self) -> str: - """Returns the string representation of the model using alias""" - return pprint.pformat(self.model_dump(by_alias=True)) - - def to_json(self) -> str: - """Returns the JSON representation of the model using alias""" - # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead - return json.dumps(self.to_dict()) - - @classmethod - def from_json(cls, json_str: str) -> Optional[Self]: - """Create an instance of PriceLevel from a JSON string""" - return cls.from_dict(json.loads(json_str)) - - def to_dict(self) -> Dict[str, Any]: - """Return the dictionary representation of the model using alias. - - This has the following differences from calling pydantic's - `self.model_dump(by_alias=True)`: - - * `None` is only added to the output dict for nullable fields that - were set at model initialization. Other fields with value `None` - are ignored. - * Fields in `self.additional_properties` are added to the output dict. - """ - excluded_fields: Set[str] = set([ - "additional_properties", - ]) - - _dict = self.model_dump( - by_alias=True, - exclude=excluded_fields, - exclude_none=True, - ) - # puts key-value pairs in additional_properties in the top level - if self.additional_properties is not None: - for _key, _value in self.additional_properties.items(): - _dict[_key] = _value - - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of PriceLevel from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_construct(**{ - "price": obj.get("price"), - "size": obj.get("size") - }) - # store additional fields in additional_properties - for _key in obj.keys(): - if _key not in cls.__properties: - _obj.additional_properties[_key] = obj.get(_key) - - return _obj - diff --git a/lighter/paper_client/risk.py b/lighter/paper_client/risk.py deleted file mode 100644 index c10bcc23..00000000 --- a/lighter/paper_client/risk.py +++ /dev/null @@ -1,214 +0,0 @@ -from math import fabs -from typing import List - -from lighter.paper_client.accounting import ( - apply_fill, - compute_total_account_value, - compute_unrealized_pnl, -) -from lighter.paper_client.types import ( - MarketConfigMap, - MarkPriceMap, - PaperAccount, - PaperAccountHealth, - PaperHealthStatus, - PaperOrderSide, -) - - -def compute_initial_margin_requirement( - account: PaperAccount, - mark_prices: MarkPriceMap, - configs: MarketConfigMap, -) -> float: - total = 0.0 - for market_id, position in account.positions.items(): - mark_price = mark_prices.get(market_id) - config = configs.get(market_id) - if mark_price is None or config is None: - continue - total += ( - fabs(position.size) - * mark_price - * (config.default_initial_margin_fraction / 10_000) - ) - return total - - -def compute_maintenance_margin_requirement( - account: PaperAccount, - mark_prices: MarkPriceMap, - configs: MarketConfigMap, -) -> float: - total = 0.0 - for market_id, position in account.positions.items(): - mark_price = mark_prices.get(market_id) - config = configs.get(market_id) - if mark_price is None or config is None: - continue - total += ( - fabs(position.size) - * mark_price - * (config.maintenance_margin_fraction / 10_000) - ) - return total - - -def compute_closeout_margin_requirement( - account: PaperAccount, - mark_prices: MarkPriceMap, - configs: MarketConfigMap, -) -> float: - total = 0.0 - for market_id, position in account.positions.items(): - mark_price = mark_prices.get(market_id) - config = configs.get(market_id) - if mark_price is None or config is None: - continue - total += ( - fabs(position.size) - * mark_price - * (config.closeout_margin_fraction / 10_000) - ) - return total - - -def compute_health( - account: PaperAccount, - mark_prices: MarkPriceMap, - configs: MarketConfigMap, -) -> PaperAccountHealth: - tav = compute_total_account_value(account, mark_prices) - imr = compute_initial_margin_requirement(account, mark_prices, configs) - mmr = compute_maintenance_margin_requirement(account, mark_prices, configs) - - if tav < 0: - status = PaperHealthStatus.BANKRUPTCY - # Not in scope for paper trading - # elif tav < comr: - # status = PaperHealthStatus.FULL_LIQUIDATION - # elif tav < mmr: - # status = PaperHealthStatus.PARTIAL_LIQUIDATION - elif tav < imr: - status = PaperHealthStatus.PRE_LIQUIDATION - else: - status = PaperHealthStatus.HEALTHY - - if imr == 0: - margin_usage = 0.0 - elif tav > 0: - margin_usage = imr / tav * 100 - else: - margin_usage = float("inf") - total_notional = 0.0 - for market_id, position in account.positions.items(): - mark_price = mark_prices.get(market_id) - if mark_price is not None: - total_notional += fabs(position.size) * mark_price - - leverage = total_notional / tav if tav > 0 else 0.0 - return PaperAccountHealth( - status=status, - total_account_value=tav, - initial_margin_requirement=imr, - maintenance_margin_requirement=mmr, - margin_usage=margin_usage, - leverage=leverage, - has_been_liquidated=account.has_been_liquidated, - ) - - -def compute_liquidation_price( - account: PaperAccount, - market_id: int, - mark_prices: MarkPriceMap, - configs: MarketConfigMap, -) -> float: - position = account.positions.get(market_id) - config = configs.get(market_id) - mark_price = mark_prices.get(market_id) - if position is None or position.size == 0 or config is None or mark_price is None: - return 0.0 - - tav = compute_total_account_value(account, mark_prices) - cross_mmr = compute_maintenance_margin_requirement(account, mark_prices, configs) - abs_size = fabs(position.size) - mm_fraction = config.maintenance_margin_fraction / 10_000 - position_sign = 1 if position.size > 0 else -1 - denominator = abs_size * (mm_fraction - position_sign) - if denominator == 0: - return 0.0 - - liquidation_price = mark_price + (tav - cross_mmr) / denominator - if liquidation_price < 0: - return 0.0 - - if position.size > 0 and liquidation_price >= mark_price: - return mark_price - if position.size < 0 and liquidation_price <= mark_price: - return mark_price - return liquidation_price - - -def check_and_liquidate( - account: PaperAccount, - mark_prices: MarkPriceMap, - configs: MarketConfigMap, -) -> List[int]: - liquidated_market_ids: List[int] = [] - for market_id, position in list(account.positions.items()): - liquidation_price = compute_liquidation_price( - account, - market_id, - mark_prices, - configs, - ) - if liquidation_price == 0: - continue - - mark_price = mark_prices.get(market_id) - if mark_price is None: - continue - - if position.size > 0 and mark_price <= liquidation_price: - liquidated_market_ids.append(market_id) - elif position.size < 0 and mark_price >= liquidation_price: - liquidated_market_ids.append(market_id) - - for market_id in liquidated_market_ids: - position = account.positions.get(market_id) - mark_price = mark_prices[market_id] - if position is None: - continue - - close_side = PaperOrderSide.SELL if position.size > 0 else PaperOrderSide.BUY - apply_fill( - account, - market_id, - close_side, - fabs(position.size), - mark_price, - 0, - is_liquidation=True, - ) - - return liquidated_market_ids - - -def update_position_metrics( - account: PaperAccount, - mark_prices: MarkPriceMap, - configs: MarketConfigMap, -) -> None: - for market_id, position in account.positions.items(): - mark_price = mark_prices.get(market_id) - if mark_price is None: - continue - position.mark_price = mark_price - position.unrealized_pnl = compute_unrealized_pnl(position, mark_price) - position.liquidation_price = compute_liquidation_price( - account, - market_id, - mark_prices, - configs, - ) diff --git a/lighter/paper_client/types.py b/lighter/paper_client/types.py deleted file mode 100644 index 52a88de8..00000000 --- a/lighter/paper_client/types.py +++ /dev/null @@ -1,158 +0,0 @@ -from dataclasses import dataclass, field -from datetime import datetime, timezone -from enum import Enum, IntEnum -from typing import Dict, List, Optional - -_FEE_TICK = 1_000_000 - - -class AccountTier(Enum): - """Publicly documented account tiers with associated fee schedules. - - Each value is ``(taker_fee, maker_fee)`` expressed as fractions - (i.e. ``280 / 1_000_000 == 0.000280 == 0.028 %``). - - Source: https://docs.lighter.xyz/trading/trading-fees - """ - - STANDARD = (0.0, 0.0) - PREMIUM = (280 / _FEE_TICK, 40 / _FEE_TICK) - PREMIUM_1 = (273 / _FEE_TICK, 39 / _FEE_TICK) - PREMIUM_2 = (266 / _FEE_TICK, 38 / _FEE_TICK) - PREMIUM_3 = (252 / _FEE_TICK, 36 / _FEE_TICK) - PREMIUM_4 = (238 / _FEE_TICK, 34 / _FEE_TICK) - PREMIUM_5 = (224 / _FEE_TICK, 32 / _FEE_TICK) - PREMIUM_6 = (210 / _FEE_TICK, 30 / _FEE_TICK) - PREMIUM_7 = (196 / _FEE_TICK, 28 / _FEE_TICK) - - @property - def taker_fee(self) -> float: - return self.value[0] - - @property - def maker_fee(self) -> float: - return self.value[1] - - -class PaperOrderType(IntEnum): - MARKET = 0 - IOC = 1 - - -class PaperOrderSide(IntEnum): - BUY = 0 - SELL = 1 - - -class PaperHealthStatus(IntEnum): - HEALTHY = 0 - PRE_LIQUIDATION = 1 - # Values 2 (PARTIAL_LIQUIDATION) and 3 (FULL_LIQUIDATION) are reserved: - # in real Lighter, accounts pass through TAV < MMR and TAV < COMR states, - # but the paper sim collapses them. Liquidation runs atomically with every - # mark update, so any position whose mark crosses liquidation_price is wiped - # in the same tick. - BANKRUPTCY = 4 - -def utc_now() -> datetime: - return datetime.now(timezone.utc) - - -@dataclass(frozen=True) -class PaperOrderRequest: - market_id: int - side: PaperOrderSide - base_amount: float - price: float = 0 - order_type: PaperOrderType = PaperOrderType.MARKET - - -@dataclass(frozen=True) -class PaperFill: - price: float - size: float - fee: float - is_maker: bool = False - - -@dataclass(frozen=True) -class PaperOrderResult: - order_type: PaperOrderType - side: PaperOrderSide - market_id: int - fills: List[PaperFill] - filled_size: float - avg_price: float - total_fee: float - quote_amount: float - unfilled: float - timestamp: datetime - - -@dataclass -class PaperPosition: - market_id: int - size: float = 0 - entry_quote: float = 0 - avg_entry_price: float = 0 - mark_price: float = 0 - unrealized_pnl: float = 0 - realized_pnl: float = 0 - liquidation_price: float = 0 - - -@dataclass(frozen=True) -class PaperTrade: - market_id: int - side: PaperOrderSide - size: float - price: float - fee: float - realized_pnl: float - is_liquidation: bool - timestamp: datetime - - -@dataclass(frozen=True) -class PaperAccountHealth: - status: PaperHealthStatus - total_account_value: float - initial_margin_requirement: float - maintenance_margin_requirement: float - margin_usage: float - leverage: float - # Sticky for the lifetime of the PaperClient session: set to True the first - # time a paper liquidation fires and never cleared (no deposit/reset API). - # Reads as "this session has experienced at least one liquidation", not - # "currently liquidating". Recreate the PaperClient to reset. - has_been_liquidated: bool = False - - -@dataclass -class PaperAccount: - initial_collateral: float - collateral: float - positions: Dict[int, PaperPosition] = field(default_factory=dict) - trades: List[PaperTrade] = field(default_factory=list) - has_been_liquidated: bool = False - - -@dataclass(frozen=True) -class MarketConfig: - market_id: int - symbol: str - size_decimals: int - price_decimals: int - default_initial_margin_fraction: int - min_initial_margin_fraction: int - maintenance_margin_fraction: int - closeout_margin_fraction: int - taker_fee: float - maker_fee: float - min_base_amount: float - min_quote_amount: float - last_trade_price: float - - -MarkPriceMap = Dict[int, float] -MarketConfigMap = Dict[int, MarketConfig] diff --git a/pyproject.toml b/pyproject.toml index 6d2c4901..1d863d6d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,6 @@ extension-pkg-whitelist = "pydantic" files = [ "lighter", #"test", # auto-generated tests - "test/paper_client", # hand-written tests ] # TODO: enable "strict" once all these individual checks are passing # strict = true diff --git a/test/paper_client/__init__.py b/test/paper_client/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/test/paper_client/helpers.py b/test/paper_client/helpers.py deleted file mode 100644 index 0ce3a223..00000000 --- a/test/paper_client/helpers.py +++ /dev/null @@ -1,126 +0,0 @@ -from lighter.models.market_config import MarketConfig as SdkMarketConfig -from lighter.models.order_book_details import OrderBookDetails -from lighter.models.order_book_orders import OrderBookOrders -from lighter.models.perps_order_book_detail import PerpsOrderBookDetail -from lighter.models.simple_order import SimpleOrder -from lighter.paper_client.types import MarketConfig - - -class FakeOrderApi: - def __init__(self) -> None: - self.details = {0: default_detail(0, "ETH"), 1: default_detail(1, "BTC")} - self.books = {} - - async def order_book_details(self, market_id=None, **kwargs): - detail = self.details[market_id] - return OrderBookDetails( - code=0, - order_book_details=[detail], - spot_order_book_details=[], - ) - - async def order_book_orders(self, market_id, limit, **kwargs): - return self.books[market_id] - - -def default_detail( - market_id: int, - symbol: str, - last_trade_price: float = 3000.0, -) -> PerpsOrderBookDetail: - return PerpsOrderBookDetail( - symbol=symbol, - market_id=market_id, - market_type="perp", - base_asset_id=market_id, - quote_asset_id=0, - status="active", - taker_fee="0.0005", - maker_fee="0", - liquidation_fee="0", - min_base_amount="0.001", - min_quote_amount="1.0", - order_quote_limit="1000000", - supported_size_decimals=4, - supported_price_decimals=2, - supported_quote_decimals=6, - size_decimals=4, - price_decimals=2, - quote_multiplier=1, - default_initial_margin_fraction=1000, - min_initial_margin_fraction=500, - maintenance_margin_fraction=50, - closeout_margin_fraction=25, - last_trade_price=last_trade_price, - daily_trades_count=0, - daily_base_token_volume=0, - daily_quote_token_volume=0, - daily_price_low=0, - daily_price_high=0, - daily_price_change=0, - open_interest=0, - daily_chart={}, - market_config=SdkMarketConfig( - market_margin_mode=0, - insurance_fund_account_index=0, - liquidation_mode=0, - force_reduce_only=False, - trading_hours="", - funding_fee_discounts_enabled=False, - hidden=False, - rfq_enabled=False, - ), - created_at="0", - strategy_index=0, - is_maker_fee_enabled=True, - is_taker_fee_enabled=True, - funding_clamp_small="0", - funding_clamp_big="0", - base_interest_rate="0", - ) - - -def cfg(market_id=0, imf=1000, mmf=500, comf=250, **kw) -> MarketConfig: - return MarketConfig( - market_id=market_id, - symbol=kw.get("symbol", f"MKT{market_id}"), - size_decimals=kw.get("size_decimals", 4), - price_decimals=kw.get("price_decimals", 2), - default_initial_margin_fraction=imf, - min_initial_margin_fraction=imf, - maintenance_margin_fraction=mmf, - closeout_margin_fraction=comf, - taker_fee=kw.get("taker_fee", 0.0005), - maker_fee=kw.get("maker_fee", 0.0002), - min_base_amount=kw.get("min_base_amount", 0.001), - min_quote_amount=kw.get("min_quote_amount", 1.0), - last_trade_price=kw.get("last_trade_price", 100.0), - ) - - -def book(asks, bids) -> OrderBookOrders: - def _order(index, price, size): - return SimpleOrder( - order_index=index, - order_id=f"order-{index}", - owner_account_index=10, - initial_base_amount=size, - remaining_base_amount=size, - price=price, - order_expiry=0, - transaction_time=0, - ) - - return OrderBookOrders( - code=0, - total_asks=len(asks), - asks=[ - _order(index, price, size) - for index, (price, size) in enumerate(asks, start=1) - ], - total_bids=len(bids), - bids=[ - _order(index, price, size) - for index, (price, size) in enumerate(bids, start=100) - ], - ) diff --git a/test/paper_client/test_accounting.py b/test/paper_client/test_accounting.py deleted file mode 100644 index d13b7160..00000000 --- a/test/paper_client/test_accounting.py +++ /dev/null @@ -1,93 +0,0 @@ -import unittest - -from lighter.paper_client.accounting import ( - apply_fill, - compute_unrealized_pnl, - compute_total_account_value, - new_paper_account, -) -from lighter.paper_client.types import PaperOrderSide, PaperPosition - - -class TestApplyFill(unittest.TestCase): - def test_open_new_long(self): - a = new_paper_account(10000.0) - apply_fill(a, market_id=0, side=PaperOrderSide.BUY, fill_size=1.0, fill_price=100.0, fee=0.5) - pos = a.positions[0] - self.assertAlmostEqual(pos.size, 1.0) - self.assertAlmostEqual(pos.entry_quote, 100.0) - self.assertAlmostEqual(a.collateral, 10000.0 - 0.5) - - def test_open_new_short(self): - a = new_paper_account(10000.0) - apply_fill(a, market_id=0, side=PaperOrderSide.SELL, fill_size=1.0, fill_price=100.0, fee=0.5) - pos = a.positions[0] - self.assertAlmostEqual(pos.size, -1.0) - self.assertAlmostEqual(pos.entry_quote, 100.0) - self.assertAlmostEqual(a.collateral, 10000.0 - 0.5) - - def test_increase_existing_long(self): - a = new_paper_account(10000.0) - apply_fill(a, market_id=0, side=PaperOrderSide.BUY, fill_size=1.0, fill_price=100.0, fee=0.0) - apply_fill(a, market_id=0, side=PaperOrderSide.BUY, fill_size=1.0, fill_price=110.0, fee=0.0) - pos = a.positions[0] - self.assertAlmostEqual(pos.size, 2.0) - self.assertAlmostEqual(pos.entry_quote, 210.0) - - def test_partial_close_long(self): - a = new_paper_account(10000.0) - apply_fill(a, market_id=0, side=PaperOrderSide.BUY, fill_size=2.0, fill_price=100.0, fee=0.0) - pnl = apply_fill(a, market_id=0, side=PaperOrderSide.SELL, fill_size=1.0, fill_price=120.0, fee=0.0) - pos = a.positions[0] - self.assertAlmostEqual(pnl, 20.0) - self.assertAlmostEqual(pos.entry_quote, 100.0) - self.assertAlmostEqual(a.collateral, 10000.0 + 20.0) - - def test_partial_close_short(self): - a = new_paper_account(10000.0) - apply_fill(a, market_id=0, side=PaperOrderSide.SELL, fill_size=2.0, fill_price=100.0, fee=0.0) - pnl = apply_fill(a, market_id=0, side=PaperOrderSide.BUY, fill_size=1.0, fill_price=80.0, fee=0.0) - self.assertAlmostEqual(pnl, 20.0) - - def test_flip_long_to_short(self): - a = new_paper_account(10000.0) - apply_fill(a, market_id=0, side=PaperOrderSide.BUY, fill_size=1.0, fill_price=100.0, fee=0.0) - pnl = apply_fill(a, market_id=0, side=PaperOrderSide.SELL, fill_size=3.0, fill_price=120.0, fee=0.0) - pos = a.positions[0] - self.assertAlmostEqual(pnl, 20.0) - self.assertAlmostEqual(pos.size, -2.0) - self.assertAlmostEqual(pos.entry_quote, 240.0) - - def test_full_close_removes_position(self): - a = new_paper_account(10000.0) - apply_fill(a, market_id=0, side=PaperOrderSide.BUY, fill_size=1.0, fill_price=100.0, fee=0.0) - pnl = apply_fill(a, market_id=0, side=PaperOrderSide.SELL, fill_size=1.0, fill_price=110.0, fee=0.0) - self.assertNotIn(0, a.positions) - self.assertAlmostEqual(pnl, 10.0) - - -class TestUnrealizedPnl(unittest.TestCase): - def test_unrealized_pnl_long(self): - pos = PaperPosition(market_id=0, size=1.0, entry_quote=100.0) - self.assertAlmostEqual(compute_unrealized_pnl(pos, mark_price=120.0), 20.0) - self.assertAlmostEqual(compute_unrealized_pnl(pos, mark_price=80.0), -20.0) - - def test_unrealized_pnl_short(self): - pos = PaperPosition(market_id=0, size=-1.0, entry_quote=100.0) - self.assertAlmostEqual(compute_unrealized_pnl(pos, mark_price=80.0), 20.0) - self.assertAlmostEqual(compute_unrealized_pnl(pos, mark_price=120.0), -20.0) - - -class TestTotalAccountValue(unittest.TestCase): - def test_total_account_value(self): - a = new_paper_account(1000.0) - # long 1@100 on market 0 - apply_fill(a, market_id=0, side=PaperOrderSide.BUY, fill_size=1.0, fill_price=100.0, fee=0.0) - # short 1@200 on market 1 - apply_fill(a, market_id=1, side=PaperOrderSide.SELL, fill_size=1.0, fill_price=200.0, fee=0.0) - tav = compute_total_account_value(a, mark_prices={0: 110.0, 1: 190.0}) - self.assertAlmostEqual(tav, 1020.0) - - -if __name__ == "__main__": - unittest.main() diff --git a/test/paper_client/test_client.py b/test/paper_client/test_client.py deleted file mode 100644 index 8fedcfb2..00000000 --- a/test/paper_client/test_client.py +++ /dev/null @@ -1,250 +0,0 @@ -import unittest - -from lighter.paper_client.accounting import apply_fill -from lighter.paper_client import ( - AccountTier, - PaperClient, - PaperHealthStatus, - PaperOrderRequest, - PaperOrderSide, - PaperOrderType, -) -from test.paper_client.helpers import FakeOrderApi, book, default_detail - - -class TestPaperClient(unittest.IsolatedAsyncioTestCase): - async def test_track_market_snapshot_and_market_buy_then_sell(self) -> None: - order_api = FakeOrderApi() - order_api.books[0] = book( - asks=[("3000.00", "0.5"), ("3001.00", "2.0")], - bids=[("2999.00", "0.5"), ("2998.00", "2.0")], - ) - client = PaperClient(None, 5000.0, order_api=order_api) - - await client.track_market_snapshot(0) - - buy_result = await client.create_paper_order( - PaperOrderRequest( - market_id=0, - side=PaperOrderSide.BUY, - base_amount=1.0, - ) - ) - - self.assertAlmostEqual(buy_result.filled_size, 1.0) - self.assertAlmostEqual(buy_result.unfilled, 0.0) - self.assertEqual(len(buy_result.fills), 2) - self.assertAlmostEqual(buy_result.avg_price, 3000.5) - self.assertAlmostEqual(buy_result.quote_amount, 3000.5) - self.assertAlmostEqual(buy_result.total_fee, 0.0) - - position = client.get_position(0) - self.assertIsNotNone(position) - self.assertAlmostEqual(position.size, 1.0) - - sell_result = await client.create_paper_order( - PaperOrderRequest( - market_id=0, - side=PaperOrderSide.SELL, - base_amount=1.0, - ) - ) - - self.assertAlmostEqual(sell_result.filled_size, 1.0) - self.assertIsNone(client.get_position(0)) - self.assertEqual(len(client.get_trades()), 4) - - async def test_ioc_order_partially_fills_until_limit_price(self) -> None: - order_api = FakeOrderApi() - order_api.books[0] = book( - asks=[("3000.00", "0.5"), ("3010.00", "2.0")], - bids=[("2999.00", "1.0")], - ) - client = PaperClient(None, 5000.0, order_api=order_api) - await client.track_market_snapshot(0) - - result = await client.create_paper_order( - PaperOrderRequest( - market_id=0, - side=PaperOrderSide.BUY, - base_amount=2.0, - price=3005.00, - order_type=PaperOrderType.IOC, - ) - ) - - self.assertAlmostEqual(result.filled_size, 0.5) - self.assertAlmostEqual(result.unfilled, 1.5) - self.assertEqual(len(result.fills), 1) - - async def test_health_uses_cross_margin_across_markets(self) -> None: - order_api = FakeOrderApi() - order_api.details[1] = default_detail(1, "BTC") - order_api.details[1].last_trade_price = 60000.0 - order_api.details[1].min_base_amount = "0.0001" - order_api.books[0] = book( - asks=[("3000.00", "10.0")], - bids=[("2999.00", "10.0")], - ) - order_api.books[1] = book( - asks=[("60000.00", "10.0")], - bids=[("59999.00", "10.0")], - ) - client = PaperClient(None, 10000.0, order_api=order_api) - await client.track_market_snapshot(0) - await client.track_market_snapshot(1) - - await client.create_paper_order( - PaperOrderRequest(0, PaperOrderSide.BUY, 1.0) - ) - await client.create_paper_order( - PaperOrderRequest(1, PaperOrderSide.BUY, 0.1) - ) - - self.assertAlmostEqual(client.get_position(0).size, 1.0) - self.assertAlmostEqual(client.get_position(1).size, 0.1) - - health = client.get_health() - self.assertEqual(health.status, PaperHealthStatus.HEALTHY) - self.assertGreater(health.leverage, 0) - self.assertLess(health.leverage, 2) - - async def test_order_can_trigger_cross_market_liquidation(self) -> None: - order_api = FakeOrderApi() - order_api.details[1] = default_detail(1, "BTC") - order_api.details[1].maintenance_margin_fraction = 500 - order_api.details[1].closeout_margin_fraction = 250 - order_api.details[1].taker_fee = "0" - order_api.details[1].min_base_amount = "0.0001" - order_api.books[0] = book( - asks=[("1.00", "10.0")], - bids=[("0.99", "10.0")], - ) - order_api.books[1] = book( - asks=[("31.00", "10.0")], - bids=[("30.00", "10.0")], - ) - client = PaperClient(None, 350.0, order_api=order_api) - await client.track_market_snapshot(0) - await client.track_market_snapshot(1) - - apply_fill(client.account, 1, PaperOrderSide.BUY, 5.0, 100.0, 0) - - result = await client.create_paper_order( - PaperOrderRequest( - market_id=0, - side=PaperOrderSide.BUY, - base_amount=10.0, - price=0.50, - order_type=PaperOrderType.IOC, - ) - ) - - self.assertIsNotNone(result) - self.assertIsNone(client.get_position(0)) - self.assertIsNone(client.get_position(1)) - - async def test_request_market_liquidation_clears_position(self) -> None: - order_api = FakeOrderApi() - order_api.details[0].maintenance_margin_fraction = 500 - order_api.details[0].closeout_margin_fraction = 250 - order_api.details[0].taker_fee = "0" - order_api.books[0] = book( - asks=[("10.00", "10.0")], - bids=[("9.00", "10.0")], - ) - client = PaperClient(None, 5.0, order_api=order_api) - await client.track_market_snapshot(0) - apply_fill(client.account, 0, PaperOrderSide.BUY, 1.0, 100.0, 0) - - result = await client.create_paper_order( - PaperOrderRequest(0, PaperOrderSide.BUY, 0.1) - ) - - self.assertIsNotNone(result) - self.assertIsNone(client.get_position(0)) - self.assertTrue(client.get_health().has_been_liquidated) - - async def test_mark_price_fallback_to_last_trade_price(self) -> None: - order_api = FakeOrderApi() - order_api.books[0] = book(asks=[], bids=[]) - client = PaperClient(None, 10000.0, order_api=order_api) - await client.track_market_snapshot(0) - - apply_fill(client.account, 0, PaperOrderSide.BUY, 1.0, 3000.0, 0.0) - - self.assertAlmostEqual(client.get_portfolio_value(), 10000.0) - - async def test_refresh_order_book_updates_unrealized_pnl(self) -> None: - order_api = FakeOrderApi() - order_api.books[0] = book( - asks=[("3000.00", "2.0")], - bids=[("2999.00", "2.0")], - ) - client = PaperClient(None, 10000.0, order_api=order_api) - await client.track_market_snapshot(0) - - await client.create_paper_order(PaperOrderRequest(0, PaperOrderSide.BUY, 1.0)) - - order_api.books[0] = book( - asks=[("3100.00", "2.0")], - bids=[("3099.00", "2.0")], - ) - await client.refresh_order_book(0) - - position = client.get_position(0) - self.assertIsNotNone(position) - self.assertAlmostEqual(position.unrealized_pnl, 99.5) - - async def test_premium_tier_applies_fees(self) -> None: - order_api = FakeOrderApi() - order_api.books[0] = book( - asks=[("3000.00", "0.5"), ("3001.00", "2.0")], - bids=[("2999.00", "0.5"), ("2998.00", "2.0")], - ) - client = PaperClient( - None, 5000.0, order_api=order_api, account_tier=AccountTier.PREMIUM - ) - await client.track_market_snapshot(0) - - result = await client.create_paper_order( - PaperOrderRequest( - market_id=0, - side=PaperOrderSide.BUY, - base_amount=1.0, - ) - ) - - # premium taker fee = 280 / 1_000_000 = 0.000280 - # fill 1: 0.5 * 3000 * 0.000280 = 0.42 - # fill 2: 0.5 * 3001 * 0.000280 = 0.42014 - expected_fee = 0.5 * 3000 * 0.000280 + 0.5 * 3001 * 0.000280 - self.assertAlmostEqual(result.total_fee, expected_fee, places=8) - - def test_default_tier_is_standard(self) -> None: - client = PaperClient(None, 5000.0, order_api=FakeOrderApi()) - self.assertEqual(client._account_tier, AccountTier.STANDARD) - - async def test_repeated_orders_produce_identical_fills(self) -> None: - order_api = FakeOrderApi() - order_api.books[0] = book( - asks=[("3000.00", "0.5"), ("3001.00", "2.0")], - bids=[("2999.00", "1.0")], - ) - client = PaperClient(None, 50000.0, order_api=order_api) - await client.track_market_snapshot(0) - - result1 = await client.create_paper_order( - PaperOrderRequest(0, PaperOrderSide.BUY, 1.0) - ) - result2 = await client.create_paper_order( - PaperOrderRequest(0, PaperOrderSide.BUY, 1.0) - ) - - self.assertAlmostEqual(result1.filled_size, result2.filled_size) - self.assertAlmostEqual(result1.avg_price, result2.avg_price) - self.assertAlmostEqual(result1.total_fee, result2.total_fee) - - -if __name__ == "__main__": - unittest.main() diff --git a/test/paper_client/test_live.py b/test/paper_client/test_live.py deleted file mode 100644 index 3906fd58..00000000 --- a/test/paper_client/test_live.py +++ /dev/null @@ -1,412 +0,0 @@ -import asyncio -import json -import unittest -from typing import Any, Dict, List, Optional - -import websockets - -from lighter.paper_client.accounting import apply_fill -from lighter.paper_client import ( - PaperClient, - PaperHealthStatus, - PaperOrderRequest, - PaperOrderSide, -) -from test.paper_client.helpers import FakeOrderApi, default_detail - - -def subscribed_message(market_id: int, asks, bids, offset: int) -> Dict[str, Any]: - return { - "type": "subscribed/order_book", - "channel": f"order_book:{market_id}", - "order_book": { - "asks": [{"price": p, "size": s} for p, s in asks], - "bids": [{"price": p, "size": s} for p, s in bids], - "offset": offset, - }, - } - - -def update_message(market_id: int, asks, bids, offset: int) -> Dict[str, Any]: - return { - "type": "update/order_book", - "channel": f"order_book/{market_id}", - "order_book": { - "asks": [{"price": p, "size": s} for p, s in asks], - "bids": [{"price": p, "size": s} for p, s in bids], - "offset": offset, - }, - } - - -async def wait_until(predicate, timeout: float = 2.0) -> None: - loop = asyncio.get_running_loop() - deadline = loop.time() + timeout - while loop.time() < deadline: - if predicate(): - return - await asyncio.sleep(0.01) - raise AssertionError("condition was not satisfied before timeout") - - -class LocalOrderBookWebSocket: - def __init__(self, snapshot: Dict[str, Any]) -> None: - self.snapshot = snapshot - self.messages: asyncio.Queue = asyncio.Queue() - self.received: List[Dict[str, Any]] = [] - self.closed = asyncio.Event() - self.server = None - self.url: Optional[str] = None - - async def __aenter__(self): - self.server = await websockets.serve(self._handler, "127.0.0.1", 0) - port = self.server.sockets[0].getsockname()[1] - self.url = f"ws://127.0.0.1:{port}" - return self - - async def __aexit__(self, exc_type, exc, tb): - await self.messages.put(None) - if self.server is not None: - self.server.close() - await self.server.wait_closed() - - async def push(self, message: Dict[str, Any]) -> None: - await self.messages.put(message) - - async def _handler(self, websocket, *args) -> None: - try: - await websocket.send(json.dumps({"type": "connected"})) - while True: - raw_message = await websocket.recv() - message = json.loads(raw_message) - self.received.append(message) - if message.get("type") == "subscribe": - await websocket.send(json.dumps(self.snapshot)) - break - - while True: - next_outbound = asyncio.create_task(self.messages.get()) - next_inbound = asyncio.create_task(websocket.recv()) - done, pending = await asyncio.wait( - [next_outbound, next_inbound], - return_when=asyncio.FIRST_COMPLETED, - ) - - for task in pending: - task.cancel() - - if next_inbound in done: - try: - self.received.append(json.loads(next_inbound.result())) - except Exception: - break - continue - - outbound = next_outbound.result() - if outbound is None: - break - await websocket.send(json.dumps(outbound)) - finally: - self.closed.set() - - -class MultiMarketWebSocket: - """WS server that routes snapshots and updates per market across connections.""" - - def __init__(self, snapshots: Dict[int, Dict[str, Any]]) -> None: - self.snapshots = snapshots - self._queues: Dict[int, asyncio.Queue] = {} - self.closed_markets: set = set() - self.server = None - self.url: Optional[str] = None - - async def __aenter__(self): - self.server = await websockets.serve(self._handler, "127.0.0.1", 0) - port = self.server.sockets[0].getsockname()[1] - self.url = f"ws://127.0.0.1:{port}" - return self - - async def __aexit__(self, exc_type, exc, tb): - for q in self._queues.values(): - await q.put(None) - if self.server is not None: - self.server.close() - await self.server.wait_closed() - - async def push(self, market_id: int, message: Dict[str, Any]) -> None: - q = self._queues.get(market_id) - if q is not None: - await q.put(message) - - async def _handler(self, websocket, *args) -> None: - market_id = None - q = asyncio.Queue() - try: - await websocket.send(json.dumps({"type": "connected"})) - while True: - raw = await websocket.recv() - msg = json.loads(raw) - if msg.get("type") == "subscribe": - channel = msg.get("channel", "") - market_id = int(channel.split("/")[-1]) - self._queues[market_id] = q - await websocket.send(json.dumps(self.snapshots[market_id])) - break - - while True: - next_out = asyncio.create_task(q.get()) - next_in = asyncio.create_task(websocket.recv()) - done, pending = await asyncio.wait( - [next_out, next_in], - return_when=asyncio.FIRST_COMPLETED, - ) - for t in pending: - t.cancel() - if next_in in done: - try: - next_in.result() - except Exception: - break - continue - out = next_out.result() - if out is None: - break - await websocket.send(json.dumps(out)) - finally: - if market_id is not None: - self.closed_markets.add(market_id) - - -class TestPaperClientLive(unittest.IsolatedAsyncioTestCase): - async def test_track_market_subscribes_and_applies_snapshot(self) -> None: - snapshot = subscribed_message( - 0, - asks=[("3002.00", "2.0"), ("3000.00", "1.0")], - bids=[("2998.00", "1.5"), ("2999.00", "1.0")], - offset=11, - ) - - async with LocalOrderBookWebSocket(snapshot) as ws: - client = PaperClient( - None, - 5000.0, - order_api=FakeOrderApi(), - ws_url=ws.url, - ) - - await client.track_market(0) - - self.assertEqual( - ws.received[0], - {"type": "subscribe", "channel": "order_book/0"}, - ) - self.assertEqual( - [level.to_dict() for level in client.order_books[0].asks], - [ - {"price": "3000.00", "size": "1.0"}, - {"price": "3002.00", "size": "2.0"}, - ], - ) - self.assertEqual( - [level.price for level in client.order_books[0].bids], - ["2999.00", "2998.00"], - ) - self.assertEqual(client.order_books[0].offset, 11) - - await client.close() - - async def test_live_updates_tombstones_sorting_offset_and_cleanup(self) -> None: - snapshot = subscribed_message( - 0, - asks=[("3000.00", "1.0"), ("3002.00", "1.0")], - bids=[("2999.00", "1.0"), ("2998.00", "1.0")], - offset=11, - ) - - async with LocalOrderBookWebSocket(snapshot) as ws: - client = PaperClient( - None, - 5000.0, - order_api=FakeOrderApi(), - ws_url=ws.url, - ) - await client.track_market(0) - - await ws.push( - update_message( - 0, - asks=[("3000.00", "0.0000"), ("2997.00", "0.5")], - bids=[("2998.00", "0.0"), ("3001.00", "0.25")], - offset=12, - ) - ) - - await wait_until(lambda: client.order_books[0].offset == 12) - - self.assertEqual( - [level.to_dict() for level in client.order_books[0].asks], - [ - {"price": "2997.00", "size": "0.5"}, - {"price": "3002.00", "size": "1.0"}, - ], - ) - self.assertEqual( - [level.to_dict() for level in client.order_books[0].bids], - [ - {"price": "3001.00", "size": "0.25"}, - {"price": "2999.00", "size": "1.0"}, - ], - ) - - await client.stop_tracking(0) - self.assertNotIn(0, client._live_listeners) - await wait_until(lambda: ws.closed.is_set()) - - async def test_live_update_liquidates_position(self) -> None: - order_api = FakeOrderApi() - order_api.details[0].maintenance_margin_fraction = 500 - order_api.details[0].closeout_margin_fraction = 250 - order_api.details[0].taker_fee = "0" - snapshot = subscribed_message( - 0, - asks=[("100.00", "10.0")], - bids=[("99.00", "10.0")], - offset=1, - ) - - async with LocalOrderBookWebSocket(snapshot) as ws: - client = PaperClient( - None, - 5.0, - order_api=order_api, - ws_url=ws.url, - ) - await client.track_market(0) - apply_fill(client.account, 0, PaperOrderSide.BUY, 1.0, 100.0, 0) - - await ws.push( - update_message( - 0, - asks=[("100.00", "0"), ("10.00", "10.0")], - bids=[("99.00", "0.0000"), ("9.00", "10.0")], - offset=2, - ) - ) - - await wait_until(lambda: client.get_position(0) is None) - self.assertTrue(client.get_trades()[-1].is_liquidation) - - await client.close() - - - async def test_live_multi_market_trading_and_metrics(self) -> None: - snapshots = { - 0: subscribed_message(0, [("3000.00", "5.0")], [("2999.00", "5.0")], 1), - 1: subscribed_message(1, [("60000.00", "1.0")], [("59999.00", "1.0")], 1), - } - - async with MultiMarketWebSocket(snapshots) as ws: - order_api = FakeOrderApi() - order_api.details[1].last_trade_price = 60000.0 - order_api.details[1].min_base_amount = "0.0001" - client = PaperClient(None, 10000.0, order_api=order_api, ws_url=ws.url) - - await client.track_market(0) - await client.track_market(1) - - # Place orders against live book - await client.create_paper_order(PaperOrderRequest(0, PaperOrderSide.BUY, 1.0)) - await client.create_paper_order(PaperOrderRequest(1, PaperOrderSide.BUY, 0.01)) - self.assertAlmostEqual(client.get_position(0).size, 1.0) - self.assertAlmostEqual(client.get_position(1).size, 0.01) - - # Delta 1: ETH price moves up (tombstone old, add new) - await ws.push(0, update_message(0, - asks=[("3000.00", "0"), ("3100.00", "5.0")], - bids=[("2999.00", "0"), ("3099.00", "5.0")], - offset=2, - )) - await wait_until(lambda: client.order_books[0].offset == 2) - - pos0 = client.get_position(0) - self.assertAlmostEqual(pos0.mark_price, 3099.5) - self.assertAlmostEqual(pos0.unrealized_pnl, 99.5) - - # Delta 2: ETH price moves further - await ws.push(0, update_message(0, - asks=[("3100.00", "0"), ("3200.00", "5.0")], - bids=[("3099.00", "0"), ("3199.00", "5.0")], - offset=3, - )) - await wait_until(lambda: client.order_books[0].offset == 3) - - pos0 = client.get_position(0) - self.assertAlmostEqual(pos0.mark_price, 3199.5) - self.assertAlmostEqual(pos0.unrealized_pnl, 199.5) - - # Cross-margin health uses both positions - health = client.get_health() - self.assertEqual(health.status, PaperHealthStatus.HEALTHY) - self.assertGreater(health.leverage, 0) - - # close() cleans up both listeners - await client.close() - self.assertEqual(len(client._live_listeners), 0) - await wait_until(lambda: len(ws.closed_markets) == 2) - - async def test_live_liquidation_state_inspection(self) -> None: - order_api = FakeOrderApi() - order_api.details[0] = default_detail(0, "ETH", last_trade_price=100.0) - order_api.details[0].maintenance_margin_fraction = 500 - order_api.details[0].closeout_margin_fraction = 250 - order_api.details[0].taker_fee = "0" - order_api.details[1] = default_detail(1, "ALT", last_trade_price=10.0) - order_api.details[1].taker_fee = "0" - - snapshots = { - 0: subscribed_message(0, [("100.00", "10.0")], [("99.00", "10.0")], 1), - 1: subscribed_message(1, [("10.00", "10.0")], [("9.99", "10.0")], 1), - } - - async with MultiMarketWebSocket(snapshots) as ws: - client = PaperClient(None, 20.0, order_api=order_api, ws_url=ws.url) - - await client.track_market(0) - await client.track_market(1) - - apply_fill(client.account, 0, PaperOrderSide.BUY, 1.0, 100.0, 0) - apply_fill(client.account, 1, PaperOrderSide.BUY, 1.0, 10.0, 0) - collateral_before = client.get_collateral() - - # Crash market 0 → TAV drops below cross_MMR → cascade liquidation - await ws.push(0, update_message(0, - asks=[("100.00", "0"), ("84.00", "10.0")], - bids=[("99.00", "0"), ("82.00", "10.0")], - offset=2, - )) - await wait_until(lambda: client.get_position(0) is None) - - # Cross-margin cascade: both positions liquidated - self.assertIsNone(client.get_position(0)) - self.assertIsNone(client.get_position(1)) - - # Collateral decreased by realized losses - self.assertLess(client.get_collateral(), collateral_before) - - # Two liquidation trades with correct details - liq_trades = [t for t in client.get_trades() if t.is_liquidation] - self.assertEqual(len(liq_trades), 2) - self.assertEqual({t.market_id for t in liq_trades}, {0, 1}) - mkt0_liq = next(t for t in liq_trades if t.market_id == 0) - self.assertLess(mkt0_liq.realized_pnl, -10) - - # No positions left → healthy with remaining collateral - health = client.get_health() - self.assertGreater(health.total_account_value, 0) - self.assertAlmostEqual(health.leverage, 0.0) - - await client.close() - - -if __name__ == "__main__": - unittest.main() diff --git a/test/paper_client/test_matching.py b/test/paper_client/test_matching.py deleted file mode 100644 index 585fa77a..00000000 --- a/test/paper_client/test_matching.py +++ /dev/null @@ -1,77 +0,0 @@ -import unittest - -from lighter.paper_client.order_book import OrderBookLevel -from lighter.paper_client.matching import simulate_match, validate_order -from lighter.paper_client.types import ( - PaperOrderRequest, - PaperOrderSide, - PaperOrderType, -) -from test.paper_client.helpers import cfg - - -class TestValidateOrder(unittest.TestCase): - def test_validate_rejects_zero_size(self): - req = PaperOrderRequest(market_id=0, side=PaperOrderSide.BUY, base_amount=0) - with self.assertRaises(ValueError): - validate_order(req, cfg()) - - def test_validate_rejects_bad_size_decimals(self): - req = PaperOrderRequest(market_id=0, side=PaperOrderSide.BUY, base_amount=0.001) - with self.assertRaises(ValueError): - validate_order(req, cfg(size_decimals=2)) - - def test_validate_rejects_ioc_zero_price(self): - req = PaperOrderRequest( - market_id=0, side=PaperOrderSide.BUY, base_amount=0.01, - price=0, order_type=PaperOrderType.IOC, - ) - with self.assertRaises(ValueError): - validate_order(req, cfg()) - - def test_validate_rejects_ioc_bad_price_decimals(self): - req = PaperOrderRequest( - market_id=0, side=PaperOrderSide.BUY, base_amount=0.01, - price=0.001, order_type=PaperOrderType.IOC, - ) - with self.assertRaises(ValueError): - validate_order(req, cfg(price_decimals=2)) - -class TestSimulateMatch(unittest.TestCase): - def test_market_buy_against_empty_book(self): - req = PaperOrderRequest(market_id=0, side=PaperOrderSide.BUY, base_amount=5.0) - fills, remaining = simulate_match(req, asks=[], bids=[], config=cfg()) - self.assertEqual(fills, []) - self.assertEqual(remaining, 5.0) - - def test_market_buy_partial_liquidity(self): - req = PaperOrderRequest(market_id=0, side=PaperOrderSide.BUY, base_amount=10.0) - asks = [OrderBookLevel("3000", "3")] - fills, remaining = simulate_match(req, asks=asks, bids=[], config=cfg()) - self.assertEqual(len(fills), 1) - self.assertAlmostEqual(fills[0].size, 3.0) - self.assertAlmostEqual(remaining, 7.0) - - def test_sell_ioc_stops_below_limit(self): - req = PaperOrderRequest( - market_id=0, side=PaperOrderSide.SELL, base_amount=5.0, - price=2995, order_type=PaperOrderType.IOC, - ) - bids = [OrderBookLevel("2999", "2"), OrderBookLevel("2998", "2"), OrderBookLevel("2990", "2")] - fills, _ = simulate_match(req, asks=[], bids=bids, config=cfg()) - self.assertEqual(len(fills), 2) - self.assertAlmostEqual(fills[0].price, 2999) - self.assertAlmostEqual(fills[1].price, 2998) - - def test_malformed_level_skipped(self): - req = PaperOrderRequest(market_id=0, side=PaperOrderSide.BUY, base_amount=5.0) - fills, remaining = simulate_match( - req, asks=[OrderBookLevel("bad", "1"), OrderBookLevel("3000", "5")], bids=[], config=cfg(), - ) - self.assertEqual(len(fills), 1) - self.assertAlmostEqual(fills[0].price, 3000) - self.assertAlmostEqual(remaining, 0.0) - - -if __name__ == "__main__": - unittest.main() diff --git a/test/paper_client/test_order_book.py b/test/paper_client/test_order_book.py deleted file mode 100644 index 1951c027..00000000 --- a/test/paper_client/test_order_book.py +++ /dev/null @@ -1,304 +0,0 @@ -import unittest - -from lighter.paper_client.order_book_depth import OrderBookDepth -from lighter.models.order_book_orders import OrderBookOrders -from lighter.paper_client.price_level import PriceLevel -from lighter.models.simple_order import SimpleOrder -from lighter.paper_client.order_book import InMemoryOrderBook - - -def make_runtime() -> InMemoryOrderBook: - return InMemoryOrderBook( - asks=[ - {"price": "3000.00", "size": "1.0"}, - {"price": "3001.00", "size": "2.0"}, - ], - bids=[ - {"price": "2999.00", "size": "1.5"}, - {"price": "2998.00", "size": "2.5"}, - ], - ) - - -class TestOrderBookRuntime(unittest.TestCase): - def test_apply_snapshot_normalizes_rest_orders_and_sorts(self) -> None: - book = InMemoryOrderBook() - - snapshot = OrderBookOrders( - code=0, - total_asks=2, - asks=[ - SimpleOrder( - order_index=1, - order_id="ask-2", - owner_account_index=10, - initial_base_amount="1.0", - remaining_base_amount="2.0", - price="3001.00", - order_expiry=0, - transaction_time=0, - ), - SimpleOrder( - order_index=2, - order_id="ask-1", - owner_account_index=10, - initial_base_amount="1.0", - remaining_base_amount="1.0", - price="3000.00", - order_expiry=0, - transaction_time=0, - ), - ], - total_bids=2, - bids=[ - SimpleOrder( - order_index=3, - order_id="bid-1", - owner_account_index=10, - initial_base_amount="1.0", - remaining_base_amount="1.5", - price="2999.00", - order_expiry=0, - transaction_time=0, - ), - SimpleOrder( - order_index=4, - order_id="bid-2", - owner_account_index=10, - initial_base_amount="1.0", - remaining_base_amount="1.0", - price="2998.00", - order_expiry=0, - transaction_time=0, - ), - ], - ) - - book.apply_snapshot(snapshot) - - self.assertEqual([level.price for level in book.asks], ["3000.00", "3001.00"]) - self.assertEqual([level.size for level in book.asks], ["1.0", "2.0"]) - self.assertEqual([level.price for level in book.bids], ["2999.00", "2998.00"]) - self.assertIsNone(book.offset) - self.assertIsNotNone(book.best_ask) - self.assertIsNotNone(book.best_bid) - self.assertEqual(book.best_ask.price, "3000.00") - self.assertEqual(book.best_bid.price, "2999.00") - self.assertEqual(book.mid_price, 2999.5) - - def test_apply_delta_removes_existing_level_for_zero_string(self) -> None: - book = make_runtime() - - book.apply_delta({"asks": [{"price": "3000.00", "size": "0"}], "bids": []}) - - self.assertEqual([level.price for level in book.asks], ["3001.00"]) - - def test_apply_delta_removes_existing_level_for_zero_point_zero(self) -> None: - book = make_runtime() - - book.apply_delta({"asks": [{"price": "3000.00", "size": "0.0"}], "bids": []}) - - self.assertEqual([level.price for level in book.asks], ["3001.00"]) - - def test_apply_delta_removes_existing_level_for_zero_point_zero_zero_zero( - self, - ) -> None: - book = make_runtime() - - book.apply_delta({"asks": [{"price": "3000.00", "size": "0.0000"}], "bids": []}) - - self.assertEqual([level.price for level in book.asks], ["3001.00"]) - - def test_apply_delta_ignores_tombstone_for_missing_level(self) -> None: - book = make_runtime() - - book.apply_delta({"asks": [{"price": "9999.00", "size": "0.0000"}], "bids": []}) - - self.assertEqual([level.price for level in book.asks], ["3000.00", "3001.00"]) - - def test_apply_delta_replaces_existing_size_for_non_zero_update(self) -> None: - book = make_runtime() - - book.apply_delta({"asks": [{"price": "3000.00", "size": "2.5"}], "bids": []}) - - self.assertEqual(len(book.asks), 2) - self.assertEqual(book.asks[0].price, "3000.00") - self.assertEqual(book.asks[0].size, "2.5") - - def test_apply_delta_accepts_order_book_depth_and_price_levels(self) -> None: - book = make_runtime() - - book.apply_delta( - OrderBookDepth( - code=0, - asks=[ - PriceLevel(price="3001.00", size="0.0000"), - PriceLevel(price="2999.50", size="0.7"), - ], - bids=[ - PriceLevel(price="2999.00", size="2.25"), - PriceLevel(price="3000.00", size="0.4"), - ], - offset=43, - nonce=7, - ) - ) - - self.assertEqual( - [level.to_dict() for level in book.asks], - [ - {"price": "2999.50", "size": "0.7"}, - {"price": "3000.00", "size": "1.0"}, - ], - ) - self.assertEqual( - [level.to_dict() for level in book.bids], - [ - {"price": "3000.00", "size": "0.4"}, - {"price": "2999.00", "size": "2.25"}, - {"price": "2998.00", "size": "2.5"}, - ], - ) - self.assertEqual(book.offset, 43) - - def test_apply_delta_inserts_new_levels_and_keeps_final_book_sorted(self) -> None: - book = make_runtime() - - book.apply_delta( - { - "asks": [ - {"price": "2999.50", "size": "0.7"}, - {"price": "3005.00", "size": "0.8"}, - ], - "bids": [ - {"price": "2999.50", "size": "0.6"}, - {"price": "2997.50", "size": "0.4"}, - ], - "offset": 42, - } - ) - - self.assertEqual( - [level.price for level in book.asks], - ["2999.50", "3000.00", "3001.00", "3005.00"], - ) - self.assertEqual( - [level.price for level in book.bids], - ["2999.50", "2999.00", "2998.00", "2997.50"], - ) - self.assertEqual(book.offset, 42) - - def test_mid_price_is_none_when_either_side_is_missing(self) -> None: - self.assertIsNone(InMemoryOrderBook().mid_price) - self.assertIsNone( - InMemoryOrderBook(asks=[{"price": "3000.00", "size": "1.0"}]).mid_price - ) - self.assertIsNone( - InMemoryOrderBook(bids=[{"price": "2999.00", "size": "1.0"}]).mid_price - ) - - def test_snapshot_filters_zero_size_levels(self) -> None: - book = InMemoryOrderBook() - - book.apply_snapshot( - { - "asks": [ - {"price": "3000.00", "size": "0"}, - {"price": "3001.00", "size": "1.25"}, - ], - "bids": [ - {"price": "2999.00", "size": "0.0000"}, - {"price": "2998.00", "size": "2.5"}, - ], - "offset": 11, - } - ) - - self.assertEqual( - [level.to_dict() for level in book.asks], - [{"price": "3001.00", "size": "1.25"}], - ) - self.assertEqual( - [level.to_dict() for level in book.bids], - [{"price": "2998.00", "size": "2.5"}], - ) - self.assertEqual(book.offset, 11) - - def test_to_dict_returns_public_book_shape(self) -> None: - book = make_runtime() - book.apply_delta({"asks": [], "bids": [], "offset": 77}) - - self.assertEqual( - book.to_dict(), - { - "asks": [ - {"price": "3000.00", "size": "1.0"}, - {"price": "3001.00", "size": "2.0"}, - ], - "bids": [ - {"price": "2999.00", "size": "1.5"}, - {"price": "2998.00", "size": "2.5"}, - ], - "offset": 77, - }, - ) - - def test_invalid_level_payload_raises_value_error(self) -> None: - with self.assertRaisesRegex(ValueError, "price and size"): - InMemoryOrderBook(asks=[{"size": "1.0"}]) - - with self.assertRaisesRegex(ValueError, "price and size"): - InMemoryOrderBook(bids=[{"price": "2999.00"}]) - - def test_asks_stay_ascending_after_mixed_updates(self) -> None: - book = InMemoryOrderBook( - asks=[ - {"price": "2111.04", "size": "304.1013"}, - {"price": "2111.36", "size": "474.9197"}, - {"price": "6666.00", "size": "1.8000"}, - ], - bids=[], - ) - - book.apply_delta( - { - "asks": [ - {"price": "6666.01", "size": "0.3000"}, - {"price": "4050.00", "size": "0.3000"}, - ], - "bids": [], - } - ) - - self.assertEqual( - [level.price for level in book.asks], - ["2111.04", "2111.36", "4050.00", "6666.00", "6666.01"], - ) - - def test_bids_stay_descending_after_mixed_updates(self) -> None: - book = InMemoryOrderBook( - asks=[], - bids=[ - {"price": "2103.36", "size": "451.4887"}, - {"price": "2102.72", "size": "474.9231"}, - ], - ) - - book.apply_delta( - { - "asks": [], - "bids": [ - {"price": "1893.31", "size": "0.0053"}, - {"price": "2103.00", "size": "0.0100"}, - ], - } - ) - - self.assertEqual( - [level.price for level in book.bids], - ["2103.36", "2103.00", "2102.72", "1893.31"], - ) - - -if __name__ == "__main__": - unittest.main() diff --git a/test/paper_client/test_risk.py b/test/paper_client/test_risk.py deleted file mode 100644 index 48a4d0fd..00000000 --- a/test/paper_client/test_risk.py +++ /dev/null @@ -1,168 +0,0 @@ -import unittest - -from lighter.paper_client.accounting import apply_fill, new_paper_account -from lighter.paper_client.risk import ( - check_and_liquidate, - compute_closeout_margin_requirement, - compute_health, - compute_initial_margin_requirement, - compute_liquidation_price, - compute_maintenance_margin_requirement, - update_position_metrics, -) -from lighter.paper_client.types import PaperHealthStatus, PaperOrderSide -from test.paper_client.helpers import cfg - - -class TestMarginRequirements(unittest.TestCase): - def test_margin_requirements(self): - a = new_paper_account(1000) - apply_fill(a, 0, PaperOrderSide.BUY, 1.0, 100.0, 0) - c = {0: cfg(0, imf=1000, mmf=500, comf=250)} - mp = {0: 100.0} - self.assertAlmostEqual(compute_initial_margin_requirement(a, mp, c), 10.0) - self.assertAlmostEqual(compute_maintenance_margin_requirement(a, mp, c), 5.0) - self.assertAlmostEqual(compute_closeout_margin_requirement(a, mp, c), 2.5) - - -class TestHealth(unittest.TestCase): - def test_health_healthy(self): - a = new_paper_account(1000) - apply_fill(a, 0, PaperOrderSide.BUY, 1.0, 100.0, 0) - health = compute_health(a, {0: 100.0}, {0: cfg()}) - self.assertEqual(health.status, PaperHealthStatus.HEALTHY) - - def test_health_pre_liquidation(self): - a = new_paper_account(9) - apply_fill(a, 0, PaperOrderSide.BUY, 1.0, 100.0, 0) - health = compute_health(a, {0: 100.0}, {0: cfg()}) - self.assertEqual(health.status, PaperHealthStatus.PRE_LIQUIDATION) - - def test_health_bankruptcy(self): - a = new_paper_account(1) - apply_fill(a, 0, PaperOrderSide.BUY, 1.0, 200.0, 0) - health = compute_health(a, {0: 100.0}, {0: cfg()}) - self.assertEqual(health.status, PaperHealthStatus.BANKRUPTCY) - - def test_margin_usage_inf_when_underwater_with_position(self): - a = new_paper_account(1) - apply_fill(a, 0, PaperOrderSide.BUY, 1.0, 200.0, 0) - # TAV = 1 + (100 - 200) = -99 <= 0, position still open so IMR > 0 - health = compute_health(a, {0: 100.0}, {0: cfg()}) - self.assertEqual(health.margin_usage, float("inf")) - self.assertAlmostEqual(health.leverage, 0.0) - - def test_margin_usage_zero_when_no_positions(self): - # Flat account with no IMR should read 0.0, not inf. - a = new_paper_account(1000) - health = compute_health(a, {}, {}) - self.assertEqual(health.status, PaperHealthStatus.HEALTHY) - self.assertAlmostEqual(health.margin_usage, 0.0) - - # Same rule after a round-trip wipes collateral to zero / negative. - a2 = new_paper_account(1) - apply_fill(a2, 0, PaperOrderSide.BUY, 1.0, 200.0, 10) - apply_fill(a2, 0, PaperOrderSide.SELL, 1.0, 100.0, 10) - # No positions remaining, TAV <= 0 due to realized loss + fees. - health2 = compute_health(a2, {0: 100.0}, {0: cfg()}) - self.assertAlmostEqual(health2.margin_usage, 0.0) - - -class TestLiquidationPrice(unittest.TestCase): - def test_liquidation_price_long(self): - a = new_paper_account(15) - apply_fill(a, 0, PaperOrderSide.BUY, 1.0, 100.0, 0) - lp = compute_liquidation_price(a, 0, {0: 100.0}, {0: cfg()}) - self.assertGreater(lp, 0.0) - self.assertLess(lp, 100.0) - - def test_liquidation_price_short(self): - a = new_paper_account(100) - apply_fill(a, 0, PaperOrderSide.SELL, 1.0, 100.0, 0) - lp = compute_liquidation_price(a, 0, {0: 100.0}, {0: cfg()}) - self.assertGreater(lp, 100.0) - - def test_liquidation_price_clamped_negative(self): - a = new_paper_account(10_000_000) - apply_fill(a, 0, PaperOrderSide.BUY, 1.0, 100.0, 0) - lp = compute_liquidation_price(a, 0, {0: 100.0}, {0: cfg()}) - self.assertAlmostEqual(lp, 0.0) - - def test_liquidation_price_capped_at_mark(self): - a = new_paper_account(2) - apply_fill(a, 0, PaperOrderSide.BUY, 1.0, 100.0, 0) - lp = compute_liquidation_price(a, 0, {0: 100.0}, {0: cfg()}) - self.assertAlmostEqual(lp, 100.0) - - def test_liquidation_price_no_position(self): - a = new_paper_account(1000) - lp = compute_liquidation_price(a, 99, {99: 100.0}, {99: cfg(99)}) - self.assertAlmostEqual(lp, 0.0) - - -class TestCheckAndLiquidate(unittest.TestCase): - def test_check_and_liquidate_short(self): - a = new_paper_account(5) - apply_fill(a, 0, PaperOrderSide.SELL, 1.0, 100.0, 0) - liquidated = check_and_liquidate(a, {0: 200.0}, {0: cfg()}) - self.assertIn(0, liquidated) - self.assertNotIn(0, a.positions) - liq_trade = a.trades[-1] - self.assertTrue(liq_trade.is_liquidation) - - def test_liquidation_scenario(self): - # User longs 0.05 BTC at $100k with $1000 collateral, 10% IMF / 5% MMF. - # Mark drifts down through pre-liquidation and crosses liq_price; - # position gets wiped, and has_been_liquidated gets flagged on get_health(). - a = new_paper_account(1000) - c = {0: cfg(0, imf=1000, mmf=500, comf=250)} - apply_fill(a, 0, PaperOrderSide.BUY, 0.05, 100_000.0, 0) - - # T0 - just opened, healthy - h0 = compute_health(a, {0: 100_000.0}, c) - self.assertEqual(h0.status, PaperHealthStatus.HEALTHY) - self.assertFalse(h0.has_been_liquidated) - self.assertAlmostEqual(h0.total_account_value, 1000.0) - self.assertAlmostEqual(h0.margin_usage, 50.0) - - # T1 - mark drops to 92k, still healthy - h1 = compute_health(a, {0: 92_000.0}, c) - self.assertEqual(h1.status, PaperHealthStatus.HEALTHY) - self.assertFalse(h1.has_been_liquidated) - - # T2 - mark drops to 88k, enters pre-liquidation - h2 = compute_health(a, {0: 88_000.0}, c) - self.assertEqual(h2.status, PaperHealthStatus.PRE_LIQUIDATION) - self.assertFalse(h2.has_been_liquidated) - self.assertGreater(h2.margin_usage, 100.0) - - # T3 - mark crosses liq_price, liquidation fires - liquidated = check_and_liquidate(a, {0: 84_000.0}, c) - self.assertEqual(liquidated, [0]) - self.assertNotIn(0, a.positions) - self.assertTrue(a.trades[-1].is_liquidation) - - # T4 continued - health now reads HEALTHY (no positions) BUT the - # sticky flag tells the user what happened. - h3 = compute_health(a, {0: 84_000.0}, c) - self.assertEqual(h3.status, PaperHealthStatus.HEALTHY) - self.assertTrue(h3.has_been_liquidated) - self.assertEqual(h3.initial_margin_requirement, 0.0) - self.assertEqual(h3.maintenance_margin_requirement, 0.0) - self.assertAlmostEqual(h3.margin_usage, 0.0) - self.assertLess(h3.total_account_value, 1000.0) - - -class TestUpdatePositionMetrics(unittest.TestCase): - def test_update_position_metrics(self): - a = new_paper_account(20) - apply_fill(a, 0, PaperOrderSide.BUY, 1.0, 100.0, 0) - update_position_metrics(a, {0: 120.0}, {0: cfg()}) - pos = a.positions[0] - self.assertAlmostEqual(pos.mark_price, 120.0) - self.assertAlmostEqual(pos.unrealized_pnl, 20.0) - self.assertGreater(pos.liquidation_price, 0.0) - - -if __name__ == "__main__": - unittest.main()