|
| 1 | +"""Paper trading — cross-market health & liquidation inspection. |
| 2 | +
|
| 3 | +Demonstrates how account health and liquidation prices change for the |
| 4 | +same two-market portfolio under different collateral levels. Runs two |
| 5 | +cross-margin scenarios: |
| 6 | + 1. Conservative — large collateral, modest ETH + BTC exposure |
| 7 | + 2. Aggressive — smaller collateral, larger ETH + BTC exposure |
| 8 | +""" |
| 9 | + |
| 10 | +import asyncio |
| 11 | +import lighter |
| 12 | + |
| 13 | +ETH_MARKET_ID = 0 |
| 14 | +BTC_MARKET_ID = 1 |
| 15 | +MARKETS = [ |
| 16 | + (ETH_MARKET_ID, "ETH-PERP"), |
| 17 | + (BTC_MARKET_ID, "BTC-PERP"), |
| 18 | +] |
| 19 | + |
| 20 | + |
| 21 | +def round_size(size: float, decimals: int) -> float: |
| 22 | + return round(size, decimals) |
| 23 | + |
| 24 | + |
| 25 | +def size_for_notional( |
| 26 | + paper: lighter.PaperClient, |
| 27 | + market_id: int, |
| 28 | + notional_usdc: float, |
| 29 | +) -> float: |
| 30 | + config = paper.market_configs[market_id] |
| 31 | + raw_size = notional_usdc / config.last_trade_price |
| 32 | + return max(round_size(raw_size, config.size_decimals), config.min_base_amount) |
| 33 | + |
| 34 | + |
| 35 | +async def track_markets(paper: lighter.PaperClient, markets: list[tuple[int, str]]) -> None: |
| 36 | + for market_id, _ in markets: |
| 37 | + await paper.track_market_snapshot(market_id) |
| 38 | + |
| 39 | + |
| 40 | +def print_health(paper: lighter.PaperClient, markets: list[tuple[int, str]]) -> None: |
| 41 | + health = paper.get_health() |
| 42 | + print(f" Health status: {health.status.name}") |
| 43 | + print(f" Total account value: {health.total_account_value:.2f} USDC") |
| 44 | + print(f" Initial margin req: {health.initial_margin_requirement:.2f} USDC") |
| 45 | + print(f" Maintenance margin: {health.maintenance_margin_requirement:.2f} USDC") |
| 46 | + print(f" Margin usage: {health.margin_usage:.2f}%") |
| 47 | + print(f" Leverage: {health.leverage:.2f}x") |
| 48 | + |
| 49 | + for market_id, label in markets: |
| 50 | + position = paper.get_position(market_id) |
| 51 | + if position is not None and position.size != 0: |
| 52 | + liq_price = paper.get_liquidation_price(market_id) |
| 53 | + liq_str = f"${liq_price:.2f}" if liq_price > 0 else "n/a (fully collateralized)" |
| 54 | + side = "LONG" if position.size > 0 else "SHORT" |
| 55 | + print( |
| 56 | + f" {label} {side} {abs(position.size):g}" |
| 57 | + f" entry=${position.avg_entry_price:.2f}" |
| 58 | + f" mark=${position.mark_price:.2f}" |
| 59 | + f" unrealized_pnl={position.unrealized_pnl:.2f}" |
| 60 | + f" liq_price={liq_str}" |
| 61 | + ) |
| 62 | + |
| 63 | + print(f" Portfolio value: {paper.get_portfolio_value():.2f} USDC") |
| 64 | + print(f" Collateral: {paper.get_collateral():.2f} USDC") |
| 65 | + |
| 66 | + |
| 67 | +async def main(): |
| 68 | + api_client = lighter.ApiClient( |
| 69 | + configuration=lighter.Configuration( |
| 70 | + host="https://mainnet.zklighter.elliot.ai", |
| 71 | + ), |
| 72 | + ) |
| 73 | + |
| 74 | + # ── Scenario 1: Conservative (low leverage) ────────────────────── |
| 75 | + # $10,000 collateral backing a modest ETH long + BTC short. |
| 76 | + # The portfolio stays lightly levered with wide or nonexistent |
| 77 | + # liquidation thresholds. |
| 78 | + print("=" * 60) |
| 79 | + print("SCENARIO 1: Conservative — $10,000 collateral, ETH + BTC portfolio") |
| 80 | + print("=" * 60) |
| 81 | + |
| 82 | + conservative = lighter.PaperClient(api_client, initial_collateral_usdc=10_000) |
| 83 | + await track_markets(conservative, MARKETS) |
| 84 | + |
| 85 | + conservative_eth_size = size_for_notional(conservative, ETH_MARKET_ID, 1_500) |
| 86 | + conservative_btc_size = size_for_notional(conservative, BTC_MARKET_ID, 1_000) |
| 87 | + |
| 88 | + await conservative.create_paper_order( |
| 89 | + lighter.PaperOrderRequest( |
| 90 | + market_id=ETH_MARKET_ID, |
| 91 | + side=lighter.PaperOrderSide.BUY, |
| 92 | + base_amount=conservative_eth_size, |
| 93 | + ) |
| 94 | + ) |
| 95 | + await conservative.create_paper_order( |
| 96 | + lighter.PaperOrderRequest( |
| 97 | + market_id=BTC_MARKET_ID, |
| 98 | + side=lighter.PaperOrderSide.SELL, |
| 99 | + base_amount=conservative_btc_size, |
| 100 | + ) |
| 101 | + ) |
| 102 | + print( |
| 103 | + f"Opened {conservative_eth_size:g} ETH long and " |
| 104 | + f"{conservative_btc_size:g} BTC short." |
| 105 | + ) |
| 106 | + print_health(conservative, MARKETS) |
| 107 | + |
| 108 | + # ── Scenario 2: Aggressive (high leverage) ─────────────────────── |
| 109 | + # $1,500 collateral backing the same market mix at much larger size. |
| 110 | + # Cross-margin still shares collateral across both markets, but |
| 111 | + # liquidation prices should move much closer to the current marks. |
| 112 | + print() |
| 113 | + print("=" * 60) |
| 114 | + print("SCENARIO 2: Aggressive — $1,500 collateral, same markets at larger size") |
| 115 | + print("=" * 60) |
| 116 | + |
| 117 | + aggressive = lighter.PaperClient(api_client, initial_collateral_usdc=1_500) |
| 118 | + await track_markets(aggressive, MARKETS) |
| 119 | + |
| 120 | + aggressive_eth_size = size_for_notional(aggressive, ETH_MARKET_ID, 6_000) |
| 121 | + aggressive_btc_size = size_for_notional(aggressive, BTC_MARKET_ID, 3_000) |
| 122 | + |
| 123 | + await aggressive.create_paper_order( |
| 124 | + lighter.PaperOrderRequest( |
| 125 | + market_id=ETH_MARKET_ID, |
| 126 | + side=lighter.PaperOrderSide.BUY, |
| 127 | + base_amount=aggressive_eth_size, |
| 128 | + ) |
| 129 | + ) |
| 130 | + await aggressive.create_paper_order( |
| 131 | + lighter.PaperOrderRequest( |
| 132 | + market_id=BTC_MARKET_ID, |
| 133 | + side=lighter.PaperOrderSide.SELL, |
| 134 | + base_amount=aggressive_btc_size, |
| 135 | + ) |
| 136 | + ) |
| 137 | + print( |
| 138 | + f"Opened {aggressive_eth_size:g} ETH long and " |
| 139 | + f"{aggressive_btc_size:g} BTC short." |
| 140 | + ) |
| 141 | + print_health(aggressive, MARKETS) |
| 142 | + |
| 143 | + # Show the contrast |
| 144 | + print() |
| 145 | + print("-" * 60) |
| 146 | + print("COMPARISON") |
| 147 | + for market_id, label in MARKETS: |
| 148 | + cons_liq = conservative.get_liquidation_price(market_id) |
| 149 | + aggr_liq = aggressive.get_liquidation_price(market_id) |
| 150 | + aggr_pos = aggressive.get_position(market_id) |
| 151 | + cons_liq_str = "n/a (can't be liquidated)" if cons_liq == 0 else f"${cons_liq:.2f}" |
| 152 | + print(f" {label} conservative liq: {cons_liq_str}") |
| 153 | + if aggr_pos is None: |
| 154 | + reason = "already liquidated" if aggressive.get_health().has_been_liquidated else "no open position" |
| 155 | + print(f" {label} aggressive: {reason}") |
| 156 | + else: |
| 157 | + distance = abs(aggr_pos.mark_price - aggr_liq) |
| 158 | + print( |
| 159 | + f" {label} aggressive liq: ${aggr_liq:.2f} " |
| 160 | + f"(${distance:.2f} from mark)" |
| 161 | + ) |
| 162 | + print("-" * 60) |
| 163 | + |
| 164 | + await api_client.close() |
| 165 | + |
| 166 | + |
| 167 | +if __name__ == "__main__": |
| 168 | + asyncio.run(main()) |
0 commit comments