walrasquant-lib 0.4.20__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.
Files changed (105) hide show
  1. walrasquant/__init__.py +7 -0
  2. walrasquant/aggregation.py +449 -0
  3. walrasquant/backends/__init__.py +5 -0
  4. walrasquant/backends/db.py +109 -0
  5. walrasquant/backends/db_memory.py +61 -0
  6. walrasquant/backends/db_postgresql.py +321 -0
  7. walrasquant/backends/db_sqlite.py +310 -0
  8. walrasquant/base/__init__.py +24 -0
  9. walrasquant/base/api_client.py +46 -0
  10. walrasquant/base/connector.py +863 -0
  11. walrasquant/base/ems.py +794 -0
  12. walrasquant/base/exchange.py +213 -0
  13. walrasquant/base/oms.py +428 -0
  14. walrasquant/base/retry.py +220 -0
  15. walrasquant/base/sms.py +545 -0
  16. walrasquant/base/ws_client.py +408 -0
  17. walrasquant/config.py +284 -0
  18. walrasquant/constants.py +413 -0
  19. walrasquant/core/__init__.py +0 -0
  20. walrasquant/core/cache.py +688 -0
  21. walrasquant/core/clock.py +59 -0
  22. walrasquant/core/connection.py +41 -0
  23. walrasquant/core/entity.py +504 -0
  24. walrasquant/core/nautilius_core.py +103 -0
  25. walrasquant/core/registry.py +41 -0
  26. walrasquant/engine.py +745 -0
  27. walrasquant/error.py +34 -0
  28. walrasquant/exchange/__init__.py +13 -0
  29. walrasquant/exchange/base_factory.py +172 -0
  30. walrasquant/exchange/binance/__init__.py +30 -0
  31. walrasquant/exchange/binance/connector.py +1093 -0
  32. walrasquant/exchange/binance/constants.py +934 -0
  33. walrasquant/exchange/binance/ems.py +140 -0
  34. walrasquant/exchange/binance/error.py +48 -0
  35. walrasquant/exchange/binance/exchange.py +144 -0
  36. walrasquant/exchange/binance/factory.py +115 -0
  37. walrasquant/exchange/binance/oms.py +1807 -0
  38. walrasquant/exchange/binance/rest_api.py +1653 -0
  39. walrasquant/exchange/binance/schema.py +1063 -0
  40. walrasquant/exchange/binance/websockets.py +389 -0
  41. walrasquant/exchange/bitget/__init__.py +28 -0
  42. walrasquant/exchange/bitget/connector.py +578 -0
  43. walrasquant/exchange/bitget/constants.py +392 -0
  44. walrasquant/exchange/bitget/ems.py +202 -0
  45. walrasquant/exchange/bitget/error.py +36 -0
  46. walrasquant/exchange/bitget/exchange.py +128 -0
  47. walrasquant/exchange/bitget/factory.py +135 -0
  48. walrasquant/exchange/bitget/oms.py +1619 -0
  49. walrasquant/exchange/bitget/rest_api.py +610 -0
  50. walrasquant/exchange/bitget/schema.py +885 -0
  51. walrasquant/exchange/bitget/websockets.py +753 -0
  52. walrasquant/exchange/bybit/__init__.py +32 -0
  53. walrasquant/exchange/bybit/connector.py +819 -0
  54. walrasquant/exchange/bybit/constants.py +479 -0
  55. walrasquant/exchange/bybit/ems.py +93 -0
  56. walrasquant/exchange/bybit/error.py +36 -0
  57. walrasquant/exchange/bybit/exchange.py +108 -0
  58. walrasquant/exchange/bybit/factory.py +128 -0
  59. walrasquant/exchange/bybit/oms.py +1195 -0
  60. walrasquant/exchange/bybit/rest_api.py +570 -0
  61. walrasquant/exchange/bybit/schema.py +867 -0
  62. walrasquant/exchange/bybit/websockets.py +307 -0
  63. walrasquant/exchange/hyperliquid/__init__.py +28 -0
  64. walrasquant/exchange/hyperliquid/connector.py +370 -0
  65. walrasquant/exchange/hyperliquid/constants.py +371 -0
  66. walrasquant/exchange/hyperliquid/ems.py +156 -0
  67. walrasquant/exchange/hyperliquid/error.py +48 -0
  68. walrasquant/exchange/hyperliquid/exchange.py +120 -0
  69. walrasquant/exchange/hyperliquid/factory.py +135 -0
  70. walrasquant/exchange/hyperliquid/oms.py +1081 -0
  71. walrasquant/exchange/hyperliquid/rest_api.py +348 -0
  72. walrasquant/exchange/hyperliquid/schema.py +583 -0
  73. walrasquant/exchange/hyperliquid/websockets.py +592 -0
  74. walrasquant/exchange/okx/__init__.py +25 -0
  75. walrasquant/exchange/okx/connector.py +931 -0
  76. walrasquant/exchange/okx/constants.py +518 -0
  77. walrasquant/exchange/okx/ems.py +144 -0
  78. walrasquant/exchange/okx/error.py +66 -0
  79. walrasquant/exchange/okx/exchange.py +102 -0
  80. walrasquant/exchange/okx/factory.py +138 -0
  81. walrasquant/exchange/okx/oms.py +1199 -0
  82. walrasquant/exchange/okx/rest_api.py +799 -0
  83. walrasquant/exchange/okx/schema.py +1449 -0
  84. walrasquant/exchange/okx/websockets.py +420 -0
  85. walrasquant/exchange/registry.py +201 -0
  86. walrasquant/execution/__init__.py +24 -0
  87. walrasquant/execution/algorithm.py +968 -0
  88. walrasquant/execution/algorithms/__init__.py +3 -0
  89. walrasquant/execution/algorithms/twap.py +392 -0
  90. walrasquant/execution/config.py +34 -0
  91. walrasquant/execution/constants.py +27 -0
  92. walrasquant/execution/schema.py +62 -0
  93. walrasquant/indicator.py +382 -0
  94. walrasquant/push.py +77 -0
  95. walrasquant/schema.py +755 -0
  96. walrasquant/strategy.py +1805 -0
  97. walrasquant/tools/__init__.py +0 -0
  98. walrasquant/tools/pm2_wrapper.py +1016 -0
  99. walrasquant/web/__init__.py +26 -0
  100. walrasquant/web/app.py +157 -0
  101. walrasquant/web/server.py +92 -0
  102. walrasquant_lib-0.4.20.dist-info/METADATA +162 -0
  103. walrasquant_lib-0.4.20.dist-info/RECORD +105 -0
  104. walrasquant_lib-0.4.20.dist-info/WHEEL +4 -0
  105. walrasquant_lib-0.4.20.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,688 @@
