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 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")