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/strategies.py
ADDED
|
@@ -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)
|