1
+ import msgspec
2
+ import asyncio
3
+ import threading
4
+ import re
5
+ import redis
6
+ import nexuslog as logging
7
+
8
+ from typing import Dict, Set, List, Optional, Any, cast
9
+ from collections import defaultdict
10
+ from returns.maybe import maybe
11
+ from pathlib import Path
12
+
13
+ from walrasquant.schema import (
14
+ Order,
15
+ Position,
16
+ ExchangeType,
17
+ Kline,
18
+ BookL1,
19
+ Trade,
20
+ AccountBalance,
21
+ Balance,
22
+ FundingRate,
23
+ IndexPrice,
24
+ MarkPrice,
25
+ BookL2,
26
+ )
27
+ from walrasquant.constants import STATUS_TRANSITIONS, AccountType, KlineInterval
28
+ from walrasquant.core.entity import TaskManager, get_redis_client_if_available
29
+ from walrasquant.core.nautilius_core import LiveClock, MessageBus
30
+ from walrasquant.constants import StorageType, ParamBackend
31
+ from walrasquant.backends import SQLiteBackend, PostgreSQLBackend, MemoryBackend
32
+
33
+
34
+ class AsyncCache:
35
+ _backend: SQLiteBackend | PostgreSQLBackend | MemoryBackend
36
+
37
+ def __init__(
38
+ self,
39
+ strategy_id: str,
40
+ user_id: str,
41
+ msgbus: MessageBus,
42
+ clock: LiveClock,
43
+ task_manager: TaskManager,
44
+ storage_backend: StorageType = StorageType.SQLITE,
45
+ db_path: str = ".keys/cache.db",
46
+ sync_interval: int = 60, # seconds
47
+ expired_time: int = 3600, # seconds
48
+ ):
49
+ parent_dir = Path(db_path).parent
50
+ if not parent_dir.exists():
51
+ parent_dir.mkdir(parents=True, exist_ok=True)
52
+
53
+ self.strategy_id = strategy_id
54
+ self.user_id = user_id
55
+ self._storage_backend = storage_backend
56
+ self._db_path = db_path
57
+
58
+ self._log = logging.getLogger(name=type(self).__name__)
59
+ self._clock = clock
60
+
61
+ # in-memory save
62
+ self._mem_orders: Dict[str, Order] = {} # oid -> Order
63
+ self._mem_open_orders: Dict[ExchangeType, Set[str]] = defaultdict(
64
+ set
65
+ ) # exchange_id -> set(oid)
66
+ self._mem_symbol_open_orders: Dict[str, Set[str]] = defaultdict(
67
+ set
68
+ ) # symbol -> set(oid)
69
+ self._mem_symbol_orders: Dict[str, Set[str]] = defaultdict(
70
+ set
71
+ ) # symbol -> set(oid)
72
+ self._mem_positions: Dict[str, Position] = {} # symbol -> Position
73
+ self._mem_account_balance: Dict[AccountType, AccountBalance] = defaultdict(
74
+ AccountBalance
75
+ )
76
+ self._mem_params: Dict[str, Any] = {} # params cache
77
+ self._cancel_intent_oids: Set[str] = (
78
+ set()
79
+ ) # oids currently pending cancel intent
80
+ self._inflight_orders: Dict[str, Set[str]] = defaultdict(
81
+ set
82
+ ) # symbol -> set(oid) orders submitted but not yet acknowledged by exchange
83
+
84
+ # set params
85
+ self._sync_interval = sync_interval # sync interval
86
+ self._expired_time = expired_time # expire time
87
+ self._task_manager = task_manager
88
+
89
+ self._kline_cache: Dict[str, Kline] = {}
90
+ self._bookl1_cache: Dict[str, BookL1] = {}
91
+ self._trade_cache: Dict[str, Trade] = {}
92
+ self._bookl2_cache: Dict[str, BookL2] = {}
93
+ self._funding_rate_cache: Dict[str, FundingRate] = {}
94
+ self._index_price_cache: Dict[str, IndexPrice] = {}
95
+ self._mark_price_cache: Dict[str, MarkPrice] = {}
96
+
97
+ self._msgbus = msgbus
98
+ self._msgbus.subscribe(topic="kline", handler=self._update_kline_cache)
99
+ self._msgbus.subscribe(topic="bookl1", handler=self._update_bookl1_cache)
100
+ self._msgbus.subscribe(topic="trade", handler=self._update_trade_cache)
101
+ self._msgbus.subscribe(topic="bookl2", handler=self._update_bookl2_cache)
102
+ self._msgbus.subscribe(
103
+ topic="funding_rate", handler=self._update_funding_rate_cache
104
+ )
105
+ self._msgbus.subscribe(
106
+ topic="index_price", handler=self._update_index_price_cache
107
+ )
108
+ self._msgbus.subscribe(
109
+ topic="mark_price", handler=self._update_mark_price_cache
110
+ )
111
+
112
+ self._storage_initialized = False
113
+ self._table_prefix = self.safe_table_name(f"{self.strategy_id}_{self.user_id}")
114
+
115
+ self._position_lock = threading.RLock() # Lock for position updates
116
+ self._order_lock = threading.RLock() # Lock for order updates
117
+ self._balance_lock = threading.RLock() # Lock for balance updates
118
+
119
+ # Redis client for parameter storage (lazy initialization)
120
+ self._redis_client = None
121
+ self._redis_client_initialized = False
122
+
123
+ ################# # base functions ####################
124
+
125
+ @staticmethod
126
+ def safe_table_name(name: str) -> str:
127
+ name = re.sub(r"[^a-zA-Z0-9_]", "_", name)
128
+ return name.lower()
129
+
130
+ async def _init_storage(self):
131
+ """Initialize the storage backend"""
132
+ if self._storage_backend == StorageType.SQLITE:
133
+ self._backend = SQLiteBackend(
134
+ strategy_id=self.strategy_id,
135
+ user_id=self.user_id,
136
+ table_prefix=self._table_prefix,
137
+ log=self._log,
138
+ db_path=self._db_path,
139
+ )
140
+ elif self._storage_backend == StorageType.POSTGRESQL:
141
+ self._backend = PostgreSQLBackend(
142
+ strategy_id=self.strategy_id,
143
+ user_id=self.user_id,
144
+ table_prefix=self._table_prefix,
145
+ log=self._log,
146
+ )
147
+ elif self._storage_backend == StorageType.MEMORY:
148
+ self._backend = MemoryBackend()
149
+
150
+ assert self._backend is not None
151
+
152
+ await self._backend.start()
153
+ self._storage_initialized = True
154
+
155
+ async def _load_params_from_db(self):
156
+ """Load existing parameters from database"""
157
+ try:
158
+ existing_params = self._backend.get_all_params()
159
+ self._mem_params.update(existing_params)
160
+ if existing_params:
161
+ self._log.debug(
162
+ f"Loaded {len(existing_params)} parameters from database"
163
+ )
164
+ except Exception as e:
165
+ self._log.error(f"Error loading parameters from database: {e}")
166
+
167
+ async def start(self):
168
+ """Start the cache"""
169
+ await self._init_storage()
170
+ if self._storage_backend != StorageType.MEMORY:
171
+ # Load existing parameters from database
172
+ await self._load_params_from_db()
173
+ self._task_manager.create_task(self._periodic_sync())
174
+
175
+ async def _periodic_sync(self):
176
+ """Periodically sync the cache"""
177
+ while True:
178
+ await self._backend.sync_orders(self._mem_orders)
179
+ await self._backend.sync_positions(self._mem_positions)
180
+ await self._backend.sync_open_orders(
181
+ self._mem_open_orders, self._mem_orders
182
+ )
183
+ await self._backend.sync_balances(self._mem_account_balance)
184
+ await self._backend.sync_params(self._mem_params)
185
+ self._cleanup_expired_data()
186
+ await asyncio.sleep(self._sync_interval)
187
+
188
+ async def sync_orders(self):
189
+ with self._order_lock:
190
+ await self._backend.sync_orders(self._mem_orders)
191
+
192
+ async def sync_positions(self):
193
+ with self._position_lock:
194
+ await self._backend.sync_positions(self._mem_positions)
195
+
196
+ async def sync_open_orders(self):
197
+ with self._order_lock:
198
+ await self._backend.sync_open_orders(
199
+ self._mem_open_orders, self._mem_orders
200
+ )
201
+
202
+ async def sync_balances(self):
203
+ with self._balance_lock:
204
+ await self._backend.sync_balances(self._mem_account_balance)
205
+
206
+ async def sync_params(self):
207
+ await self._backend.sync_params(self._mem_params)
208
+
209
+ def _cleanup_expired_data(self):
210
+ """Cleanup expired data"""
211
+ current_time = self._clock.timestamp_ms()
212
+ expire_before = current_time - self._expired_time * 1000
213
+
214
+ with self._order_lock:
215
+ expired_orders = []
216
+ for oid, order in self._mem_orders.copy().items():
217
+ if order.timestamp is not None and order.timestamp < expire_before:
218
+ expired_orders.append(oid)
219
+
220
+ if not order.is_closed:
221
+ self._log.warning(f"order {oid} is not closed, but expired")
222
+
223
+ for oid in expired_orders:
224
+ del self._mem_orders[oid]
225
+ self._log.debug(f"removing order {oid} from memory")
226
+ for symbol, order_set in self._mem_symbol_orders.copy().items():
227
+ self._log.debug(f"removing order {oid} from symbol {symbol}")
228
+ order_set.discard(oid)
229
+
230
+ async def close(self):
231
+ """关闭缓存"""
232
+ if self._storage_initialized and self._backend:
233
+ try:
234
+ await self._backend.sync_orders(self._mem_orders)
235
+ await self._backend.sync_positions(self._mem_positions)
236
+ await self._backend.sync_open_orders(
237
+ self._mem_open_orders, self._mem_orders
238
+ )
239
+ await self._backend.sync_balances(self._mem_account_balance)
240
+ await self._backend.sync_params(self._mem_params)
241
+ except Exception as e:
242
+ # Never let cache close crash shutdown; log and continue
243
+ self._log.error(f"Error closing cache (sync phase): {e}")
244
+ finally:
245
+ try:
246
+ await self._backend.close()
247
+ except Exception as e:
248
+ self._log.error(f"Error closing storage backend: {e}")
249
+
250
+ ################ # cache public data ###################
251
+
252
+ def _update_kline_cache(self, kline: Kline):
253
+ key = f"{kline.symbol}-{kline.interval.value}"
254
+ self._kline_cache[key] = kline
255
+
256
+ def _update_bookl1_cache(self, bookl1: BookL1):
257
+ self._bookl1_cache[bookl1.symbol] = bookl1
258
+
259
+ def _update_trade_cache(self, trade: Trade):
260
+ self._trade_cache[trade.symbol] = trade
261
+
262
+ def _update_bookl2_cache(self, bookl2: BookL2):
263
+ self._bookl2_cache[bookl2.symbol] = bookl2
264
+
265
+ def _update_funding_rate_cache(self, funding_rate: FundingRate):
266
+ self._funding_rate_cache[funding_rate.symbol] = funding_rate
267
+
268
+ def _update_index_price_cache(self, index_price: IndexPrice):
269
+ self._index_price_cache[index_price.symbol] = index_price
270
+
271
+ def _update_mark_price_cache(self, mark_price: MarkPrice):
272
+ self._mark_price_cache[mark_price.symbol] = mark_price
273
+
274
+ def kline(self, symbol: str, interval: KlineInterval) -> Optional[Kline]:
275
+ """
276
+ Retrieve a Kline object from the cache by symbol.
277
+
278
+ :param symbol: The symbol of the Kline to retrieve.
279
+ :return: The Kline object if found, otherwise None.
280
+ """
281
+ key = f"{symbol}-{interval.value}"
282
+ return self._kline_cache.get(key, None)
283
+
284
+ def bookl1(self, symbol: str) -> Optional[BookL1]:
285
+ """
286
+ Retrieve a BookL1 object from the cache by symbol.
287
+
288
+ :param symbol: The symbol of the BookL1 to retrieve.
289
+ :return: The BookL1 object if found, otherwise None.
290
+ """
291
+ return self._bookl1_cache.get(symbol, None)
292
+
293
+ def bookl2(self, symbol: str) -> Optional[BookL2]:
294
+ """
295
+ Retrieve a BookL2 object from the cache by symbol.
296
+ """
297
+ return self._bookl2_cache.get(symbol, None)
298
+
299
+ def trade(self, symbol: str) -> Optional[Trade]:
300
+ """
301
+ Retrieve a Trade object from the cache by symbol.
302
+
303
+ :param symbol: The symbol of the Trade to retrieve.
304
+ :return: The Trade object if found, otherwise None.
305
+ """
306
+ return self._trade_cache.get(symbol, None)
307
+
308
+ def funding_rate(self, symbol: str) -> Optional[FundingRate]:
309
+ """
310
+ Retrieve a FundingRate object from the cache by symbol.
311
+ """
312
+ return self._funding_rate_cache.get(symbol, None)
313
+
314
+ def index_price(self, symbol: str) -> Optional[IndexPrice]:
315
+ """
316
+ Retrieve an IndexPrice object from the cache by symbol.
317
+ """
318
+ return self._index_price_cache.get(symbol, None)
319
+
320
+ def mark_price(self, symbol: str) -> Optional[MarkPrice]:
321
+ """
322
+ Retrieve a MarkPrice object from the cache by symbol.
323
+ """
324
+ return self._mark_price_cache.get(symbol, None)
325
+
326
+ ################ # cache private data ###################
327
+
328
+ def _check_status_transition(self, order: Order):
329
+ if order.oid is None:
330
+ return True
331
+ previous_order = self._mem_orders.get(order.oid)
332
+ if not previous_order:
333
+ return True
334
+
335
+ if order.status not in STATUS_TRANSITIONS[previous_order.status]:
336
+ self._log.warning(
337
+ f"Order id: {order.oid} Invalid status transition: {previous_order.status} -> {order.status}"
338
+ )
339
+ return False
340
+
341
+ return True
342
+
343
+ def _apply_position(self, position: Position):
344
+ with self._position_lock:
345
+ if position.is_closed:
346
+ self._mem_positions.pop(position.symbol, None)
347
+ else:
348
+ self._mem_positions[position.symbol] = position
349
+
350
+ def _apply_balance(self, account_type: AccountType, balances: List[Balance]):
351
+ with self._balance_lock:
352
+ self._mem_account_balance[account_type]._apply(balances)
353
+
354
+ def get_balance(self, account_type: AccountType) -> AccountBalance:
355
+ with self._balance_lock:
356
+ return self._mem_account_balance[account_type]
357
+
358
+ @maybe
359
+ def get_position(self, symbol: str) -> Optional[Position]:
360
+ with self._position_lock:
361
+ if position := self._mem_positions.get(symbol, None):
362
+ return position
363
+
364
+ def get_all_positions(
365
+ self, exchange: Optional[ExchangeType] = None
366
+ ) -> Dict[str, Position]:
367
+ with self._position_lock:
368
+ positions = {
369
+ symbol: position
370
+ for symbol, position in self._mem_positions.copy().items()
371
+ if (
372
+ (exchange is None or position.exchange == exchange)
373
+ and position.is_opened
374
+ )
375
+ }
376
+ return positions
377
+
378
+ def _order_status_update(self, order: Order) -> bool:
379
+ if order.oid is None:
380
+ return False
381
+ with self._order_lock:
382
+ if not self._check_status_transition(order):
383
+ return False
384
+ self._mem_orders[order.oid] = order
385
+
386
+ # Remove from inflight tracking once exchange acknowledges the order
387
+ self._inflight_orders[order.symbol].discard(order.oid)
388
+
389
+ # Ensure order is tracked in all sets if it's open (handles WebSocket arriving before REST API)
390
+ if not order.is_closed:
391
+ self._mem_open_orders[order.exchange].add(order.oid)
392
+ self._mem_symbol_orders[order.symbol].add(order.oid)
393
+ self._mem_symbol_open_orders[order.symbol].add(order.oid)
394
+ if order.is_cancel_failed:
395
+ self._cancel_intent_oids.discard(order.oid)
396
+ else:
397
+ self._mem_open_orders[order.exchange].discard(order.oid)
398
+ self._mem_symbol_open_orders[order.symbol].discard(order.oid)
399
+ self._cancel_intent_oids.discard(order.oid)
400
+ return True
401
+
402
+ def mark_all_cancel_intent(self, symbol: str) -> None:
403
+ with self._order_lock:
404
+ oids = self._mem_symbol_open_orders.get(symbol, set())
405
+ self._cancel_intent_oids.update(oids)
406
+ self._log.debug(
407
+ f"Marked {len(oids)} orders as cancel intent for symbol {symbol}"
408
+ )
409
+
410
+ def mark_cancel_intent(self, oid: str) -> None:
411
+ with self._order_lock:
412
+ self._cancel_intent_oids.add(oid)
413
+ self._log.debug(f"Marked order {oid} as cancel intent")
414
+
415
+ def add_inflight_order(self, symbol: str, oid: str) -> None:
416
+ with self._order_lock:
417
+ self._inflight_orders[symbol].add(oid)
418
+
419
+ def get_inflight_orders(self, symbol: str) -> Set[str]:
420
+ with self._order_lock:
421
+ return self._inflight_orders.get(symbol, set()).copy()
422
+
423
+ async def wait_for_inflight_orders(
424
+ self, symbol: str, timeout: float = 5.0, interval: float = 0.1
425
+ ) -> None:
426
+ waited = 0.0
427
+ while self.get_inflight_orders(symbol) and waited < timeout:
428
+ await asyncio.sleep(interval)
429
+ waited += interval
430
+
431
+ remaining = self.get_inflight_orders(symbol)
432
+ if remaining:
433
+ self._log.warning(
434
+ f"Timeout waiting for inflight orders to resolve for {symbol}, "
435
+ f"remaining inflight: {remaining}"
436
+ )
437
+
438
+ # NOTE: this function is not for user to call, it is for internal use
439
+ def _get_all_balances_from_db(self, account_type: AccountType) -> List[Balance]:
440
+ with self._balance_lock:
441
+ return self._backend.get_all_balances(account_type)
442
+
443
+ # NOTE: this function is not for user to call, it is for internal use
444
+ def _get_all_positions_from_db(
445
+ self, exchange_id: ExchangeType
446
+ ) -> Dict[str, Position]:
447
+ return self._backend.get_all_positions(exchange_id)
448
+
449
+ @maybe
450
+ def get_order(self, oid: str) -> Optional[Order]:
451
+ with self._order_lock:
452
+ return self._backend.get_order(oid, self._mem_orders)
453
+
454
+ def get_symbol_orders(self, symbol: str, in_mem: bool = True) -> Set[str]:
455
+ """Get all orders for a symbol from memory and storage"""
456
+ with self._order_lock:
457
+ memory_orders = self._mem_symbol_orders.get(symbol, set())
458
+ if not in_mem:
459
+ storage_orders = self._backend.get_symbol_orders(symbol)
460
+ return memory_orders.union(storage_orders)
461
+ return memory_orders
462
+
463
+ def get_open_orders(
464
+ self,
465
+ symbol: str | None = None,
466
+ exchange: ExchangeType | None = None,
467
+ *,
468
+ include_canceling: bool = False,
469
+ ) -> Set[str]:
470
+ with self._order_lock:
471
+ if symbol is not None:
472
+ orders = self._mem_symbol_open_orders[symbol].copy()
473
+ elif exchange is not None:
474
+ orders = self._mem_open_orders[exchange].copy()
475
+ else:
476
+ raise ValueError("Either `symbol` or `exchange` must be specified")
477
+
478
+ if include_canceling:
479
+ return orders
480
+
481
+ return orders.difference(self._cancel_intent_oids)
482
+
483
+ ################ # parameter cache ###################
484
+
485
+ def _ensure_redis_client(self) -> None:
486
+ """
487
+ Lazy initialization of Redis client.
488
+ Raises RuntimeError if Redis is not available.
489
+ """
490
+ if not self._redis_client_initialized:
491
+ self._redis_client = get_redis_client_if_available()
492
+ self._redis_client_initialized = True
493
+ if self._redis_client is None:
494
+ raise RuntimeError(
495
+ "Redis client is not available. Please ensure Redis is running and configured properly."
496
+ )
497
+
498
+ def _get_redis_param_key(self, key: str) -> str:
499
+ """Generate Redis key for parameter storage"""
500
+ return f"nexus:param:{self.strategy_id}:{self.user_id}:{key}"
501
+
502
+ def get_param(
503
+ self,
504
+ key: str,
505
+ default: Any = None,
506
+ param_backend: ParamBackend = ParamBackend.MEMORY,
507
+ ) -> Any:
508
+ """
509
+ Get a parameter from the cache
510
+
511
+ Args:
512
+ key: Parameter key
513
+ default: Default value if key not found
514
+ param_backend: Storage backend (MEMORY or REDIS)
515
+
516
+ Returns:
517
+ Parameter value or default
518
+
519
+ Raises:
520
+ RuntimeError: If param_backend is REDIS but Redis is not available
521
+ """
522
+ if param_backend == ParamBackend.REDIS:
523
+ self._ensure_redis_client()
524
+ _redis = self._redis_client
525
+ assert _redis is not None
526
+ redis_key = self._get_redis_param_key(key)
527
+ value = cast(Optional[bytes], _redis.get(redis_key))
528
+ if value is None:
529
+ return default
530
+ # Deserialize the value
531
+ return msgspec.json.decode(value)
532
+ else:
533
+ return self._mem_params.get(key, default)
534
+
535
+ def set_param(
536
+ self, key: str, value: Any, param_backend: ParamBackend = ParamBackend.MEMORY
537
+ ) -> None:
538
+ """
539
+ Set a parameter in the cache
540
+
541
+ Args:
542
+ key: Parameter key
543
+ value: Parameter value
544
+ param_backend: Storage backend (MEMORY or REDIS)
545
+
546
+ Raises:
547
+ RuntimeError: If param_backend is REDIS but Redis is not available
548
+ """
549
+ if param_backend == ParamBackend.REDIS:
550
+ self._ensure_redis_client()
551
+ _redis = self._redis_client
552
+ assert _redis is not None
553
+ redis_key = self._get_redis_param_key(key)
554
+ # Serialize the value
555
+ serialized_value = msgspec.json.encode(value)
556
+ _redis.set(redis_key, serialized_value)
557
+ else:
558
+ self._mem_params[key] = value
559
+
560
+ def get_all_params(
561
+ self, param_backend: ParamBackend = ParamBackend.MEMORY
562
+ ) -> Dict[str, Any]:
563
+ """
564
+ Get all parameters from the cache
565
+
566
+ Args:
567
+ param_backend: Storage backend (MEMORY or REDIS)
568
+
569
+ Returns:
570
+ Dictionary of all parameters
571
+
572
+ Raises:
573
+ RuntimeError: If param_backend is REDIS but Redis is not available
574
+ """
575
+ if param_backend == ParamBackend.REDIS:
576
+ self._ensure_redis_client()
577
+ _redis = self._redis_client
578
+ assert _redis is not None
579
+ pattern = f"nexus:param:{self.strategy_id}:{self.user_id}:*"
580
+ params = {}
581
+ # Use SCAN to iterate over keys matching the pattern
582
+ cursor = 0
583
+ while True:
584
+ cursor, keys = _redis.scan(cursor, match=pattern, count=100)
585
+ for redis_key in keys:
586
+ # Extract the parameter key from the Redis key
587
+ key = (
588
+ redis_key.decode()
589
+ if isinstance(redis_key, bytes)
590
+ else str(redis_key)
591
+ )
592
+ param_key = key.split(":", 4)[-1] # Get the part after the last ':'
593
+ value = cast(Optional[bytes], _redis.get(redis_key))
594
+ if value is not None:
595
+ params[param_key] = msgspec.json.decode(value)
596
+ if cursor == 0:
597
+ break
598
+ return params
599
+ else:
600
+ return self._mem_params.copy()
601
+
602
+ def clear_param(
603
+ self,
604
+ key: Optional[str] = None,
605
+ param_backend: ParamBackend = ParamBackend.MEMORY,
606
+ ) -> None:
607
+ """
608
+ Clear parameter(s) from the cache
609
+
610
+ Args:
611
+ key: Parameter key to clear (None to clear all)
612
+ param_backend: Storage backend (MEMORY or REDIS)
613
+
614
+ Raises:
615
+ RuntimeError: If param_backend is REDIS but Redis is not available
616
+ """
617
+ if param_backend == ParamBackend.REDIS:
618
+ self._ensure_redis_client()
619
+ _redis = self._redis_client
620
+ assert _redis is not None
621
+ if key is None:
622
+ # Clear all parameters matching the pattern
623
+ pattern = f"nexus:param:{self.strategy_id}:{self.user_id}:*"
624
+ cursor = 0
625
+ while True:
626
+ cursor, keys = _redis.scan(cursor, match=pattern, count=100)
627
+ if keys:
628
+ _redis.delete(*keys)
629
+ if cursor == 0:
630
+ break
631
+ else:
632
+ # Clear specific parameter
633
+ redis_key = self._get_redis_param_key(key)
634
+ _redis.delete(redis_key)
635
+ else:
636
+ if key is None:
637
+ # Clear all parameters
638
+ self._mem_params.clear()
639
+ else:
640
+ # Clear specific parameter
641
+ self._mem_params.pop(key, None)
642
+
643
+ ################ # Redis direct access ###################
644
+
645
+ @property
646
+ def redis(self) -> Optional[redis.Redis]:
647
+ """
648
+ Direct access to Redis client for calling any Redis methods.
649
+
650
+ Returns:
651
+ Redis client instance
652
+
653
+ Raises:
654
+ RuntimeError: If Redis is not available
655
+
656
+ Example:
657
+ # Set a key
658
+ cache.redis.set("mykey", "myvalue")
659
+
660
+ # Get a key
661
+ value = cache.redis.get("mykey")
662
+
663
+ # Use hash operations
664
+ cache.redis.hset("myhash", "field1", "value1")
665
+ cache.redis.hget("myhash", "field1")
666
+
667
+ # Use list operations
668
+ cache.redis.lpush("mylist", "item1")
669
+ cache.redis.rpop("mylist")
670
+
671
+ # Use set operations
672
+ cache.redis.sadd("myset", "member1")
673
+ cache.redis.smembers("myset")
674
+
675
+ # Use sorted set operations
676
+ cache.redis.zadd("myzset", {"member1": 1.0})
677
+ cache.redis.zrange("myzset", 0, -1)
678
+
679
+ # Pipeline operations
680
+ pipe = cache.redis.pipeline()
681
+ pipe.set("key1", "value1")
682
+ pipe.set("key2", "value2")
683
+ pipe.execute()
684
+
685
+ # All standard Redis commands are available
686
+ """
687
+ self._ensure_redis_client()
688
+ return self._redis_client