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/exceptions.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Arthur SDK Exceptions"""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ArthurError(Exception):
|
|
5
|
+
"""Base exception for Arthur SDK"""
|
|
6
|
+
pass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class AuthError(ArthurError):
|
|
10
|
+
"""Authentication failed"""
|
|
11
|
+
pass
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class OrderError(ArthurError):
|
|
15
|
+
"""Order placement failed"""
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InsufficientFundsError(ArthurError):
|
|
20
|
+
"""Not enough balance for operation"""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PositionError(ArthurError):
|
|
25
|
+
"""Position operation failed"""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RateLimitError(ArthurError):
|
|
30
|
+
"""Rate limit exceeded"""
|
|
31
|
+
pass
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Market Maker Strategy Runner for Orderly Network.
|
|
3
|
+
|
|
4
|
+
Simple market making: place limit orders on both sides, manage inventory,
|
|
5
|
+
capture spread.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Optional, Dict, Any
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from .client import Arthur, Order, Position
|
|
15
|
+
from .exceptions import ArthurError, OrderError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass
|
|
19
|
+
class MMConfig:
|
|
20
|
+
"""Market maker configuration"""
|
|
21
|
+
name: str
|
|
22
|
+
symbol: str
|
|
23
|
+
|
|
24
|
+
# Spread settings
|
|
25
|
+
base_spread_bps: float = 30 # Base spread in basis points
|
|
26
|
+
min_spread_bps: float = 15 # Minimum spread
|
|
27
|
+
|
|
28
|
+
# Size settings
|
|
29
|
+
order_size_usd: float = 50 # Size per side in USD
|
|
30
|
+
max_inventory_usd: float = 300 # Max inventory before skewing
|
|
31
|
+
levels: int = 1 # Number of price levels
|
|
32
|
+
|
|
33
|
+
# Skew settings
|
|
34
|
+
skew_per_100_usd: float = 5 # BPS to skew per $100 inventory
|
|
35
|
+
|
|
36
|
+
# Risk settings
|
|
37
|
+
max_position_usd: float = 500
|
|
38
|
+
stop_loss_pct: float = 5
|
|
39
|
+
daily_loss_limit_usd: float = 50
|
|
40
|
+
|
|
41
|
+
# Execution
|
|
42
|
+
post_only: bool = True
|
|
43
|
+
requote_interval_sec: float = 30
|
|
44
|
+
min_edge_bps: float = 5
|
|
45
|
+
|
|
46
|
+
# Flags
|
|
47
|
+
dry_run: bool = True
|
|
48
|
+
log_quotes: bool = True
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_file(cls, path: str) -> "MMConfig":
|
|
52
|
+
"""Load config from JSON file"""
|
|
53
|
+
with open(Path(path).expanduser()) as f:
|
|
54
|
+
data = json.load(f)
|
|
55
|
+
|
|
56
|
+
mm = data.get("market_making", {})
|
|
57
|
+
risk = data.get("risk", {})
|
|
58
|
+
exec_cfg = data.get("execution", {})
|
|
59
|
+
flags = data.get("flags", {})
|
|
60
|
+
|
|
61
|
+
return cls(
|
|
62
|
+
name=data.get("name", "Unnamed MM"),
|
|
63
|
+
symbol=data.get("symbol", "ORDER"),
|
|
64
|
+
base_spread_bps=mm.get("base_spread_bps", 30),
|
|
65
|
+
min_spread_bps=mm.get("min_spread_bps", 15),
|
|
66
|
+
order_size_usd=mm.get("order_size_usd", 50),
|
|
67
|
+
max_inventory_usd=mm.get("max_inventory_usd", 300),
|
|
68
|
+
levels=mm.get("levels", 1),
|
|
69
|
+
skew_per_100_usd=mm.get("skew_per_100_usd", 5),
|
|
70
|
+
max_position_usd=risk.get("max_position_usd", 500),
|
|
71
|
+
stop_loss_pct=risk.get("stop_loss_pct", 5),
|
|
72
|
+
daily_loss_limit_usd=risk.get("daily_loss_limit_usd", 50),
|
|
73
|
+
post_only=exec_cfg.get("post_only", True),
|
|
74
|
+
requote_interval_sec=mm.get("requote_interval_sec", 30),
|
|
75
|
+
min_edge_bps=exec_cfg.get("min_edge_bps", 5),
|
|
76
|
+
dry_run=flags.get("dry_run", True),
|
|
77
|
+
log_quotes=flags.get("log_quotes", True),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class MarketMaker:
|
|
82
|
+
"""
|
|
83
|
+
Simple market maker for Orderly Network.
|
|
84
|
+
|
|
85
|
+
Strategy:
|
|
86
|
+
1. Get mid price from orderbook
|
|
87
|
+
2. Calculate bid/ask prices based on spread
|
|
88
|
+
3. Skew quotes based on inventory
|
|
89
|
+
4. Place/update limit orders
|
|
90
|
+
5. Repeat every N seconds
|
|
91
|
+
|
|
92
|
+
Example:
|
|
93
|
+
client = Arthur.from_credentials_file("creds.json")
|
|
94
|
+
mm = MarketMaker(client, MMConfig.from_file("strategies/order-mm.json"))
|
|
95
|
+
mm.run_once() # Single quote cycle
|
|
96
|
+
mm.run_loop() # Continuous quoting
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __init__(self, client: Arthur, config: MMConfig):
|
|
100
|
+
self.client = client
|
|
101
|
+
self.config = config
|
|
102
|
+
self.symbol = client._normalize_symbol(config.symbol)
|
|
103
|
+
|
|
104
|
+
# State
|
|
105
|
+
self.active_orders: Dict[str, Order] = {} # order_id -> Order
|
|
106
|
+
self.last_quote_time: float = 0
|
|
107
|
+
self.daily_pnl: float = 0
|
|
108
|
+
self.session_start: float = time.time()
|
|
109
|
+
|
|
110
|
+
def run_once(self) -> Dict[str, Any]:
|
|
111
|
+
"""
|
|
112
|
+
Run one quote cycle.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Dict with quote details and status
|
|
116
|
+
"""
|
|
117
|
+
result = {
|
|
118
|
+
"timestamp": int(time.time() * 1000),
|
|
119
|
+
"symbol": self.symbol,
|
|
120
|
+
"dry_run": self.config.dry_run,
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
# 1. Get market data
|
|
125
|
+
spread_info = self.client.spread(self.config.symbol)
|
|
126
|
+
mid_price = spread_info["mid"]
|
|
127
|
+
market_spread_bps = spread_info["spread_bps"]
|
|
128
|
+
|
|
129
|
+
result["mid_price"] = mid_price
|
|
130
|
+
result["market_spread_bps"] = market_spread_bps
|
|
131
|
+
|
|
132
|
+
# 2. Get current position
|
|
133
|
+
position = self.client.position(self.config.symbol)
|
|
134
|
+
inventory_usd = 0
|
|
135
|
+
if position:
|
|
136
|
+
inventory_usd = position.size * mid_price
|
|
137
|
+
if position.side == "SHORT":
|
|
138
|
+
inventory_usd = -inventory_usd
|
|
139
|
+
|
|
140
|
+
result["inventory_usd"] = inventory_usd
|
|
141
|
+
|
|
142
|
+
# 3. Check risk limits
|
|
143
|
+
if abs(inventory_usd) >= self.config.max_position_usd:
|
|
144
|
+
result["status"] = "max_inventory"
|
|
145
|
+
result["action"] = "cancel_all"
|
|
146
|
+
if not self.config.dry_run:
|
|
147
|
+
self.client.cancel_all(self.symbol)
|
|
148
|
+
return result
|
|
149
|
+
|
|
150
|
+
# 4. Calculate quotes
|
|
151
|
+
quotes = self._calculate_quotes(mid_price, inventory_usd)
|
|
152
|
+
result["quotes"] = quotes
|
|
153
|
+
|
|
154
|
+
# 5. Place orders
|
|
155
|
+
if self.config.dry_run:
|
|
156
|
+
result["status"] = "dry_run"
|
|
157
|
+
result["action"] = "would_quote"
|
|
158
|
+
else:
|
|
159
|
+
# Cancel existing orders
|
|
160
|
+
self.client.cancel_all(self.symbol)
|
|
161
|
+
|
|
162
|
+
# Place new quotes
|
|
163
|
+
orders = self.client.quote(
|
|
164
|
+
symbol=self.config.symbol,
|
|
165
|
+
bid_price=quotes["bid_price"],
|
|
166
|
+
ask_price=quotes["ask_price"],
|
|
167
|
+
size=quotes["size"],
|
|
168
|
+
cancel_existing=False, # Already cancelled
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
result["orders"] = {
|
|
172
|
+
"bid": orders["bid"].order_id,
|
|
173
|
+
"ask": orders["ask"].order_id,
|
|
174
|
+
}
|
|
175
|
+
result["status"] = "quoted"
|
|
176
|
+
result["action"] = "placed_orders"
|
|
177
|
+
|
|
178
|
+
self.last_quote_time = time.time()
|
|
179
|
+
|
|
180
|
+
if self.config.log_quotes:
|
|
181
|
+
self._log_quote(result)
|
|
182
|
+
|
|
183
|
+
except Exception as e:
|
|
184
|
+
result["status"] = "error"
|
|
185
|
+
result["error"] = str(e)
|
|
186
|
+
|
|
187
|
+
return result
|
|
188
|
+
|
|
189
|
+
def _calculate_quotes(self, mid_price: float, inventory_usd: float) -> Dict:
|
|
190
|
+
"""Calculate bid and ask prices based on config and inventory"""
|
|
191
|
+
|
|
192
|
+
# Base spread
|
|
193
|
+
spread_bps = self.config.base_spread_bps
|
|
194
|
+
|
|
195
|
+
# Inventory skew: widen on the side we're heavy
|
|
196
|
+
skew_bps = (inventory_usd / 100) * self.config.skew_per_100_usd
|
|
197
|
+
|
|
198
|
+
# Calculate prices
|
|
199
|
+
half_spread = (spread_bps / 10000) * mid_price / 2
|
|
200
|
+
skew_amount = (skew_bps / 10000) * mid_price
|
|
201
|
+
|
|
202
|
+
bid_price = mid_price - half_spread - skew_amount
|
|
203
|
+
ask_price = mid_price + half_spread - skew_amount
|
|
204
|
+
|
|
205
|
+
# Ensure minimum spread
|
|
206
|
+
min_spread = (self.config.min_spread_bps / 10000) * mid_price
|
|
207
|
+
if ask_price - bid_price < min_spread:
|
|
208
|
+
# Widen symmetrically
|
|
209
|
+
gap = min_spread - (ask_price - bid_price)
|
|
210
|
+
bid_price -= gap / 2
|
|
211
|
+
ask_price += gap / 2
|
|
212
|
+
|
|
213
|
+
# Calculate size
|
|
214
|
+
size = self.config.order_size_usd / mid_price
|
|
215
|
+
|
|
216
|
+
# Round to tick size (ORDER has base_tick=1)
|
|
217
|
+
size = max(1, round(size))
|
|
218
|
+
|
|
219
|
+
# Round prices to quote tick (0.00001)
|
|
220
|
+
bid_price = round(bid_price, 5)
|
|
221
|
+
ask_price = round(ask_price, 5)
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
"bid_price": bid_price,
|
|
225
|
+
"ask_price": ask_price,
|
|
226
|
+
"size": size,
|
|
227
|
+
"spread_bps": ((ask_price - bid_price) / mid_price) * 10000,
|
|
228
|
+
"skew_bps": skew_bps,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
def _log_quote(self, result: Dict):
|
|
232
|
+
"""Log quote details"""
|
|
233
|
+
quotes = result.get("quotes", {})
|
|
234
|
+
mode = "[DRY]" if self.config.dry_run else "[LIVE]"
|
|
235
|
+
|
|
236
|
+
print(f"{mode} {self.config.symbol} | "
|
|
237
|
+
f"mid=${result.get('mid_price', 0):.5f} | "
|
|
238
|
+
f"bid=${quotes.get('bid_price', 0):.5f} / "
|
|
239
|
+
f"ask=${quotes.get('ask_price', 0):.5f} | "
|
|
240
|
+
f"spread={quotes.get('spread_bps', 0):.1f}bps | "
|
|
241
|
+
f"inv=${result.get('inventory_usd', 0):.0f}")
|
|
242
|
+
|
|
243
|
+
def run_loop(self, duration_sec: Optional[float] = None):
|
|
244
|
+
"""
|
|
245
|
+
Run continuous quoting loop.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
duration_sec: Run for this many seconds (None = forever)
|
|
249
|
+
"""
|
|
250
|
+
print(f"Starting market maker for {self.config.symbol}")
|
|
251
|
+
print(f"Spread: {self.config.base_spread_bps}bps | "
|
|
252
|
+
f"Size: ${self.config.order_size_usd} | "
|
|
253
|
+
f"Interval: {self.config.requote_interval_sec}s")
|
|
254
|
+
print(f"Mode: {'DRY RUN' if self.config.dry_run else 'LIVE'}")
|
|
255
|
+
print("-" * 60)
|
|
256
|
+
|
|
257
|
+
start_time = time.time()
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
while True:
|
|
261
|
+
self.run_once()
|
|
262
|
+
|
|
263
|
+
# Check duration
|
|
264
|
+
if duration_sec and (time.time() - start_time) >= duration_sec:
|
|
265
|
+
break
|
|
266
|
+
|
|
267
|
+
time.sleep(self.config.requote_interval_sec)
|
|
268
|
+
|
|
269
|
+
except KeyboardInterrupt:
|
|
270
|
+
print("\nStopping market maker...")
|
|
271
|
+
finally:
|
|
272
|
+
# Cancel all orders on exit
|
|
273
|
+
if not self.config.dry_run:
|
|
274
|
+
print("Cancelling all orders...")
|
|
275
|
+
self.client.cancel_all(self.symbol)
|
|
276
|
+
|
|
277
|
+
print("Market maker stopped.")
|
|
278
|
+
|
|
279
|
+
def status(self) -> Dict:
|
|
280
|
+
"""Get current MM status"""
|
|
281
|
+
position = self.client.position(self.config.symbol)
|
|
282
|
+
orders = self.client.orders(self.config.symbol)
|
|
283
|
+
spread = self.client.spread(self.config.symbol)
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
"symbol": self.config.symbol,
|
|
287
|
+
"mode": "dry_run" if self.config.dry_run else "live",
|
|
288
|
+
"mid_price": spread["mid"],
|
|
289
|
+
"market_spread_bps": spread["spread_bps"],
|
|
290
|
+
"position": {
|
|
291
|
+
"side": position.side if position else None,
|
|
292
|
+
"size": position.size if position else 0,
|
|
293
|
+
"pnl": position.unrealized_pnl if position else 0,
|
|
294
|
+
} if position else None,
|
|
295
|
+
"open_orders": len(orders),
|
|
296
|
+
"uptime_sec": time.time() - self.session_start,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def run_mm(config_path: str, credentials_path: str, duration: Optional[float] = None):
|
|
301
|
+
"""
|
|
302
|
+
Run market maker from config file.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
config_path: Path to strategy JSON
|
|
306
|
+
credentials_path: Path to credentials JSON
|
|
307
|
+
duration: Run duration in seconds (None = forever)
|
|
308
|
+
"""
|
|
309
|
+
client = Arthur.from_credentials_file(credentials_path)
|
|
310
|
+
config = MMConfig.from_file(config_path)
|
|
311
|
+
mm = MarketMaker(client, config)
|
|
312
|
+
mm.run_loop(duration_sec=duration)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
if __name__ == "__main__":
|
|
316
|
+
import sys
|
|
317
|
+
|
|
318
|
+
if len(sys.argv) < 3:
|
|
319
|
+
print("Usage: python -m orderly_agent.market_maker <strategy.json> <credentials.json> [duration_sec]")
|
|
320
|
+
sys.exit(1)
|
|
321
|
+
|
|
322
|
+
strategy = sys.argv[1]
|
|
323
|
+
creds = sys.argv[2]
|
|
324
|
+
duration = float(sys.argv[3]) if len(sys.argv) > 3 else None
|
|
325
|
+
|
|
326
|
+
run_mm(strategy, creds, duration)
|