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/__init__.py +35 -0
- arthur_sdk/auth.py +149 -0
- arthur_sdk/cli.py +183 -0
- arthur_sdk/client.py +1075 -0
- arthur_sdk/exceptions.py +31 -0
- arthur_sdk/market_maker.py +326 -0
- arthur_sdk/strategies.py +576 -0
- arthur_sdk-0.2.1.dist-info/METADATA +225 -0
- arthur_sdk-0.2.1.dist-info/RECORD +13 -0
- arthur_sdk-0.2.1.dist-info/WHEEL +5 -0
- arthur_sdk-0.2.1.dist-info/entry_points.txt +2 -0
- arthur_sdk-0.2.1.dist-info/licenses/LICENSE +21 -0
- arthur_sdk-0.2.1.dist-info/top_level.txt +1 -0
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)"
|