sory 0.0.2__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.
- sory/__init__.py +15 -0
- sory/client.py +183 -0
- sory/config.py +45 -0
- sory/core/__init__.py +1 -0
- sory/core/base.py +104 -0
- sory/core/executor.py +233 -0
- sory/core/orderbook.py +356 -0
- sory/core/trades.py +154 -0
- sory/exceptions.py +4 -0
- sory/models.py +138 -0
- sory/utils/__init__.py +5 -0
- sory/utils/helpers.py +59 -0
- sory/utils/logger.py +58 -0
- sory-0.0.2.dist-info/METADATA +277 -0
- sory-0.0.2.dist-info/RECORD +18 -0
- sory-0.0.2.dist-info/WHEEL +5 -0
- sory-0.0.2.dist-info/licenses/LICENSE +21 -0
- sory-0.0.2.dist-info/top_level.txt +1 -0
sory/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Sory package for cryptocurrency trading."""
|
|
2
|
+
from sory.client import SoryClient
|
|
3
|
+
from sory.models import OrderbookConfig, TradesConfig, APIConfig, OrderConfig
|
|
4
|
+
from sory.exceptions import SoryError
|
|
5
|
+
|
|
6
|
+
__version__ = "3.0.2"
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"SoryClient",
|
|
10
|
+
"SoryError",
|
|
11
|
+
"OrderbookConfig",
|
|
12
|
+
"TradesConfig",
|
|
13
|
+
"APIConfig",
|
|
14
|
+
"OrderConfig",
|
|
15
|
+
]
|
sory/client.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Dict, List, Optional, Callable
|
|
3
|
+
from sory.utils.logger import logger, catch_exception
|
|
4
|
+
import ccxt.pro as ccxt
|
|
5
|
+
|
|
6
|
+
from sory.models import OrderbookConfig, TradesConfig, APIConfig, OrderConfig
|
|
7
|
+
from sory.core.orderbook import OrderbookManager
|
|
8
|
+
from sory.core.trades import TradesManager
|
|
9
|
+
from sory.core.executor import OrderExecutor
|
|
10
|
+
from sory.exceptions import SoryError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SoryClient:
|
|
14
|
+
|
|
15
|
+
@catch_exception
|
|
16
|
+
def __init__(self):
|
|
17
|
+
self._orderbook_manager = None
|
|
18
|
+
self._trades_manager = None
|
|
19
|
+
self._order_executor = None
|
|
20
|
+
self._running = True
|
|
21
|
+
logger.info("Initialized Sory Client")
|
|
22
|
+
|
|
23
|
+
@catch_exception
|
|
24
|
+
async def __aenter__(self):
|
|
25
|
+
"""Async context manager entry point."""
|
|
26
|
+
return self
|
|
27
|
+
|
|
28
|
+
@catch_exception
|
|
29
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
30
|
+
"""Async context manager exit point that automatically cleans up resources."""
|
|
31
|
+
await self.close()
|
|
32
|
+
return False # Allow any exceptions to propagate
|
|
33
|
+
|
|
34
|
+
@catch_exception
|
|
35
|
+
async def set_config(self, *, type: str, **config_params) -> Dict:
|
|
36
|
+
"""Unified configuration method for both orderbook and trades.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
type: The type of configuration ('orderbook' or 'trades')
|
|
40
|
+
**config_params: Configuration parameters specific to the type
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
Dict with status and message
|
|
44
|
+
|
|
45
|
+
Examples:
|
|
46
|
+
--> await sory.set_config(
|
|
47
|
+
... type="orderbook",
|
|
48
|
+
... aggregated=True,
|
|
49
|
+
... exchanges=["binance", "okx"],
|
|
50
|
+
... symbols=["BTC/USDT"],
|
|
51
|
+
... depth=10
|
|
52
|
+
... )
|
|
53
|
+
"""
|
|
54
|
+
if not isinstance(type, str):
|
|
55
|
+
raise SoryError(f"Config type must be a string, got {type.__class__.__name__}")
|
|
56
|
+
|
|
57
|
+
type = type.lower()
|
|
58
|
+
if type not in ['orderbook', 'trades']:
|
|
59
|
+
raise SoryError(
|
|
60
|
+
f"Invalid config type: '{type}'. Must be 'orderbook' or 'trades'."
|
|
61
|
+
"\nExample usage:"
|
|
62
|
+
"\n await sory.set_config("
|
|
63
|
+
"\n type='orderbook',"
|
|
64
|
+
"\n aggregated=True,"
|
|
65
|
+
"\n exchanges=['binance', 'okx'],"
|
|
66
|
+
"\n symbols=['BTC/USDT'],"
|
|
67
|
+
"\n depth=10"
|
|
68
|
+
"\n )"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
if type == 'orderbook':
|
|
72
|
+
config = OrderbookConfig(**config_params)
|
|
73
|
+
self._orderbook_manager = OrderbookManager(config)
|
|
74
|
+
return {"status": "success", "message": "Orderbook configuration set"}
|
|
75
|
+
else: # trades
|
|
76
|
+
config = TradesConfig(**config_params)
|
|
77
|
+
self._trades_manager = TradesManager(config)
|
|
78
|
+
return {"status": "success", "message": "Trades configuration set"}
|
|
79
|
+
|
|
80
|
+
@catch_exception
|
|
81
|
+
async def start_stream(self, stream_type: str, callback: Optional[Callable] = None,
|
|
82
|
+
timeout: Optional[float] = None) -> Dict:
|
|
83
|
+
"""
|
|
84
|
+
Unified method to start either orderbook or trades stream.
|
|
85
|
+
|
|
86
|
+
Args:
|
|
87
|
+
stream_type: Either 'orderbook' or 'trades'
|
|
88
|
+
callback: Optional callback function to receive updates
|
|
89
|
+
timeout: Optional timeout in seconds. If None, runs indefinitely.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Dict: Status and message
|
|
93
|
+
"""
|
|
94
|
+
if stream_type not in ['orderbook', 'trades']:
|
|
95
|
+
raise SoryError(f"Invalid stream type: {stream_type}. Must be 'orderbook' or 'trades'")
|
|
96
|
+
|
|
97
|
+
if stream_type == 'orderbook':
|
|
98
|
+
if not self._orderbook_manager:
|
|
99
|
+
raise SoryError("Orderbook not configured. Call set_config(type='orderbook', ...) first")
|
|
100
|
+
if callback:
|
|
101
|
+
self._orderbook_manager.add_callback(callback)
|
|
102
|
+
await self._orderbook_manager.start(timeout=timeout)
|
|
103
|
+
return {"status": "success", "message": "Orderbook stream started"}
|
|
104
|
+
else: # trades
|
|
105
|
+
if not self._trades_manager:
|
|
106
|
+
raise SoryError("Trades not configured. Call set_config(type='trades', ...) first")
|
|
107
|
+
if callback:
|
|
108
|
+
self._trades_manager.add_callback(callback)
|
|
109
|
+
await self._trades_manager.start(timeout=timeout)
|
|
110
|
+
return {"status": "success", "message": "Trades stream started"}
|
|
111
|
+
|
|
112
|
+
@catch_exception
|
|
113
|
+
async def stop_stream(self, stream_type: str) -> Dict:
|
|
114
|
+
"""Unified method to stop either orderbook or trades stream."""
|
|
115
|
+
if stream_type not in ['orderbook', 'trades']:
|
|
116
|
+
raise SoryError(f"Invalid stream type: {stream_type}. Must be 'orderbook' or 'trades'")
|
|
117
|
+
|
|
118
|
+
if stream_type == 'orderbook':
|
|
119
|
+
if self._orderbook_manager:
|
|
120
|
+
await self._orderbook_manager.close()
|
|
121
|
+
return {"status": "success", "message": "Orderbook stream stopped"}
|
|
122
|
+
else: # trades
|
|
123
|
+
if self._trades_manager:
|
|
124
|
+
await self._trades_manager.close()
|
|
125
|
+
return {"status": "success", "message": "Trades stream stopped"}
|
|
126
|
+
return {"status": "success", "message": f"{stream_type} stream was not running"}
|
|
127
|
+
|
|
128
|
+
@catch_exception
|
|
129
|
+
async def get_latest_data(self, data_type: str) -> Dict:
|
|
130
|
+
"""Unified method to get latest data from either orderbook or trades."""
|
|
131
|
+
if data_type not in ['orderbook', 'trades']:
|
|
132
|
+
raise SoryError(f"Invalid data type: {data_type}. Must be 'orderbook' or 'trades'")
|
|
133
|
+
|
|
134
|
+
if data_type == 'orderbook':
|
|
135
|
+
if not self._orderbook_manager:
|
|
136
|
+
raise SoryError("Orderbook not configured. Call set_config(type='orderbook', ...) first")
|
|
137
|
+
return await self._orderbook_manager.get_latest_orderbooks()
|
|
138
|
+
else: # trades
|
|
139
|
+
if not self._trades_manager:
|
|
140
|
+
raise SoryError("Trades not configured. Call set_config(type='trades', ...) first")
|
|
141
|
+
return await self._trades_manager.get_latest_trades()
|
|
142
|
+
|
|
143
|
+
@catch_exception
|
|
144
|
+
async def initialize_executor(self, api_configs: List[APIConfig], margin_mode: bool = False) -> None:
|
|
145
|
+
"""Initialize the order executor with API configs."""
|
|
146
|
+
if not self._orderbook_manager:
|
|
147
|
+
raise SoryError("Orderbook must be configured before initializing executor. Call set_config(type='orderbook', ...) first")
|
|
148
|
+
|
|
149
|
+
if not self._orderbook_manager.config.aggregated:
|
|
150
|
+
raise SoryError("Orderbook must be configured with aggregation enabled for order execution")
|
|
151
|
+
|
|
152
|
+
# Create and initialize executor
|
|
153
|
+
self._order_executor = OrderExecutor(self._orderbook_manager.config, api_configs, margin_mode)
|
|
154
|
+
await self._order_executor.initialize()
|
|
155
|
+
|
|
156
|
+
@catch_exception
|
|
157
|
+
async def execute_order(self, order: OrderConfig) -> Dict:
|
|
158
|
+
"""Execute an order using the initialized executor."""
|
|
159
|
+
if not self._order_executor:
|
|
160
|
+
raise SoryError("Order executor not initialized. Call initialize_executor first")
|
|
161
|
+
|
|
162
|
+
# Get latest orderbook data for execution
|
|
163
|
+
orderbook = await self.get_latest_data('orderbook')
|
|
164
|
+
|
|
165
|
+
# Execute the order
|
|
166
|
+
await self._order_executor.update_orderbook(orderbook)
|
|
167
|
+
return await self._order_executor.execute_order(order)
|
|
168
|
+
|
|
169
|
+
@catch_exception
|
|
170
|
+
async def close(self):
|
|
171
|
+
"""Clean up resources and close connections."""
|
|
172
|
+
self._running = False
|
|
173
|
+
tasks = []
|
|
174
|
+
|
|
175
|
+
if self._orderbook_manager:
|
|
176
|
+
tasks.append(self._orderbook_manager.close())
|
|
177
|
+
if self._trades_manager:
|
|
178
|
+
tasks.append(self._trades_manager.close())
|
|
179
|
+
if self._order_executor:
|
|
180
|
+
tasks.append(self._order_executor.close())
|
|
181
|
+
|
|
182
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
183
|
+
logger.info("Sory closed")
|
sory/config.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Available orderbook depth levels for each exchange
|
|
2
|
+
EXCHANGES = {
|
|
3
|
+
'ascendex': {'available_depth': range(51)},
|
|
4
|
+
'binance': {'available_depth': range(51)},
|
|
5
|
+
'binancecoinm': {'available_depth': [5, 10, 20]},
|
|
6
|
+
'binanceus': {'available_depth': range(51)},
|
|
7
|
+
'binanceusdm': {'available_depth': [5, 10, 20]},
|
|
8
|
+
'bitget': {'available_depth': range(51)},
|
|
9
|
+
'bitmart': {'available_depth': range(51)},
|
|
10
|
+
'bitmex': {'available_depth': [25]},
|
|
11
|
+
'bingx': {'available_depth': range(51)},
|
|
12
|
+
'bitrue': {'available_depth': range(51)},
|
|
13
|
+
'bitstamp': {'available_depth': range(51)},
|
|
14
|
+
'bybit': {'available_depth': [1, 50, 200]},
|
|
15
|
+
'coinbase': {'available_depth': range(51)},
|
|
16
|
+
'cryptocom': {'available_depth': [10, 50]},
|
|
17
|
+
'deribit': {'available_depth': range(51)},
|
|
18
|
+
'gate': {'available_depth': range(51)},
|
|
19
|
+
'gemini': {'available_depth': range(51)},
|
|
20
|
+
'htx': {'available_depth': [20, 150]},
|
|
21
|
+
'hyperliquid': {'available_depth': range(51)},
|
|
22
|
+
'kraken': {'available_depth': [10, 25, 100, 500, 1000]},
|
|
23
|
+
'krakenfutures': {'available_depth': range(51)},
|
|
24
|
+
'kucoin': {'available_depth': [5, 20, 50, 100]},
|
|
25
|
+
'kucoinfutures': {'available_depth': [20, 100]},
|
|
26
|
+
'lbank': {'available_depth': range(51)},
|
|
27
|
+
'mexc': {'available_depth': range(51)},
|
|
28
|
+
'okx': {'available_depth': range(50)},
|
|
29
|
+
'p2b': {'available_depth': [5, 10, 20, 30, 50]},
|
|
30
|
+
'paradex': {'available_depth': range(51)},
|
|
31
|
+
'phemex': {'available_depth': range(51)},
|
|
32
|
+
'probit': {'available_depth': range(51)},
|
|
33
|
+
'upbit': {'available_depth': range(51)},
|
|
34
|
+
'whitebit': {'available_depth': range(51)},
|
|
35
|
+
'woo': {'available_depth': range(51)},
|
|
36
|
+
'xt': {'available_depth': range(51)},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Sandbox exchanges with their available depth levels
|
|
40
|
+
SANDBOX_EXCHANGES = {
|
|
41
|
+
'binanceusdm': {'available_depth': [5, 10, 20]},
|
|
42
|
+
'bybit': {'available_depth': [1, 50, 200]},
|
|
43
|
+
'deribit': {'available_depth': range(51)},
|
|
44
|
+
'bitmex': {'available_depth': [25]},
|
|
45
|
+
}
|
sory/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core functionality for the Sory package."""
|
sory/core/base.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import time
|
|
3
|
+
from typing import Dict, List, Set, Optional, Callable
|
|
4
|
+
import ccxt.pro as ccxt
|
|
5
|
+
from sory.utils.logger import logger, catch_exception
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class BaseExchangeManager:
|
|
9
|
+
"""Base class for exchange data managers (orderbook and trades)."""
|
|
10
|
+
@catch_exception
|
|
11
|
+
def __init__(self, config):
|
|
12
|
+
self.config = config
|
|
13
|
+
self.exchanges = self._initialize_exchanges()
|
|
14
|
+
self.exchange_symbols = {} # Map of exchange -> supported symbols
|
|
15
|
+
self.running = True
|
|
16
|
+
self.lock = asyncio.Lock()
|
|
17
|
+
self.update_event = asyncio.Event()
|
|
18
|
+
self._callbacks = []
|
|
19
|
+
self._collection_tasks = []
|
|
20
|
+
|
|
21
|
+
@catch_exception
|
|
22
|
+
def _initialize_exchanges(self) -> Dict[str, ccxt.Exchange]:
|
|
23
|
+
"""Initialize exchange instances with appropriate settings."""
|
|
24
|
+
exchanges = {}
|
|
25
|
+
for exchange_id in self.config.exchanges:
|
|
26
|
+
exchange_class = getattr(ccxt, exchange_id)
|
|
27
|
+
instance = exchange_class({
|
|
28
|
+
'enableRateLimit': True,
|
|
29
|
+
})
|
|
30
|
+
is_sandbox = self.config.sandbox_mode.get(exchange_id, False)
|
|
31
|
+
instance.set_sandbox_mode(is_sandbox)
|
|
32
|
+
exchanges[exchange_id] = instance
|
|
33
|
+
return exchanges
|
|
34
|
+
|
|
35
|
+
@catch_exception
|
|
36
|
+
async def _verify_exchange_symbols(self):
|
|
37
|
+
"""Verify which symbols are available on each exchange."""
|
|
38
|
+
for exchange_id, exchange in self.exchanges.items():
|
|
39
|
+
markets = await exchange.load_markets()
|
|
40
|
+
available_symbols = set()
|
|
41
|
+
for symbol in self.config.symbols:
|
|
42
|
+
if symbol in markets:
|
|
43
|
+
available_symbols.add(symbol)
|
|
44
|
+
self.exchange_symbols[exchange_id] = available_symbols
|
|
45
|
+
logger.info(f"Exchange {exchange_id} supports symbols: {available_symbols}")
|
|
46
|
+
|
|
47
|
+
@catch_exception
|
|
48
|
+
def add_callback(self, callback):
|
|
49
|
+
"""Register a new callback function."""
|
|
50
|
+
self._callbacks.append(callback)
|
|
51
|
+
|
|
52
|
+
@catch_exception
|
|
53
|
+
def remove_callback(self, callback):
|
|
54
|
+
"""Remove a registered callback function."""
|
|
55
|
+
if callback in self._callbacks:
|
|
56
|
+
self._callbacks.remove(callback)
|
|
57
|
+
|
|
58
|
+
@catch_exception
|
|
59
|
+
async def _execute_callbacks(self, data):
|
|
60
|
+
"""Execute registered callbacks with data."""
|
|
61
|
+
for callback in self._callbacks:
|
|
62
|
+
await callback(data)
|
|
63
|
+
|
|
64
|
+
@catch_exception
|
|
65
|
+
async def start(self, timeout=None):
|
|
66
|
+
"""
|
|
67
|
+
Start collecting data for all valid exchange-symbol combinations.
|
|
68
|
+
Args:
|
|
69
|
+
timeout: Optional timeout in seconds. If None, runs indefinitely.
|
|
70
|
+
"""
|
|
71
|
+
# First verify which exchanges support which symbols
|
|
72
|
+
await self._verify_exchange_symbols()
|
|
73
|
+
|
|
74
|
+
# Subclasses should override and implement this method to create collection tasks
|
|
75
|
+
# using self._create_collection_tasks() and then call super().start(timeout)
|
|
76
|
+
|
|
77
|
+
# If timeout is set, create a task to handle it
|
|
78
|
+
if timeout is not None:
|
|
79
|
+
asyncio.create_task(self._handle_timeout(timeout))
|
|
80
|
+
|
|
81
|
+
@catch_exception
|
|
82
|
+
async def _handle_timeout(self, timeout):
|
|
83
|
+
"""Handle timeout for the manager."""
|
|
84
|
+
await asyncio.sleep(timeout)
|
|
85
|
+
logger.info(f"Manager reached timeout of {timeout} seconds")
|
|
86
|
+
self.running = False
|
|
87
|
+
|
|
88
|
+
@catch_exception
|
|
89
|
+
async def close(self):
|
|
90
|
+
"""Clean up resources and close exchange connections."""
|
|
91
|
+
self.running = False
|
|
92
|
+
|
|
93
|
+
# Cancel any running collection tasks
|
|
94
|
+
for task in self._collection_tasks:
|
|
95
|
+
task.cancel()
|
|
96
|
+
if self._collection_tasks:
|
|
97
|
+
await asyncio.gather(*self._collection_tasks, return_exceptions=True)
|
|
98
|
+
self._collection_tasks.clear()
|
|
99
|
+
|
|
100
|
+
# Close exchange connections
|
|
101
|
+
await asyncio.gather(*[exchange.close() for exchange in self.exchanges.values()], return_exceptions=True)
|
|
102
|
+
self._callbacks.clear()
|
|
103
|
+
|
|
104
|
+
logger.info(f"{self.__class__.__name__} closed")
|
sory/core/executor.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
from typing import Dict, List, Tuple
|
|
3
|
+
import ccxt.pro as ccxt
|
|
4
|
+
import time
|
|
5
|
+
from sory.models import OrderbookConfig, OrderConfig, APIConfig
|
|
6
|
+
from sory.utils.logger import logger, catch_exception
|
|
7
|
+
from sory.exceptions import SoryError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class OrderExecutor:
|
|
11
|
+
@catch_exception
|
|
12
|
+
def __init__(self, orderbook_config: OrderbookConfig, api_configs: List[APIConfig], margin_mode: bool = False):
|
|
13
|
+
"""Initialize the order executor with the given configuration."""
|
|
14
|
+
if not orderbook_config.aggregated or len(orderbook_config.symbols) != 1:
|
|
15
|
+
raise ValueError("OrderExecutor requires aggregated data and exactly one symbol in orderbook_config")
|
|
16
|
+
|
|
17
|
+
self.orderbook_config = orderbook_config
|
|
18
|
+
self.api_configs = {config.exchange: config for config in api_configs}
|
|
19
|
+
self.margin_mode = margin_mode
|
|
20
|
+
self.exchanges = {}
|
|
21
|
+
self.symbol = orderbook_config.symbols[0]
|
|
22
|
+
self.latest_orderbook = None
|
|
23
|
+
self._initialized = False
|
|
24
|
+
|
|
25
|
+
@catch_exception
|
|
26
|
+
async def initialize(self):
|
|
27
|
+
"""Initialize connection to exchanges and load markets."""
|
|
28
|
+
if self._initialized:
|
|
29
|
+
logger.info("OrderExecutor already initialized")
|
|
30
|
+
return
|
|
31
|
+
|
|
32
|
+
for exchange_id in self.orderbook_config.exchanges:
|
|
33
|
+
if exchange_id not in self.api_configs:
|
|
34
|
+
continue
|
|
35
|
+
|
|
36
|
+
config = self.api_configs[exchange_id]
|
|
37
|
+
exchange_class = getattr(ccxt, exchange_id)
|
|
38
|
+
|
|
39
|
+
options = {
|
|
40
|
+
'adjustForTimeDifference': True,
|
|
41
|
+
'recvWindow': 20000
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if self.margin_mode:
|
|
45
|
+
options['defaultType'] = 'margin'
|
|
46
|
+
|
|
47
|
+
exchange = exchange_class({
|
|
48
|
+
'apiKey': config.api_key,
|
|
49
|
+
'secret': config.secret,
|
|
50
|
+
'password': config.password,
|
|
51
|
+
'enableRateLimit': True,
|
|
52
|
+
'options': options
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
# Test authentication
|
|
56
|
+
exchange.check_required_credentials()
|
|
57
|
+
|
|
58
|
+
is_sandbox = self.orderbook_config.sandbox_mode.get(exchange_id, False)
|
|
59
|
+
exchange.set_sandbox_mode(is_sandbox)
|
|
60
|
+
|
|
61
|
+
# Load markets
|
|
62
|
+
await exchange.load_markets()
|
|
63
|
+
logger.info(f"Markets loaded for {exchange_id}")
|
|
64
|
+
|
|
65
|
+
self.exchanges[exchange_id] = exchange
|
|
66
|
+
|
|
67
|
+
if not self.exchanges:
|
|
68
|
+
raise SoryError("No exchanges could be initialized with provided API configurations")
|
|
69
|
+
|
|
70
|
+
self._initialized = True
|
|
71
|
+
logger.info("OrderExecutor initialized successfully")
|
|
72
|
+
|
|
73
|
+
@catch_exception
|
|
74
|
+
def _prepare_margin_params(self, exchange_id: str) -> Dict:
|
|
75
|
+
"""Prepare exchange-specific margin parameters."""
|
|
76
|
+
if not self.margin_mode:
|
|
77
|
+
return {} # Return empty params for spot trading
|
|
78
|
+
|
|
79
|
+
# Return margin params only if margin_mode is True
|
|
80
|
+
if exchange_id == 'binance':
|
|
81
|
+
return {
|
|
82
|
+
'marginMode': 'cross',
|
|
83
|
+
'sideEffectType': 'AUTO_BORROW_REPAY'
|
|
84
|
+
}
|
|
85
|
+
elif exchange_id == 'bybit':
|
|
86
|
+
return {
|
|
87
|
+
'isLeverage': 1,
|
|
88
|
+
'leverageType': 'cross_margin',
|
|
89
|
+
'spotMarginTrading': True,
|
|
90
|
+
'marginTrading': True
|
|
91
|
+
}
|
|
92
|
+
elif exchange_id == 'gate':
|
|
93
|
+
return {
|
|
94
|
+
'account': 'cross_margin',
|
|
95
|
+
'auto_borrow': True,
|
|
96
|
+
'marginMode': 'cross',
|
|
97
|
+
'unifiedAccount': True
|
|
98
|
+
}
|
|
99
|
+
return {}
|
|
100
|
+
|
|
101
|
+
@catch_exception
|
|
102
|
+
async def update_orderbook(self, orderbook: Dict):
|
|
103
|
+
"""Update the latest orderbook data."""
|
|
104
|
+
if self.symbol not in orderbook:
|
|
105
|
+
raise SoryError(f"No orderbook data available for symbol {self.symbol}")
|
|
106
|
+
self.latest_orderbook = orderbook[self.symbol]
|
|
107
|
+
|
|
108
|
+
@catch_exception
|
|
109
|
+
async def execute_order(self, order: OrderConfig) -> Dict:
|
|
110
|
+
"""Execute market orders across available exchanges."""
|
|
111
|
+
if not self._initialized:
|
|
112
|
+
raise SoryError("OrderExecutor not initialized. Call initialize_executor() first")
|
|
113
|
+
|
|
114
|
+
if not self.latest_orderbook:
|
|
115
|
+
raise SoryError("No orderbook data available")
|
|
116
|
+
|
|
117
|
+
execution_plan = self._create_execution_plan(order)
|
|
118
|
+
if not execution_plan:
|
|
119
|
+
raise SoryError("Could not create valid execution plan with available liquidity")
|
|
120
|
+
|
|
121
|
+
start_time = time.time()
|
|
122
|
+
|
|
123
|
+
# Execute all orders in parallel
|
|
124
|
+
execution_tasks = [
|
|
125
|
+
self.execute_single_order(exchange_id, amount, order)
|
|
126
|
+
for exchange_id, amount in execution_plan
|
|
127
|
+
]
|
|
128
|
+
execution_results = await asyncio.gather(*execution_tasks)
|
|
129
|
+
|
|
130
|
+
# Calculate totals
|
|
131
|
+
total_filled_amount = sum(
|
|
132
|
+
result['filled_amount']
|
|
133
|
+
for result in execution_results
|
|
134
|
+
if result['status'] == 'success'
|
|
135
|
+
)
|
|
136
|
+
remaining_amount = max(0, order.amount - total_filled_amount)
|
|
137
|
+
|
|
138
|
+
# Get execution times
|
|
139
|
+
execution_times = {
|
|
140
|
+
'total': round((time.time() - start_time) * 1000, 2)
|
|
141
|
+
}
|
|
142
|
+
for result in execution_results:
|
|
143
|
+
execution_times[result['exchange']] = result['execution_time']
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
"status": "completed" if remaining_amount <= 1e-8 else "partially_filled",
|
|
147
|
+
"original_amount": order.amount,
|
|
148
|
+
"filled_amount": total_filled_amount,
|
|
149
|
+
"remaining_amount": remaining_amount,
|
|
150
|
+
"execution_results": execution_results,
|
|
151
|
+
"execution_plan": execution_plan,
|
|
152
|
+
"execution_times": execution_times
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@catch_exception
|
|
156
|
+
async def execute_single_order(self, exchange_id: str, amount: float, order: OrderConfig) -> Dict:
|
|
157
|
+
"""Execute a single market order on one exchange."""
|
|
158
|
+
execution_start_time = time.time()
|
|
159
|
+
|
|
160
|
+
exchange = self.exchanges[exchange_id]
|
|
161
|
+
params = self._prepare_margin_params(exchange_id)
|
|
162
|
+
|
|
163
|
+
logger.info(f"Executing market order on {exchange_id}: {order.side} | {amount} | {self.symbol}")
|
|
164
|
+
|
|
165
|
+
# Gate requires price for market orders, others don't
|
|
166
|
+
price = None if exchange_id != 'gate' else 1
|
|
167
|
+
executed_order = await exchange.createOrder(
|
|
168
|
+
self.symbol,
|
|
169
|
+
'market',
|
|
170
|
+
order.side,
|
|
171
|
+
amount,
|
|
172
|
+
price,
|
|
173
|
+
params
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
execution_time = round((time.time() - execution_start_time) * 1000, 2)
|
|
177
|
+
filled_amount = float(executed_order.get('filled', 0) or 0)
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
"exchange": exchange_id,
|
|
181
|
+
"order": executed_order,
|
|
182
|
+
"status": "success",
|
|
183
|
+
"planned_amount": amount,
|
|
184
|
+
"filled_amount": filled_amount,
|
|
185
|
+
"execution_time": execution_time
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
@catch_exception
|
|
189
|
+
def _create_execution_plan(self, order: OrderConfig) -> List[Tuple[str, float]]:
|
|
190
|
+
"""Create an execution plan for market orders based on available liquidity."""
|
|
191
|
+
book_side = self.latest_orderbook['bids'] if order.side == 'sell' else self.latest_orderbook['asks']
|
|
192
|
+
executions = {}
|
|
193
|
+
remaining = order.amount
|
|
194
|
+
|
|
195
|
+
# Fast path: Just accumulate by exchange until we hit our target
|
|
196
|
+
for _, size, exchange in book_side:
|
|
197
|
+
if exchange in self.exchanges:
|
|
198
|
+
amount = min(size, remaining)
|
|
199
|
+
if exchange in executions:
|
|
200
|
+
executions[exchange] += amount
|
|
201
|
+
else:
|
|
202
|
+
executions[exchange] = amount
|
|
203
|
+
remaining -= amount
|
|
204
|
+
if remaining <= 0:
|
|
205
|
+
break
|
|
206
|
+
|
|
207
|
+
# Final plan considering minimum amounts
|
|
208
|
+
final_plan = []
|
|
209
|
+
for exchange, amount in executions.items():
|
|
210
|
+
min_amount = self.exchanges[exchange].market(self.symbol)['limits']['amount']['min']
|
|
211
|
+
if amount >= min_amount:
|
|
212
|
+
final_plan.append((exchange, amount))
|
|
213
|
+
|
|
214
|
+
# Log the execution plan
|
|
215
|
+
logger.info(f"Created market order execution plan:")
|
|
216
|
+
for exchange, amount in final_plan:
|
|
217
|
+
logger.info(f"{exchange}: {amount}")
|
|
218
|
+
|
|
219
|
+
return final_plan
|
|
220
|
+
|
|
221
|
+
@catch_exception
|
|
222
|
+
async def close(self):
|
|
223
|
+
"""Close all exchange connections."""
|
|
224
|
+
close_tasks = []
|
|
225
|
+
for exchange in self.exchanges.values():
|
|
226
|
+
if hasattr(exchange, 'close'):
|
|
227
|
+
close_tasks.append(exchange.close())
|
|
228
|
+
|
|
229
|
+
if close_tasks:
|
|
230
|
+
await asyncio.gather(*close_tasks, return_exceptions=True)
|
|
231
|
+
|
|
232
|
+
self._initialized = False
|
|
233
|
+
logger.info("OrderExecutor closed")
|