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.
- walrasquant/__init__.py +7 -0
- walrasquant/aggregation.py +449 -0
- walrasquant/backends/__init__.py +5 -0
- walrasquant/backends/db.py +109 -0
- walrasquant/backends/db_memory.py +61 -0
- walrasquant/backends/db_postgresql.py +321 -0
- walrasquant/backends/db_sqlite.py +310 -0
- walrasquant/base/__init__.py +24 -0
- walrasquant/base/api_client.py +46 -0
- walrasquant/base/connector.py +863 -0
- walrasquant/base/ems.py +794 -0
- walrasquant/base/exchange.py +213 -0
- walrasquant/base/oms.py +428 -0
- walrasquant/base/retry.py +220 -0
- walrasquant/base/sms.py +545 -0
- walrasquant/base/ws_client.py +408 -0
- walrasquant/config.py +284 -0
- walrasquant/constants.py +413 -0
- walrasquant/core/__init__.py +0 -0
- walrasquant/core/cache.py +688 -0
- walrasquant/core/clock.py +59 -0
- walrasquant/core/connection.py +41 -0
- walrasquant/core/entity.py +504 -0
- walrasquant/core/nautilius_core.py +103 -0
- walrasquant/core/registry.py +41 -0
- walrasquant/engine.py +745 -0
- walrasquant/error.py +34 -0
- walrasquant/exchange/__init__.py +13 -0
- walrasquant/exchange/base_factory.py +172 -0
- walrasquant/exchange/binance/__init__.py +30 -0
- walrasquant/exchange/binance/connector.py +1093 -0
- walrasquant/exchange/binance/constants.py +934 -0
- walrasquant/exchange/binance/ems.py +140 -0
- walrasquant/exchange/binance/error.py +48 -0
- walrasquant/exchange/binance/exchange.py +144 -0
- walrasquant/exchange/binance/factory.py +115 -0
- walrasquant/exchange/binance/oms.py +1807 -0
- walrasquant/exchange/binance/rest_api.py +1653 -0
- walrasquant/exchange/binance/schema.py +1063 -0
- walrasquant/exchange/binance/websockets.py +389 -0
- walrasquant/exchange/bitget/__init__.py +28 -0
- walrasquant/exchange/bitget/connector.py +578 -0
- walrasquant/exchange/bitget/constants.py +392 -0
- walrasquant/exchange/bitget/ems.py +202 -0
- walrasquant/exchange/bitget/error.py +36 -0
- walrasquant/exchange/bitget/exchange.py +128 -0
- walrasquant/exchange/bitget/factory.py +135 -0
- walrasquant/exchange/bitget/oms.py +1619 -0
- walrasquant/exchange/bitget/rest_api.py +610 -0
- walrasquant/exchange/bitget/schema.py +885 -0
- walrasquant/exchange/bitget/websockets.py +753 -0
- walrasquant/exchange/bybit/__init__.py +32 -0
- walrasquant/exchange/bybit/connector.py +819 -0
- walrasquant/exchange/bybit/constants.py +479 -0
- walrasquant/exchange/bybit/ems.py +93 -0
- walrasquant/exchange/bybit/error.py +36 -0
- walrasquant/exchange/bybit/exchange.py +108 -0
- walrasquant/exchange/bybit/factory.py +128 -0
- walrasquant/exchange/bybit/oms.py +1195 -0
- walrasquant/exchange/bybit/rest_api.py +570 -0
- walrasquant/exchange/bybit/schema.py +867 -0
- walrasquant/exchange/bybit/websockets.py +307 -0
- walrasquant/exchange/hyperliquid/__init__.py +28 -0
- walrasquant/exchange/hyperliquid/connector.py +370 -0
- walrasquant/exchange/hyperliquid/constants.py +371 -0
- walrasquant/exchange/hyperliquid/ems.py +156 -0
- walrasquant/exchange/hyperliquid/error.py +48 -0
- walrasquant/exchange/hyperliquid/exchange.py +120 -0
- walrasquant/exchange/hyperliquid/factory.py +135 -0
- walrasquant/exchange/hyperliquid/oms.py +1081 -0
- walrasquant/exchange/hyperliquid/rest_api.py +348 -0
- walrasquant/exchange/hyperliquid/schema.py +583 -0
- walrasquant/exchange/hyperliquid/websockets.py +592 -0
- walrasquant/exchange/okx/__init__.py +25 -0
- walrasquant/exchange/okx/connector.py +931 -0
- walrasquant/exchange/okx/constants.py +518 -0
- walrasquant/exchange/okx/ems.py +144 -0
- walrasquant/exchange/okx/error.py +66 -0
- walrasquant/exchange/okx/exchange.py +102 -0
- walrasquant/exchange/okx/factory.py +138 -0
- walrasquant/exchange/okx/oms.py +1199 -0
- walrasquant/exchange/okx/rest_api.py +799 -0
- walrasquant/exchange/okx/schema.py +1449 -0
- walrasquant/exchange/okx/websockets.py +420 -0
- walrasquant/exchange/registry.py +201 -0
- walrasquant/execution/__init__.py +24 -0
- walrasquant/execution/algorithm.py +968 -0
- walrasquant/execution/algorithms/__init__.py +3 -0
- walrasquant/execution/algorithms/twap.py +392 -0
- walrasquant/execution/config.py +34 -0
- walrasquant/execution/constants.py +27 -0
- walrasquant/execution/schema.py +62 -0
- walrasquant/indicator.py +382 -0
- walrasquant/push.py +77 -0
- walrasquant/schema.py +755 -0
- walrasquant/strategy.py +1805 -0
- walrasquant/tools/__init__.py +0 -0
- walrasquant/tools/pm2_wrapper.py +1016 -0
- walrasquant/web/__init__.py +26 -0
- walrasquant/web/app.py +157 -0
- walrasquant/web/server.py +92 -0
- walrasquant_lib-0.4.20.dist-info/METADATA +162 -0
- walrasquant_lib-0.4.20.dist-info/RECORD +105 -0
- walrasquant_lib-0.4.20.dist-info/WHEEL +4 -0
- 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
|