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.
@@ -0,0 +1,576 @@
1
+ """
2
+ Agent Trading SDK Strategy Runner - Execute trading strategies from JSON configs.
3
+
4
+ Supports both simple single-symbol and multi-asset strategies (like Unlockoor).
5
+ """
6
+
7
+ import json
8
+ import time
9
+ import urllib.request
10
+ from dataclasses import dataclass, field
11
+ from typing import Optional, Dict, List, Any, Callable, Union
12
+ from pathlib import Path
13
+
14
+ from .client import Arthur, Position
15
+ from .exceptions import ArthurError
16
+
17
+
18
+ @dataclass
19
+ class Signal:
20
+ """Trading signal from strategy evaluation"""
21
+ action: str # "long", "short", "close", "hold"
22
+ symbol: str
23
+ size: Optional[float] = None
24
+ usd: Optional[float] = None
25
+ reason: str = ""
26
+ confidence: float = 1.0
27
+
28
+
29
+ @dataclass
30
+ class StrategyConfig:
31
+ """Strategy configuration loaded from JSON - supports both simple and multi-asset formats"""
32
+ name: str
33
+ version: str = "1.0.0"
34
+ description: str = ""
35
+
36
+ # Single symbol mode (simple strategies)
37
+ symbol: Optional[str] = None
38
+
39
+ # Multi-asset mode (Unlockoor-style)
40
+ long_assets: List[str] = field(default_factory=list)
41
+ short_assets: List[str] = field(default_factory=list)
42
+
43
+ # Timeframe
44
+ timeframe: str = "4h"
45
+
46
+ # Signals config
47
+ signals: Dict = field(default_factory=dict)
48
+
49
+ # Risk management
50
+ risk: Dict = field(default_factory=dict)
51
+
52
+ # Position sizing
53
+ position: Dict = field(default_factory=dict)
54
+
55
+ # Execution settings
56
+ execution: Dict = field(default_factory=dict)
57
+
58
+ # Flags
59
+ flags: Dict = field(default_factory=dict)
60
+
61
+ @classmethod
62
+ def from_file(cls, path: str) -> "StrategyConfig":
63
+ """Load strategy from JSON file"""
64
+ with open(Path(path).expanduser()) as f:
65
+ data = json.load(f)
66
+ return cls.from_dict(data)
67
+
68
+ @classmethod
69
+ def from_dict(cls, data: Dict) -> "StrategyConfig":
70
+ """Create strategy from dict"""
71
+ return cls(
72
+ name=data.get("name", "Unnamed Strategy"),
73
+ version=data.get("version", "1.0.0"),
74
+ description=data.get("description", ""),
75
+ symbol=data.get("symbol"),
76
+ long_assets=data.get("long_assets", []),
77
+ short_assets=data.get("short_assets", []),
78
+ timeframe=data.get("timeframe") or data.get("signals", {}).get("timeframe", "4h"),
79
+ signals=data.get("signals", {}),
80
+ risk=data.get("risk", {}),
81
+ position=data.get("position", {}),
82
+ execution=data.get("execution", {}),
83
+ flags=data.get("flags", {}),
84
+ )
85
+
86
+ @property
87
+ def all_symbols(self) -> List[str]:
88
+ """Get all tradeable symbols"""
89
+ if self.symbol:
90
+ return [self.symbol]
91
+ return list(set(self.long_assets + self.short_assets))
92
+
93
+ @property
94
+ def is_multi_asset(self) -> bool:
95
+ """Check if this is a multi-asset strategy"""
96
+ return bool(self.long_assets or self.short_assets)
97
+
98
+ @property
99
+ def leverage(self) -> int:
100
+ return self.position.get("leverage", 5)
101
+
102
+ @property
103
+ def position_size_pct(self) -> float:
104
+ return self.position.get("size_pct", 10)
105
+
106
+ @property
107
+ def stop_loss_pct(self) -> Optional[float]:
108
+ return self.risk.get("stop_loss_pct")
109
+
110
+ @property
111
+ def take_profit_pct(self) -> Optional[float]:
112
+ return self.risk.get("take_profit_pct")
113
+
114
+ @property
115
+ def max_positions(self) -> int:
116
+ return self.risk.get("max_positions", 5)
117
+
118
+ @property
119
+ def dry_run(self) -> bool:
120
+ return self.flags.get("dry_run", False)
121
+
122
+ @property
123
+ def allow_shorts(self) -> bool:
124
+ return self.flags.get("allow_shorts", True)
125
+
126
+
127
+ class StrategyRunner:
128
+ """
129
+ Execute trading strategies on Orderly Network.
130
+
131
+ Supports:
132
+ - Simple single-symbol strategies
133
+ - Multi-asset strategies (Unlockoor-style with long_assets/short_assets)
134
+ - RSI-based signals
135
+ - Risk management (stop loss, take profit)
136
+
137
+ Example:
138
+ client = Arthur.from_credentials_file("creds.json")
139
+ runner = StrategyRunner(client)
140
+
141
+ # Run a strategy once
142
+ result = runner.run("strategies/unlockoor.json")
143
+ """
144
+
145
+ def __init__(
146
+ self,
147
+ client: Arthur,
148
+ dry_run: bool = False,
149
+ on_signal: Optional[Callable[[Signal], None]] = None,
150
+ on_trade: Optional[Callable[[Dict], None]] = None,
151
+ ):
152
+ """
153
+ Initialize strategy runner.
154
+
155
+ Args:
156
+ client: Authenticated Arthur client
157
+ dry_run: If True, don't execute trades (just log signals)
158
+ on_signal: Callback when signal is generated
159
+ on_trade: Callback when trade is executed
160
+ """
161
+ self.client = client
162
+ self.dry_run = dry_run
163
+ self.on_signal = on_signal
164
+ self.on_trade = on_trade
165
+ self._last_run: Dict[str, float] = {}
166
+ self._rsi_cache: Dict[str, float] = {}
167
+ self._rsi_cache_time: float = 0
168
+
169
+ def run(
170
+ self,
171
+ strategy: Union[str, StrategyConfig, Dict],
172
+ force: bool = False,
173
+ ) -> Dict[str, Any]:
174
+ """
175
+ Run a strategy once.
176
+
177
+ Args:
178
+ strategy: Path to JSON file, StrategyConfig, or dict
179
+ force: Run even if not time for next check
180
+
181
+ Returns:
182
+ Dict with run results
183
+ """
184
+ # Load strategy config
185
+ if isinstance(strategy, str):
186
+ config = StrategyConfig.from_file(strategy)
187
+ elif isinstance(strategy, dict):
188
+ config = StrategyConfig.from_dict(strategy)
189
+ else:
190
+ config = strategy
191
+
192
+ result = {
193
+ "strategy": config.name,
194
+ "version": config.version,
195
+ "timestamp": int(time.time() * 1000),
196
+ "signals": [],
197
+ "trades": [],
198
+ "errors": [],
199
+ "dry_run": self.dry_run or config.dry_run,
200
+ }
201
+
202
+ try:
203
+ # Check if it's time to run
204
+ if not force and not self._should_run(config):
205
+ result["skipped"] = True
206
+ result["reason"] = "Not time for next check"
207
+ return result
208
+
209
+ # Get current positions
210
+ positions = {p.symbol: p for p in self.client.positions()}
211
+
212
+ # Multi-asset or single-symbol?
213
+ if config.is_multi_asset:
214
+ signals = self._evaluate_multi_asset(config, positions)
215
+ else:
216
+ signals = self._evaluate_single(config, positions)
217
+
218
+ result["signals"] = [s.__dict__ for s in signals]
219
+
220
+ # Execute signals
221
+ for signal in signals:
222
+ if self.on_signal:
223
+ self.on_signal(signal)
224
+
225
+ if signal.action != "hold":
226
+ if self.dry_run or config.dry_run:
227
+ result["trades"].append({
228
+ "action": signal.action,
229
+ "symbol": signal.symbol,
230
+ "dry_run": True,
231
+ "reason": signal.reason,
232
+ })
233
+ else:
234
+ trade = self._execute(config, signal, positions.get(signal.symbol))
235
+ result["trades"].append(trade)
236
+
237
+ if self.on_trade and trade:
238
+ self.on_trade(trade)
239
+
240
+ self._last_run[config.name] = time.time()
241
+
242
+ except Exception as e:
243
+ result["errors"].append(str(e))
244
+
245
+ return result
246
+
247
+ def _should_run(self, config: StrategyConfig) -> bool:
248
+ """Check if enough time has passed since last run"""
249
+ last = self._last_run.get(config.name, 0)
250
+
251
+ # Parse timeframe to seconds
252
+ tf = config.timeframe.lower()
253
+ if tf.endswith("m"):
254
+ interval = int(tf[:-1]) * 60
255
+ elif tf.endswith("h"):
256
+ interval = int(tf[:-1]) * 3600
257
+ elif tf.endswith("d"):
258
+ interval = int(tf[:-1]) * 86400
259
+ else:
260
+ interval = 3600 # Default 1 hour
261
+
262
+ return time.time() - last >= interval
263
+
264
+ def _evaluate_multi_asset(
265
+ self,
266
+ config: StrategyConfig,
267
+ positions: Dict[str, Position],
268
+ ) -> List[Signal]:
269
+ """Evaluate a multi-asset strategy (Unlockoor-style)"""
270
+ signals = []
271
+
272
+ # Get RSI for all assets
273
+ rsi_values = self._get_rsi_batch(config.all_symbols, config.signals.get("period", 14))
274
+
275
+ # Count current positions
276
+ current_position_count = len([p for p in positions.values() if p.size > 0])
277
+
278
+ # Check long assets
279
+ for asset in config.long_assets:
280
+ symbol = self._normalize_symbol(asset)
281
+ rsi = rsi_values.get(symbol, 50)
282
+ position = positions.get(symbol)
283
+
284
+ signal = self._evaluate_asset(
285
+ config=config,
286
+ symbol=symbol,
287
+ asset=asset,
288
+ rsi=rsi,
289
+ position=position,
290
+ side="long",
291
+ current_position_count=current_position_count,
292
+ )
293
+ signals.append(signal)
294
+
295
+ if signal.action in ["long", "short"]:
296
+ current_position_count += 1
297
+
298
+ # Check short assets
299
+ if config.allow_shorts:
300
+ for asset in config.short_assets:
301
+ symbol = self._normalize_symbol(asset)
302
+ rsi = rsi_values.get(symbol, 50)
303
+ position = positions.get(symbol)
304
+
305
+ signal = self._evaluate_asset(
306
+ config=config,
307
+ symbol=symbol,
308
+ asset=asset,
309
+ rsi=rsi,
310
+ position=position,
311
+ side="short",
312
+ current_position_count=current_position_count,
313
+ )
314
+ signals.append(signal)
315
+
316
+ if signal.action in ["long", "short"]:
317
+ current_position_count += 1
318
+
319
+ return signals
320
+
321
+ def _evaluate_asset(
322
+ self,
323
+ config: StrategyConfig,
324
+ symbol: str,
325
+ asset: str,
326
+ rsi: float,
327
+ position: Optional[Position],
328
+ side: str, # "long" or "short"
329
+ current_position_count: int,
330
+ ) -> Signal:
331
+ """Evaluate a single asset within a multi-asset strategy"""
332
+
333
+ long_entry = config.signals.get("long_entry", 30)
334
+ short_entry = config.signals.get("short_entry", 70)
335
+
336
+ # If we have a position, check for exit
337
+ if position and position.size > 0:
338
+ # Check stop loss
339
+ if config.stop_loss_pct:
340
+ if position.pnl_percent <= -config.stop_loss_pct:
341
+ return Signal(
342
+ action="close",
343
+ symbol=symbol,
344
+ reason=f"Stop loss hit: {position.pnl_percent:.1f}% (limit: -{config.stop_loss_pct}%)",
345
+ )
346
+
347
+ # Check take profit
348
+ if config.take_profit_pct:
349
+ if position.pnl_percent >= config.take_profit_pct:
350
+ return Signal(
351
+ action="close",
352
+ symbol=symbol,
353
+ reason=f"Take profit hit: {position.pnl_percent:.1f}% (target: +{config.take_profit_pct}%)",
354
+ )
355
+
356
+ # Check RSI exit
357
+ if position.side == "LONG" and rsi >= 70:
358
+ return Signal(
359
+ action="close",
360
+ symbol=symbol,
361
+ reason=f"RSI exit for long: {rsi:.1f} >= 70",
362
+ )
363
+ elif position.side == "SHORT" and rsi <= 30:
364
+ return Signal(
365
+ action="close",
366
+ symbol=symbol,
367
+ reason=f"RSI exit for short: {rsi:.1f} <= 30",
368
+ )
369
+
370
+ # Hold position
371
+ return Signal(
372
+ action="hold",
373
+ symbol=symbol,
374
+ reason=f"Holding {position.side} position, RSI={rsi:.1f}, PnL={position.pnl_percent:.1f}%",
375
+ )
376
+
377
+ # No position - check for entry
378
+ if current_position_count >= config.max_positions:
379
+ return Signal(
380
+ action="hold",
381
+ symbol=symbol,
382
+ reason=f"Max positions ({config.max_positions}) reached",
383
+ )
384
+
385
+ # Entry signals
386
+ if side == "long" and rsi <= long_entry:
387
+ return Signal(
388
+ action="long",
389
+ symbol=symbol,
390
+ reason=f"Long entry: RSI {rsi:.1f} <= {long_entry}",
391
+ confidence=1.0 - (rsi / 100), # Higher confidence at lower RSI
392
+ )
393
+ elif side == "short" and rsi >= short_entry:
394
+ return Signal(
395
+ action="short",
396
+ symbol=symbol,
397
+ reason=f"Short entry: RSI {rsi:.1f} >= {short_entry}",
398
+ confidence=rsi / 100, # Higher confidence at higher RSI
399
+ )
400
+
401
+ # No signal
402
+ return Signal(
403
+ action="hold",
404
+ symbol=symbol,
405
+ reason=f"No entry signal: RSI={rsi:.1f} (long<={long_entry}, short>={short_entry})",
406
+ )
407
+
408
+ def _evaluate_single(
409
+ self,
410
+ config: StrategyConfig,
411
+ positions: Dict[str, Position],
412
+ ) -> List[Signal]:
413
+ """Evaluate a simple single-symbol strategy"""
414
+ symbol = self._normalize_symbol(config.symbol or "ETH")
415
+ position = positions.get(symbol)
416
+
417
+ # Get RSI
418
+ rsi = self._get_rsi(symbol, config.signals.get("period", 14))
419
+
420
+ signal = self._evaluate_asset(
421
+ config=config,
422
+ symbol=symbol,
423
+ asset=config.symbol or "ETH",
424
+ rsi=rsi,
425
+ position=position,
426
+ side="long", # Default to long for simple strategies
427
+ current_position_count=len(positions),
428
+ )
429
+
430
+ return [signal]
431
+
432
+ def _normalize_symbol(self, symbol: str) -> str:
433
+ """Convert short symbol (ETH) to full symbol (PERP_ETH_USDC)"""
434
+ symbol = symbol.upper()
435
+ if symbol.startswith("PERP_"):
436
+ return symbol
437
+ return f"PERP_{symbol}_USDC"
438
+
439
+ def _get_rsi(self, symbol: str, period: int = 14) -> float:
440
+ """Get RSI for a symbol using Orderly's TradingView history endpoint"""
441
+ try:
442
+ # Calculate time range (need period + buffer days)
443
+ now = int(time.time())
444
+ days_needed = period + 5
445
+ from_ts = now - (days_needed * 86400)
446
+
447
+ # Use TV history endpoint
448
+ url = f"https://api-evm.orderly.org/tv/history?symbol={symbol}&resolution=1D&from={from_ts}&to={now}"
449
+
450
+ with urllib.request.urlopen(url, timeout=10) as resp:
451
+ data = json.loads(resp.read().decode())
452
+
453
+ if data.get("s") != "ok" or "c" not in data:
454
+ return 50.0 # Default neutral RSI
455
+
456
+ closes = data["c"] # Already in chronological order
457
+
458
+ if len(closes) < period + 1:
459
+ return 50.0
460
+
461
+ # Calculate RSI
462
+ gains = []
463
+ losses = []
464
+
465
+ for i in range(1, len(closes)):
466
+ change = closes[i] - closes[i-1]
467
+ if change > 0:
468
+ gains.append(change)
469
+ losses.append(0)
470
+ else:
471
+ gains.append(0)
472
+ losses.append(abs(change))
473
+
474
+ if len(gains) < period:
475
+ return 50.0
476
+
477
+ avg_gain = sum(gains[-period:]) / period
478
+ avg_loss = sum(losses[-period:]) / period
479
+
480
+ if avg_loss == 0:
481
+ return 100.0
482
+
483
+ rs = avg_gain / avg_loss
484
+ rsi = 100 - (100 / (1 + rs))
485
+
486
+ return rsi
487
+
488
+ except Exception as e:
489
+ print(f"Error getting RSI for {symbol}: {e}")
490
+ return 50.0 # Default neutral
491
+
492
+ def _get_rsi_batch(self, symbols: List[str], period: int = 14) -> Dict[str, float]:
493
+ """Get RSI for multiple symbols"""
494
+ # Use cache if fresh (< 5 minutes)
495
+ if time.time() - self._rsi_cache_time < 300:
496
+ return self._rsi_cache
497
+
498
+ result = {}
499
+ for symbol in symbols:
500
+ full_symbol = self._normalize_symbol(symbol)
501
+ result[full_symbol] = self._get_rsi(full_symbol, period)
502
+ time.sleep(0.15) # Rate limit
503
+
504
+ self._rsi_cache = result
505
+ self._rsi_cache_time = time.time()
506
+
507
+ return result
508
+
509
+ def _execute(
510
+ self,
511
+ config: StrategyConfig,
512
+ signal: Signal,
513
+ position: Optional[Position],
514
+ ) -> Dict:
515
+ """Execute a trading signal"""
516
+ result = {
517
+ "action": signal.action,
518
+ "symbol": signal.symbol,
519
+ "timestamp": int(time.time() * 1000),
520
+ "reason": signal.reason,
521
+ }
522
+
523
+ try:
524
+ # Set leverage
525
+ self.client.set_leverage(signal.symbol, config.leverage)
526
+
527
+ # Calculate position size
528
+ balance = self.client.balance()
529
+ size_usd = balance * (config.position_size_pct / 100)
530
+
531
+ if signal.action == "long":
532
+ order = self.client.buy(signal.symbol, usd=size_usd)
533
+ result["order_id"] = order.order_id
534
+ result["size"] = order.size
535
+ result["status"] = "executed"
536
+
537
+ elif signal.action == "short":
538
+ order = self.client.sell(signal.symbol, usd=size_usd)
539
+ result["order_id"] = order.order_id
540
+ result["size"] = order.size
541
+ result["status"] = "executed"
542
+
543
+ elif signal.action == "close":
544
+ order = self.client.close(signal.symbol)
545
+ if order:
546
+ result["order_id"] = order.order_id
547
+ result["size"] = order.size
548
+ result["status"] = "executed"
549
+
550
+ except Exception as e:
551
+ result["status"] = "failed"
552
+ result["error"] = str(e)
553
+
554
+ return result
555
+
556
+
557
+ # Convenience function
558
+ def run_strategy(
559
+ strategy_path: str,
560
+ credentials_path: str,
561
+ dry_run: bool = False,
562
+ ) -> Dict:
563
+ """
564
+ Run a strategy once (convenience function).
565
+
566
+ Args:
567
+ strategy_path: Path to strategy JSON
568
+ credentials_path: Path to credentials JSON
569
+ dry_run: Don't execute trades
570
+
571
+ Returns:
572
+ Run result dict
573
+ """
574
+ client = Arthur.from_credentials_file(credentials_path)
575
+ runner = StrategyRunner(client, dry_run=dry_run)
576
+ return runner.run(strategy_path)