arthur-sdk 0.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
arthur_sdk/client.py ADDED
@@ -0,0 +1,1075 @@
1
+ """
2
+ Arthur SDK Client - Main trading interface for AI agents.
3
+
4
+ Trade in 3 lines:
5
+ from arthur_sdk import Arthur
6
+ client = Arthur.from_credentials_file("creds.json")
7
+ client.buy("ETH", usd=100)
8
+
9
+ Built for Arthur DEX: https://arthurdex.com
10
+ """
11
+
12
+ import json
13
+ import time
14
+ import hmac
15
+ import hashlib
16
+ import base64
17
+ from typing import Optional, Dict, List, Any, Union
18
+ from dataclasses import dataclass
19
+ import urllib.request
20
+ import urllib.error
21
+
22
+ from .exceptions import ArthurError, AuthError, OrderError, InsufficientFundsError
23
+ from .auth import generate_auth_headers
24
+
25
+
26
+ @dataclass
27
+ class Position:
28
+ """Represents an open position"""
29
+ symbol: str
30
+ side: str # "LONG" or "SHORT"
31
+ size: float
32
+ entry_price: float
33
+ mark_price: float
34
+ unrealized_pnl: float
35
+ leverage: float
36
+
37
+ @property
38
+ def pnl_percent(self) -> float:
39
+ if self.entry_price == 0:
40
+ return 0
41
+ return (self.unrealized_pnl / (self.size * self.entry_price)) * 100
42
+
43
+
44
+ @dataclass
45
+ class Order:
46
+ """Represents an order"""
47
+ order_id: str
48
+ symbol: str
49
+ side: str
50
+ order_type: str
51
+ price: Optional[float]
52
+ size: float
53
+ status: str
54
+ created_at: int
55
+
56
+
57
+ class Arthur:
58
+ """
59
+ Arthur SDK Client - Simple trading for AI agents.
60
+
61
+ Built for Arthur DEX (https://arthurdex.com) on Orderly Network.
62
+
63
+ Example:
64
+ from arthur_sdk import Arthur
65
+
66
+ client = Arthur(api_key="your_key", secret_key="your_secret")
67
+ client.buy("ETH", usd=100)
68
+ client.positions()
69
+ """
70
+
71
+ BASE_URL = "https://api-evm.orderly.org"
72
+ BROKER_ID = "arthur_dex"
73
+
74
+ # Symbol mappings for convenience
75
+ SYMBOL_MAP = {
76
+ "BTC": "PERP_BTC_USDC",
77
+ "ETH": "PERP_ETH_USDC",
78
+ "SOL": "PERP_SOL_USDC",
79
+ "ARB": "PERP_ARB_USDC",
80
+ "OP": "PERP_OP_USDC",
81
+ "AVAX": "PERP_AVAX_USDC",
82
+ "LINK": "PERP_LINK_USDC",
83
+ "DOGE": "PERP_DOGE_USDC",
84
+ "SUI": "PERP_SUI_USDC",
85
+ "TIA": "PERP_TIA_USDC",
86
+ "WOO": "PERP_WOO_USDC",
87
+ "ORDER": "PERP_ORDER_USDC",
88
+ }
89
+
90
+ def __init__(
91
+ self,
92
+ api_key: Optional[str] = None,
93
+ secret_key: Optional[str] = None,
94
+ account_id: Optional[str] = None,
95
+ testnet: bool = False,
96
+ ):
97
+ """
98
+ Initialize Arthur client.
99
+
100
+ Args:
101
+ api_key: Orderly API key (ed25519:xxx format)
102
+ secret_key: Orderly secret key (ed25519:xxx format)
103
+ account_id: Orderly account ID
104
+ testnet: Use testnet instead of mainnet
105
+ """
106
+ self.api_key = api_key
107
+ self.secret_key = secret_key
108
+ self.account_id = account_id
109
+
110
+ if testnet:
111
+ self.BASE_URL = "https://testnet-api-evm.orderly.org"
112
+
113
+ self._prices_cache = {}
114
+ self._prices_cache_time = 0
115
+
116
+ @classmethod
117
+ def from_credentials_file(cls, path: str, testnet: bool = False) -> "Arthur":
118
+ """
119
+ Load credentials from a JSON file.
120
+
121
+ Args:
122
+ path: Path to credentials JSON file
123
+ testnet: Use testnet
124
+
125
+ Returns:
126
+ Configured Arthur client
127
+ """
128
+ with open(path) as f:
129
+ creds = json.load(f)
130
+
131
+ return cls(
132
+ api_key=creds.get("orderly_key") or creds.get("api_key") or creds.get("key"),
133
+ secret_key=creds.get("orderly_secret") or creds.get("secret_key"),
134
+ account_id=creds.get("account_id"),
135
+ testnet=testnet,
136
+ )
137
+
138
+ def _normalize_symbol(self, symbol: str) -> str:
139
+ """Convert short symbol (ETH) to full symbol (PERP_ETH_USDC)"""
140
+ symbol = symbol.upper()
141
+ if symbol in self.SYMBOL_MAP:
142
+ return self.SYMBOL_MAP[symbol]
143
+ if symbol.startswith("PERP_"):
144
+ return symbol
145
+ return f"PERP_{symbol}_USDC"
146
+
147
+ def _sign_request(self, method: str, path: str, body: str = "") -> Dict[str, str]:
148
+ """Generate signed headers for authenticated request"""
149
+ if not self.api_key or not self.secret_key or not self.account_id:
150
+ raise AuthError("Missing credentials: api_key, secret_key, and account_id required")
151
+
152
+ return generate_auth_headers(
153
+ api_key=self.api_key,
154
+ secret_key=self.secret_key,
155
+ account_id=self.account_id,
156
+ method=method,
157
+ path=path,
158
+ body=body,
159
+ )
160
+
161
+ def _request(
162
+ self,
163
+ method: str,
164
+ path: str,
165
+ data: Optional[Dict] = None,
166
+ auth: bool = True
167
+ ) -> Dict:
168
+ """Make HTTP request to Orderly API"""
169
+ url = f"{self.BASE_URL}{path}"
170
+
171
+ # Only include body for methods that support it
172
+ if data and method.upper() in ("POST", "PUT", "PATCH"):
173
+ body = json.dumps(data)
174
+ else:
175
+ body = ""
176
+
177
+ headers = {}
178
+ if auth:
179
+ headers = self._sign_request(method, path, body)
180
+
181
+ # Set Content-Type based on request type
182
+ # DELETE requests must NOT have application/json Content-Type (Orderly rejects it)
183
+ if body:
184
+ headers["Content-Type"] = "application/json"
185
+ elif method.upper() == "DELETE":
186
+ headers["Content-Type"] = "application/x-www-form-urlencoded"
187
+ elif not auth:
188
+ headers["Content-Type"] = "application/json"
189
+
190
+ req = urllib.request.Request(
191
+ url,
192
+ data=body.encode() if body else None,
193
+ headers=headers,
194
+ method=method
195
+ )
196
+
197
+ try:
198
+ with urllib.request.urlopen(req, timeout=30) as resp:
199
+ return json.loads(resp.read().decode())
200
+ except urllib.error.HTTPError as e:
201
+ error_body = e.read().decode()
202
+ try:
203
+ error_data = json.loads(error_body)
204
+ raise ArthurError(f"API Error: {error_data.get('message', error_body)}")
205
+ except json.JSONDecodeError:
206
+ raise ArthurError(f"API Error ({e.code}): {error_body}")
207
+
208
+ # ==================== Market Data ====================
209
+
210
+ def price(self, symbol: str) -> float:
211
+ """
212
+ Get current price for a symbol.
213
+
214
+ Args:
215
+ symbol: Token symbol (e.g., "ETH" or "PERP_ETH_USDC")
216
+
217
+ Returns:
218
+ Current mark price
219
+ """
220
+ symbol = self._normalize_symbol(symbol)
221
+
222
+ # Check cache (5 second TTL)
223
+ now = time.time()
224
+ if now - self._prices_cache_time < 5 and symbol in self._prices_cache:
225
+ return self._prices_cache[symbol]
226
+
227
+ resp = self._request("GET", f"/v1/public/futures/{symbol}", auth=False)
228
+ if resp.get("success"):
229
+ price = float(resp["data"]["mark_price"])
230
+ self._prices_cache[symbol] = price
231
+ self._prices_cache_time = now
232
+ return price
233
+ raise ArthurError(f"Failed to get price for {symbol}")
234
+
235
+ def prices(self) -> Dict[str, float]:
236
+ """Get prices for all supported symbols"""
237
+ resp = self._request("GET", "/v1/public/futures", auth=False)
238
+ if resp.get("success"):
239
+ prices = {}
240
+ for item in resp["data"]["rows"]:
241
+ symbol = item["symbol"]
242
+ prices[symbol] = float(item["mark_price"])
243
+ # Also add short name
244
+ short = symbol.replace("PERP_", "").replace("_USDC", "")
245
+ prices[short] = float(item["mark_price"])
246
+ self._prices_cache = prices
247
+ self._prices_cache_time = time.time()
248
+ return prices
249
+ raise ArthurError("Failed to get prices")
250
+
251
+ # ==================== Account ====================
252
+
253
+ def balance(self) -> float:
254
+ """
255
+ Get available USDC balance.
256
+
257
+ Returns:
258
+ Available balance in USDC
259
+ """
260
+ resp = self._request("GET", "/v1/client/holding")
261
+ if resp.get("success"):
262
+ for holding in resp["data"]["holding"]:
263
+ if holding["token"] == "USDC":
264
+ return float(holding["holding"])
265
+ return 0.0
266
+
267
+ def equity(self) -> float:
268
+ """
269
+ Get total account equity (balance + unrealized PnL).
270
+
271
+ Returns:
272
+ Total equity in USDC
273
+ """
274
+ resp = self._request("GET", "/v1/client/holding")
275
+ if resp.get("success"):
276
+ return float(resp["data"].get("total_equity", 0))
277
+ return 0.0
278
+
279
+ # ==================== Positions ====================
280
+
281
+ def positions(self) -> List[Position]:
282
+ """
283
+ Get all open positions.
284
+
285
+ Returns:
286
+ List of Position objects
287
+ """
288
+ resp = self._request("GET", "/v1/positions")
289
+ if not resp.get("success"):
290
+ return []
291
+
292
+ positions = []
293
+ for row in resp["data"].get("rows", []):
294
+ if float(row.get("position_qty", 0)) != 0:
295
+ positions.append(Position(
296
+ symbol=row["symbol"],
297
+ side="LONG" if float(row["position_qty"]) > 0 else "SHORT",
298
+ size=abs(float(row["position_qty"])),
299
+ entry_price=float(row.get("average_open_price", 0)),
300
+ mark_price=float(row.get("mark_price", 0)),
301
+ unrealized_pnl=float(row.get("unrealized_pnl", 0)),
302
+ leverage=float(row.get("leverage", 1)),
303
+ ))
304
+ return positions
305
+
306
+ def position(self, symbol: str) -> Optional[Position]:
307
+ """
308
+ Get position for a specific symbol.
309
+
310
+ Args:
311
+ symbol: Token symbol
312
+
313
+ Returns:
314
+ Position object or None if no position
315
+ """
316
+ symbol = self._normalize_symbol(symbol)
317
+ for pos in self.positions():
318
+ if pos.symbol == symbol:
319
+ return pos
320
+ return None
321
+
322
+ def pnl(self) -> float:
323
+ """
324
+ Get total unrealized PnL across all positions.
325
+
326
+ Returns:
327
+ Total unrealized PnL in USDC
328
+ """
329
+ return sum(pos.unrealized_pnl for pos in self.positions())
330
+
331
+ # ==================== Trading ====================
332
+
333
+ def buy(
334
+ self,
335
+ symbol: str,
336
+ size: Optional[float] = None,
337
+ usd: Optional[float] = None,
338
+ price: Optional[float] = None,
339
+ reduce_only: bool = False,
340
+ ) -> Order:
341
+ """
342
+ Open or add to a long position.
343
+
344
+ Args:
345
+ symbol: Token symbol (e.g., "ETH")
346
+ size: Position size in base asset (e.g., 0.1 ETH)
347
+ usd: Position size in USD (alternative to size)
348
+ price: Limit price (None for market order)
349
+ reduce_only: Only reduce existing position
350
+
351
+ Returns:
352
+ Order object
353
+
354
+ Example:
355
+ client.buy("ETH", usd=100) # Buy $100 worth of ETH
356
+ client.buy("BTC", size=0.01) # Buy 0.01 BTC
357
+ """
358
+ return self._place_order(
359
+ symbol=symbol,
360
+ side="BUY",
361
+ size=size,
362
+ usd=usd,
363
+ price=price,
364
+ reduce_only=reduce_only,
365
+ )
366
+
367
+ def sell(
368
+ self,
369
+ symbol: str,
370
+ size: Optional[float] = None,
371
+ usd: Optional[float] = None,
372
+ price: Optional[float] = None,
373
+ reduce_only: bool = False,
374
+ ) -> Order:
375
+ """
376
+ Open or add to a short position.
377
+
378
+ Args:
379
+ symbol: Token symbol
380
+ size: Position size in base asset
381
+ usd: Position size in USD
382
+ price: Limit price (None for market order)
383
+ reduce_only: Only reduce existing position
384
+
385
+ Returns:
386
+ Order object
387
+ """
388
+ return self._place_order(
389
+ symbol=symbol,
390
+ side="SELL",
391
+ size=size,
392
+ usd=usd,
393
+ price=price,
394
+ reduce_only=reduce_only,
395
+ )
396
+
397
+ def close(self, symbol: str, size: Optional[float] = None) -> Optional[Order]:
398
+ """
399
+ Close a position (partially or fully).
400
+
401
+ Args:
402
+ symbol: Token symbol
403
+ size: Size to close (None = close entire position)
404
+
405
+ Returns:
406
+ Order object, or None if no position to close
407
+ """
408
+ pos = self.position(symbol)
409
+ if not pos:
410
+ return None
411
+
412
+ close_size = size or pos.size
413
+ close_side = "SELL" if pos.side == "LONG" else "BUY"
414
+
415
+ return self._place_order(
416
+ symbol=symbol,
417
+ side=close_side,
418
+ size=close_size,
419
+ reduce_only=True,
420
+ )
421
+
422
+ def close_all(self) -> List[Order]:
423
+ """
424
+ Close all open positions.
425
+
426
+ Returns:
427
+ List of Order objects for each closed position
428
+ """
429
+ orders = []
430
+ for pos in self.positions():
431
+ order = self.close(pos.symbol)
432
+ if order:
433
+ orders.append(order)
434
+ return orders
435
+
436
+ def _place_order(
437
+ self,
438
+ symbol: str,
439
+ side: str,
440
+ size: Optional[float] = None,
441
+ usd: Optional[float] = None,
442
+ price: Optional[float] = None,
443
+ reduce_only: bool = False,
444
+ ) -> Order:
445
+ """Internal method to place an order"""
446
+ symbol = self._normalize_symbol(symbol)
447
+
448
+ # Calculate size from USD if needed
449
+ if usd and not size:
450
+ current_price = self.price(symbol)
451
+ size = usd / current_price
452
+
453
+ if not size:
454
+ raise OrderError("Must specify either size or usd")
455
+
456
+ order_type = "LIMIT" if price else "MARKET"
457
+
458
+ order_data = {
459
+ "symbol": symbol,
460
+ "side": side,
461
+ "order_type": order_type,
462
+ "order_quantity": str(size),
463
+ "reduce_only": reduce_only,
464
+ }
465
+
466
+ if price:
467
+ order_data["order_price"] = str(price)
468
+
469
+ resp = self._request("POST", "/v1/order", data=order_data)
470
+
471
+ if not resp.get("success"):
472
+ error = resp.get("message", "Unknown error")
473
+ if "insufficient" in error.lower():
474
+ raise InsufficientFundsError(error)
475
+ raise OrderError(error)
476
+
477
+ data = resp["data"]
478
+ return Order(
479
+ order_id=str(data["order_id"]),
480
+ symbol=symbol,
481
+ side=side,
482
+ order_type=order_type,
483
+ price=price,
484
+ size=size,
485
+ status=data.get("status", "NEW"),
486
+ created_at=int(time.time() * 1000),
487
+ )
488
+
489
+ # ==================== Risk Management ====================
490
+
491
+ def set_leverage(self, symbol: str, leverage: int) -> bool:
492
+ """
493
+ Set leverage for a symbol.
494
+
495
+ Args:
496
+ symbol: Token symbol
497
+ leverage: Leverage multiplier (1-50)
498
+
499
+ Returns:
500
+ True if successful
501
+ """
502
+ symbol = self._normalize_symbol(symbol)
503
+ resp = self._request(
504
+ "POST",
505
+ "/v1/client/leverage",
506
+ data={"symbol": symbol, "leverage": leverage}
507
+ )
508
+ return resp.get("success", False)
509
+
510
+ def set_stop_loss(
511
+ self,
512
+ symbol: str,
513
+ price: Optional[float] = None,
514
+ pct: Optional[float] = None,
515
+ ) -> Order:
516
+ """
517
+ Set stop loss for a position.
518
+
519
+ Args:
520
+ symbol: Token symbol
521
+ price: Stop price
522
+ pct: Stop loss percentage from entry (alternative to price)
523
+
524
+ Returns:
525
+ Order object for stop loss
526
+ """
527
+ pos = self.position(symbol)
528
+ if not pos:
529
+ raise OrderError(f"No position for {symbol}")
530
+
531
+ if pct and not price:
532
+ if pos.side == "LONG":
533
+ price = pos.entry_price * (1 - pct / 100)
534
+ else:
535
+ price = pos.entry_price * (1 + pct / 100)
536
+
537
+ if not price:
538
+ raise OrderError("Must specify price or pct")
539
+
540
+ # Place stop loss order
541
+ side = "SELL" if pos.side == "LONG" else "BUY"
542
+ return self._place_order(
543
+ symbol=symbol,
544
+ side=side,
545
+ size=pos.size,
546
+ price=price,
547
+ reduce_only=True,
548
+ )
549
+
550
+ # ==================== Info ====================
551
+
552
+ def orders(self, symbol: Optional[str] = None) -> List[Order]:
553
+ """
554
+ Get open orders.
555
+
556
+ Args:
557
+ symbol: Filter by symbol (optional)
558
+
559
+ Returns:
560
+ List of Order objects
561
+ """
562
+ path = "/v1/orders"
563
+ if symbol:
564
+ path += f"?symbol={self._normalize_symbol(symbol)}"
565
+
566
+ resp = self._request("GET", path)
567
+ if not resp.get("success"):
568
+ return []
569
+
570
+ orders = []
571
+ for row in resp["data"].get("rows", []):
572
+ orders.append(Order(
573
+ order_id=str(row["order_id"]),
574
+ symbol=row["symbol"],
575
+ side=row["side"],
576
+ order_type=row["type"],
577
+ price=float(row.get("price")) if row.get("price") else None,
578
+ size=float(row["quantity"]),
579
+ status=row["status"],
580
+ created_at=int(row["created_time"]),
581
+ ))
582
+ return orders
583
+
584
+ def cancel(self, order_id: str, symbol: str) -> bool:
585
+ """
586
+ Cancel an order.
587
+
588
+ Args:
589
+ order_id: Order ID to cancel
590
+ symbol: Symbol of the order
591
+
592
+ Returns:
593
+ True if cancelled successfully
594
+ """
595
+ symbol = self._normalize_symbol(symbol)
596
+ resp = self._request(
597
+ "DELETE",
598
+ f"/v1/order?order_id={order_id}&symbol={symbol}"
599
+ )
600
+ return resp.get("success", False)
601
+
602
+ def cancel_all(self, symbol: Optional[str] = None) -> int:
603
+ """
604
+ Cancel all open orders.
605
+
606
+ Args:
607
+ symbol: Cancel only orders for this symbol (optional)
608
+
609
+ Returns:
610
+ Number of orders cancelled
611
+ """
612
+ path = "/v1/orders"
613
+ if symbol:
614
+ path += f"?symbol={self._normalize_symbol(symbol)}"
615
+
616
+ resp = self._request("DELETE", path)
617
+ if resp.get("success"):
618
+ return resp["data"].get("cancelled_count", 0)
619
+ return 0
620
+
621
+ # ==================== Market Making ====================
622
+
623
+ def limit_buy(
624
+ self,
625
+ symbol: str,
626
+ price: float,
627
+ size: Optional[float] = None,
628
+ usd: Optional[float] = None,
629
+ post_only: bool = False,
630
+ ) -> Order:
631
+ """
632
+ Place a limit buy order.
633
+
634
+ Args:
635
+ symbol: Token symbol
636
+ price: Limit price
637
+ size: Order size in base asset
638
+ usd: Order size in USD (alternative)
639
+ post_only: If True, order will only be maker (cancel if would take)
640
+
641
+ Returns:
642
+ Order object
643
+ """
644
+ return self._place_limit_order(
645
+ symbol=symbol,
646
+ side="BUY",
647
+ price=price,
648
+ size=size,
649
+ usd=usd,
650
+ post_only=post_only,
651
+ )
652
+
653
+ def limit_sell(
654
+ self,
655
+ symbol: str,
656
+ price: float,
657
+ size: Optional[float] = None,
658
+ usd: Optional[float] = None,
659
+ post_only: bool = False,
660
+ ) -> Order:
661
+ """
662
+ Place a limit sell order.
663
+
664
+ Args:
665
+ symbol: Token symbol
666
+ price: Limit price
667
+ size: Order size in base asset
668
+ usd: Order size in USD (alternative)
669
+ post_only: If True, order will only be maker
670
+
671
+ Returns:
672
+ Order object
673
+ """
674
+ return self._place_limit_order(
675
+ symbol=symbol,
676
+ side="SELL",
677
+ price=price,
678
+ size=size,
679
+ usd=usd,
680
+ post_only=post_only,
681
+ )
682
+
683
+ def _place_limit_order(
684
+ self,
685
+ symbol: str,
686
+ side: str,
687
+ price: float,
688
+ size: Optional[float] = None,
689
+ usd: Optional[float] = None,
690
+ post_only: bool = False,
691
+ reduce_only: bool = False,
692
+ ) -> Order:
693
+ """Internal method to place a limit order"""
694
+ symbol = self._normalize_symbol(symbol)
695
+
696
+ # Calculate size from USD if needed
697
+ if usd and not size:
698
+ size = usd / price # Use limit price for size calc
699
+
700
+ if not size:
701
+ raise OrderError("Must specify either size or usd")
702
+
703
+ order_type = "POST_ONLY" if post_only else "LIMIT"
704
+
705
+ order_data = {
706
+ "symbol": symbol,
707
+ "side": side,
708
+ "order_type": order_type,
709
+ "order_quantity": str(size),
710
+ "order_price": str(price),
711
+ "reduce_only": reduce_only,
712
+ }
713
+
714
+ resp = self._request("POST", "/v1/order", data=order_data)
715
+
716
+ if not resp.get("success"):
717
+ error = resp.get("message", "Unknown error")
718
+ if "insufficient" in error.lower():
719
+ raise InsufficientFundsError(error)
720
+ raise OrderError(error)
721
+
722
+ data = resp["data"]
723
+ return Order(
724
+ order_id=str(data["order_id"]),
725
+ symbol=symbol,
726
+ side=side,
727
+ order_type=order_type,
728
+ price=price,
729
+ size=size,
730
+ status=data.get("status", "NEW"),
731
+ created_at=int(time.time() * 1000),
732
+ )
733
+
734
+ def orderbook(self, symbol: str, depth: int = 10) -> Dict[str, List]:
735
+ """
736
+ Get orderbook for a symbol.
737
+
738
+ Args:
739
+ symbol: Token symbol
740
+ depth: Number of levels to return (default 10)
741
+
742
+ Returns:
743
+ Dict with 'bids' and 'asks' lists of [price, size] pairs
744
+ """
745
+ symbol = self._normalize_symbol(symbol)
746
+ resp = self._request("GET", f"/v1/orderbook/{symbol}", auth=False)
747
+
748
+ if not resp.get("success"):
749
+ raise ArthurError(f"Failed to get orderbook for {symbol}")
750
+
751
+ data = resp["data"]
752
+
753
+ # Handle both formats: [{price, quantity}] or [[price, qty]]
754
+ def parse_levels(levels):
755
+ result = []
756
+ for level in levels[:depth]:
757
+ if isinstance(level, dict):
758
+ result.append([float(level["price"]), float(level["quantity"])])
759
+ else:
760
+ result.append([float(level[0]), float(level[1])])
761
+ return result
762
+
763
+ return {
764
+ "bids": parse_levels(data.get("bids", [])),
765
+ "asks": parse_levels(data.get("asks", [])),
766
+ "timestamp": data.get("timestamp", int(time.time() * 1000)),
767
+ }
768
+
769
+ def spread(self, symbol: str) -> Dict[str, float]:
770
+ """
771
+ Get current spread for a symbol.
772
+
773
+ Args:
774
+ symbol: Token symbol
775
+
776
+ Returns:
777
+ Dict with best_bid, best_ask, mid, spread_pct, spread_bps
778
+ """
779
+ ob = self.orderbook(symbol, depth=1)
780
+
781
+ if not ob["bids"] or not ob["asks"]:
782
+ raise ArthurError(f"No orderbook data for {symbol}")
783
+
784
+ best_bid = ob["bids"][0][0]
785
+ best_ask = ob["asks"][0][0]
786
+ mid = (best_bid + best_ask) / 2
787
+ spread_abs = best_ask - best_bid
788
+ spread_pct = (spread_abs / mid) * 100
789
+ spread_bps = spread_pct * 100
790
+
791
+ return {
792
+ "best_bid": best_bid,
793
+ "best_ask": best_ask,
794
+ "mid": mid,
795
+ "spread": spread_abs,
796
+ "spread_pct": spread_pct,
797
+ "spread_bps": spread_bps,
798
+ }
799
+
800
+ def get_order(self, order_id: str) -> Optional[Order]:
801
+ """
802
+ Get order by ID.
803
+
804
+ Args:
805
+ order_id: Order ID
806
+
807
+ Returns:
808
+ Order object or None if not found
809
+ """
810
+ resp = self._request("GET", f"/v1/order/{order_id}")
811
+
812
+ if not resp.get("success"):
813
+ return None
814
+
815
+ row = resp["data"]
816
+ return Order(
817
+ order_id=str(row["order_id"]),
818
+ symbol=row["symbol"],
819
+ side=row["side"],
820
+ order_type=row["type"],
821
+ price=float(row.get("price")) if row.get("price") else None,
822
+ size=float(row["quantity"]),
823
+ status=row["status"],
824
+ created_at=int(row["created_time"]),
825
+ )
826
+
827
+ def quote(
828
+ self,
829
+ symbol: str,
830
+ bid_price: float,
831
+ ask_price: float,
832
+ size: float,
833
+ cancel_existing: bool = True,
834
+ ) -> Dict[str, Order]:
835
+ """
836
+ Place a two-sided quote (bid + ask).
837
+
838
+ Args:
839
+ symbol: Token symbol
840
+ bid_price: Bid (buy) price
841
+ ask_price: Ask (sell) price
842
+ size: Size for each side
843
+ cancel_existing: Cancel existing orders first
844
+
845
+ Returns:
846
+ Dict with 'bid' and 'ask' Order objects
847
+ """
848
+ symbol = self._normalize_symbol(symbol)
849
+
850
+ if cancel_existing:
851
+ self.cancel_all(symbol)
852
+
853
+ bid_order = self.limit_buy(symbol, price=bid_price, size=size, post_only=True)
854
+ ask_order = self.limit_sell(symbol, price=ask_price, size=size, post_only=True)
855
+
856
+ return {
857
+ "bid": bid_order,
858
+ "ask": ask_order,
859
+ }
860
+
861
+ # ==================== Convenience ====================
862
+
863
+ def summary(self) -> Dict[str, Any]:
864
+ """
865
+ Get account summary including balance, positions, and PnL.
866
+
867
+ Returns:
868
+ Dict with account summary
869
+ """
870
+ positions = self.positions()
871
+ total_pnl = sum(p.unrealized_pnl for p in positions)
872
+
873
+ return {
874
+ "balance": self.balance(),
875
+ "equity": self.equity(),
876
+ "positions": len(positions),
877
+ "unrealized_pnl": total_pnl,
878
+ "position_details": [
879
+ {
880
+ "symbol": p.symbol.replace("PERP_", "").replace("_USDC", ""),
881
+ "side": p.side,
882
+ "size": p.size,
883
+ "entry": p.entry_price,
884
+ "mark": p.mark_price,
885
+ "pnl": p.unrealized_pnl,
886
+ "pnl_pct": p.pnl_percent,
887
+ }
888
+ for p in positions
889
+ ]
890
+ }
891
+
892
+ # ==================== Settlement & Withdrawal ====================
893
+
894
+ def settle_pnl(self) -> bool:
895
+ """
896
+ Settle unrealized PnL to available balance.
897
+
898
+ Call this before withdrawing to ensure all PnL is settled.
899
+
900
+ Returns:
901
+ True if settlement successful
902
+ """
903
+ resp = self._request("POST", "/v1/settle_pnl")
904
+ return resp.get("success", False)
905
+
906
+ def free_collateral(self) -> float:
907
+ """
908
+ Get free collateral (withdrawable balance after margin requirements).
909
+
910
+ This is the maximum amount you can withdraw without closing positions.
911
+
912
+ Returns:
913
+ Free collateral in USDC
914
+ """
915
+ resp = self._request("GET", "/v1/positions")
916
+ if resp.get("success"):
917
+ return resp["data"].get("free_collateral", 0)
918
+ return 0
919
+
920
+ def withdraw(
921
+ self,
922
+ amount: float,
923
+ wallet_private_key: str,
924
+ chain_id: int = 42161,
925
+ receiver: Optional[str] = None,
926
+ ) -> Dict[str, Any]:
927
+ """
928
+ Withdraw USDC from Orderly to on-chain wallet.
929
+
930
+ Requires wallet private key for EIP-712 signature.
931
+
932
+ Args:
933
+ amount: Amount of USDC to withdraw
934
+ wallet_private_key: Wallet private key for signing (0x...)
935
+ chain_id: Chain ID to withdraw to (42161=Arbitrum, 8453=Base, 10=Optimism)
936
+ receiver: Receiver address (defaults to wallet address)
937
+
938
+ Returns:
939
+ Dict with withdraw_id
940
+
941
+ Example:
942
+ result = client.withdraw(
943
+ amount=100,
944
+ wallet_private_key="0x...",
945
+ )
946
+ print(f"Withdrawal ID: {result['withdraw_id']}")
947
+
948
+ Raises:
949
+ ArthurError: If withdrawal fails (e.g., insufficient balance, margin occupied)
950
+ """
951
+ try:
952
+ from eth_account import Account
953
+ from eth_account.messages import encode_typed_data
954
+ except ImportError:
955
+ raise ImportError(
956
+ "eth-account is required for withdrawals. Install with: pip install eth-account"
957
+ )
958
+
959
+ # Derive wallet address from private key
960
+ account = Account.from_key(wallet_private_key)
961
+ wallet_address = account.address
962
+ if receiver is None:
963
+ receiver = wallet_address
964
+
965
+ # Get withdrawal nonce
966
+ nonce_resp = self._request("GET", "/v1/withdraw_nonce")
967
+ if not nonce_resp.get("success"):
968
+ raise ArthurError(f"Failed to get withdrawal nonce: {nonce_resp.get('message')}")
969
+ withdraw_nonce = nonce_resp["data"]["withdraw_nonce"]
970
+
971
+ # Prepare EIP-712 typed data
972
+ timestamp_ms = int(time.time() * 1000)
973
+ amount_raw = int(amount * 1_000_000) # USDC has 6 decimals
974
+
975
+ typed_data = {
976
+ "types": {
977
+ "EIP712Domain": [
978
+ {"name": "name", "type": "string"},
979
+ {"name": "version", "type": "string"},
980
+ {"name": "chainId", "type": "uint256"},
981
+ {"name": "verifyingContract", "type": "address"}
982
+ ],
983
+ "Withdraw": [
984
+ {"name": "brokerId", "type": "string"},
985
+ {"name": "chainId", "type": "uint256"},
986
+ {"name": "receiver", "type": "address"},
987
+ {"name": "token", "type": "string"},
988
+ {"name": "amount", "type": "uint256"},
989
+ {"name": "withdrawNonce", "type": "uint64"},
990
+ {"name": "timestamp", "type": "uint64"}
991
+ ]
992
+ },
993
+ "primaryType": "Withdraw",
994
+ "domain": {
995
+ "name": "Orderly",
996
+ "version": "1",
997
+ "chainId": chain_id,
998
+ "verifyingContract": "0x6F7a338F2aA472838dEFD3283eB360d4Dff5D203"
999
+ },
1000
+ "message": {
1001
+ "brokerId": self.BROKER_ID,
1002
+ "chainId": chain_id,
1003
+ "receiver": receiver,
1004
+ "token": "USDC",
1005
+ "amount": amount_raw,
1006
+ "withdrawNonce": withdraw_nonce,
1007
+ "timestamp": timestamp_ms
1008
+ }
1009
+ }
1010
+
1011
+ # Sign with wallet
1012
+ signable = encode_typed_data(full_message=typed_data)
1013
+ signed = account.sign_message(signable)
1014
+ user_signature = '0x' + signed.signature.hex()
1015
+
1016
+ # Submit withdrawal
1017
+ withdraw_body = {
1018
+ "userAddress": wallet_address,
1019
+ "message": {
1020
+ "brokerId": self.BROKER_ID,
1021
+ "chainId": chain_id,
1022
+ "receiver": receiver,
1023
+ "token": "USDC",
1024
+ "amount": str(amount_raw),
1025
+ "withdrawNonce": withdraw_nonce,
1026
+ "timestamp": timestamp_ms
1027
+ },
1028
+ "signature": user_signature,
1029
+ "verifyingContract": "0x6F7a338F2aA472838dEFD3283eB360d4Dff5D203"
1030
+ }
1031
+
1032
+ result = self._request("POST", "/v1/withdraw_request", withdraw_body)
1033
+
1034
+ if not result.get("success"):
1035
+ raise ArthurError(f"Withdrawal failed: {result.get('message')}")
1036
+
1037
+ return result["data"]
1038
+
1039
+ def withdraw_all(
1040
+ self,
1041
+ wallet_private_key: str,
1042
+ chain_id: int = 42161,
1043
+ ) -> Dict[str, Any]:
1044
+ """
1045
+ Withdraw all available USDC (free collateral) to on-chain wallet.
1046
+
1047
+ This withdraws the maximum possible amount without affecting open positions.
1048
+
1049
+ Args:
1050
+ wallet_private_key: Wallet private key for signing (0x...)
1051
+ chain_id: Chain ID to withdraw to (42161=Arbitrum, 8453=Base)
1052
+
1053
+ Returns:
1054
+ Dict with withdraw_id and amount
1055
+ """
1056
+ # Get free collateral (max withdrawable)
1057
+ free = self.free_collateral()
1058
+
1059
+ if free <= 0.01:
1060
+ raise ArthurError("No funds available to withdraw")
1061
+
1062
+ # Leave small buffer for rounding
1063
+ amount = free - 0.01
1064
+
1065
+ result = self.withdraw(
1066
+ amount=amount,
1067
+ wallet_private_key=wallet_private_key,
1068
+ chain_id=chain_id,
1069
+ )
1070
+
1071
+ result["amount"] = amount
1072
+ return result
1073
+
1074
+ def __repr__(self) -> str:
1075
+ return f"Arthur(account_id={self.account_id[:8]}...)" if self.account_id else "Arthur(not authenticated)"