Skip to content

Commit 6c0ed95

Browse files
authored
Paper Trading
Adds paper_client to add Paper Trading support.
1 parent 3455381 commit 6c0ed95

23 files changed

Lines changed: 3071 additions & 3 deletions

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ target/
6565
.ipynb_checkpoints
6666
openapi-generator-cli.jar
6767

68-
68+
.DS_Store
6969
.idea
7070

7171
examples/secrets.py

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,23 @@ python examples/ws.py
6464
python examples/create_cancel_order.py
6565
```
6666

67+
## Paper Trading
68+
69+
#### [Snapshot Mode](examples/paper_trading_snapshot.py)
70+
```sh
71+
python examples/paper_trading_snapshot.py
72+
```
73+
74+
#### [Live Mode](examples/paper_trading_live.py)
75+
```sh
76+
python examples/paper_trading_live.py
77+
```
78+
79+
#### [Health Inspection](examples/paper_trading_health.py)
80+
```sh
81+
python examples/paper_trading_health.py
82+
```
83+
6784
## Documentation for API Endpoints
6885

6986
All URIs are relative to *https://mainnet.zklighter.elliot.ai*

examples/README.md

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,24 @@ Spot assets (like ETH) need to have both the from and to route set to `spot`.
152152
You can get all `asset_id`s by following the example below:
153153
- `spot_get_order_books.py`
154154

155+
## Paper Trading (simulated, no API keys required)
156+
Paper trading lets you simulate trades against real order book data without submitting transactions.
157+
158+
- `paper_trading_snapshot.py`
159+
- fetches a one-time order book snapshot and simulates buy/sell trades
160+
- prints fills, collateral, and trade history
161+
162+
- `paper_trading_live.py`
163+
- subscribes to real-time order book updates via WebSocket
164+
- simulates trades against continuously updated book state
165+
- the paper client uses its own internal WebSocket listener (not `lighter.WsClient`)
166+
167+
- `paper_trading_health.py`
168+
- opens positions across multiple markets
169+
- compares conservative vs aggressive leverage on the same two-market portfolio
170+
- inspects account health, margin usage, leverage, and liquidation prices
171+
155172
## Setup steps for mainnet
156173
- deposit money on Lighter to create an account first
157174
- change the URL to `mainnet.zklighter.elliot.ai`
158-
- repeat setup step
175+
- repeat setup step

examples/paper_trading_health.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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())

examples/paper_trading_live.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
"""Paper trading — live mode.
2+
3+
Subscribes to real-time order book updates via WebSocket and simulates
4+
trades against continuously updated book state.
5+
6+
The paper client manages its own internal WebSocket listener and
7+
sorted order book.
8+
"""
9+
10+
import asyncio
11+
import lighter
12+
13+
14+
async def main():
15+
api_client = lighter.ApiClient(
16+
configuration=lighter.Configuration(
17+
host="https://mainnet.zklighter.elliot.ai",
18+
),
19+
)
20+
21+
paper = lighter.PaperClient(api_client, initial_collateral_usdc=10_000)
22+
23+
# Start live tracking — connects a WebSocket and waits for the initial
24+
# order book snapshot before returning.
25+
await paper.track_market(market_id=0) # ETH-PERP
26+
print("Live tracking started for ETH-PERP")
27+
28+
# Wait a moment to accumulate order book updates
29+
await asyncio.sleep(2)
30+
31+
# Place a market buy
32+
result = await paper.create_paper_order(
33+
lighter.PaperOrderRequest(
34+
market_id=0,
35+
side=lighter.PaperOrderSide.BUY,
36+
base_amount=0.1,
37+
)
38+
)
39+
print(f"BUY filled={result.filled_size} avg_price={result.avg_price:.2f}")
40+
41+
# Let the book update for a bit, then close with a sell
42+
await asyncio.sleep(2)
43+
44+
result = await paper.create_paper_order(
45+
lighter.PaperOrderRequest(
46+
market_id=0,
47+
side=lighter.PaperOrderSide.SELL,
48+
base_amount=0.1,
49+
)
50+
)
51+
print(f"SELL filled={result.filled_size} avg_price={result.avg_price:.2f}")
52+
53+
# Print final account state
54+
account = paper.get_account()
55+
print(f"\nCollateral: {account.collateral:.2f} USDC")
56+
print(f"Portfolio value: {paper.get_portfolio_value():.2f} USDC")
57+
58+
# Stop tracking and clean up
59+
await paper.close()
60+
await api_client.close()
61+
62+
63+
if __name__ == "__main__":
64+
asyncio.run(main())

examples/paper_trading_snapshot.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
"""Paper trading — snapshot mode.
2+
3+
Fetches a one-time order book snapshot and simulates trades against it.
4+
No API keys or signing required; only read-only API access is used.
5+
"""
6+
7+
import asyncio
8+
import lighter
9+
10+
11+
async def main():
12+
api_client = lighter.ApiClient(
13+
configuration=lighter.Configuration(
14+
host="https://mainnet.zklighter.elliot.ai",
15+
),
16+
)
17+
18+
paper = lighter.PaperClient(api_client, initial_collateral_usdc=10_000)
19+
20+
# Load a snapshot of the ETH-PERP order book (market_id=0)
21+
await paper.track_market_snapshot(market_id=0)
22+
23+
# Simulate a market buy for 0.5 ETH
24+
result = await paper.create_paper_order(
25+
lighter.PaperOrderRequest(
26+
market_id=0,
27+
side=lighter.PaperOrderSide.BUY,
28+
base_amount=0.5,
29+
)
30+
)
31+
print(f"BUY filled={result.filled_size} avg_price={result.avg_price:.2f} fee={result.total_fee:.4f}")
32+
33+
# Simulate a market sell to close the position
34+
result = await paper.create_paper_order(
35+
lighter.PaperOrderRequest(
36+
market_id=0,
37+
side=lighter.PaperOrderSide.SELL,
38+
base_amount=0.5,
39+
)
40+
)
41+
print(f"SELL filled={result.filled_size} avg_price={result.avg_price:.2f} fee={result.total_fee:.4f}")
42+
43+
# Print account summary
44+
account = paper.get_account()
45+
print(f"\nCollateral: {account.collateral:.2f} USDC")
46+
print(f"Trades: {len(account.trades)}")
47+
for trade in account.trades:
48+
side = "BUY" if trade.side == lighter.PaperOrderSide.BUY else "SELL"
49+
print(f" {side} {trade.size} @ {trade.price:.2f} pnl={trade.realized_pnl:.4f}")
50+
51+
await api_client.close()
52+
53+
54+
if __name__ == "__main__":
55+
asyncio.run(main())

lighter/__init__.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,4 +204,19 @@
204204
from lighter.models.withdraw_history_item import WithdrawHistoryItem
205205
from lighter.models.zk_lighter_info import ZkLighterInfo
206206
from lighter.ws_client import WsClient
207-
from lighter.signer_client import SignerClient, create_api_key
207+
from lighter.signer_client import SignerClient, create_api_key
208+
from lighter.paper_client import (
209+
InMemoryOrderBook,
210+
OrderBookLevel,
211+
PaperAccount,
212+
PaperAccountHealth,
213+
PaperClient,
214+
PaperFill,
215+
PaperHealthStatus,
216+
PaperOrderRequest,
217+
PaperOrderResult,
218+
PaperOrderSide,
219+
PaperOrderType,
220+
PaperPosition,
221+
PaperTrade,
222+
)

0 commit comments

Comments
 (0)