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,863 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Dict, List, Literal, Mapping
3
+ from decimal import Decimal
4
+ import asyncio
5
+ import inspect
6
+ import nexuslog as logging
7
+
8
+ from decimal import ROUND_HALF_UP, ROUND_CEILING, ROUND_FLOOR
9
+ from walrasquant.base.oms import OrderManagementSystem
10
+ from walrasquant.base.ws_client import WSClient
11
+ from walrasquant.base.api_client import ApiClient
12
+ from walrasquant.base.exchange import ExchangeManager
13
+ from walrasquant.aggregation import (
14
+ KlineAggregator,
15
+ TimeKlineAggregator,
16
+ VolumeKlineAggregator,
17
+ )
18
+ from walrasquant.schema import (
19
+ Order,
20
+ BaseMarket,
21
+ Position,
22
+ Balance,
23
+ KlineList,
24
+ Ticker,
25
+ Trade,
26
+ )
27
+ from walrasquant.constants import ExchangeType, AccountType
28
+ from walrasquant.core.cache import AsyncCache
29
+ from walrasquant.core.entity import TaskManager
30
+ from walrasquant.error import OrderError
31
+ from walrasquant.constants import (
32
+ OrderSide,
33
+ OrderType,
34
+ TimeInForce,
35
+ PositionSide,
36
+ KlineInterval,
37
+ BookLevel,
38
+ OrderStatus,
39
+ )
40
+ from walrasquant.core.nautilius_core import LiveClock, MessageBus, UUID4
41
+
42
+
43
+ class ApiProxy:
44
+ def __init__(self, api_client: ApiClient, task_manager: TaskManager):
45
+ self._api_client = api_client
46
+ self._task_manager = task_manager
47
+
48
+ def __getattr__(self, name):
49
+ if not hasattr(self._api_client, name):
50
+ raise AttributeError(f"ApiClient has no attribute '{name}'")
51
+
52
+ api_method = getattr(self._api_client, name)
53
+ if not callable(api_method):
54
+ return api_method
55
+
56
+ # check whether the method is async
57
+ if not inspect.iscoroutinefunction(api_method):
58
+ return api_method
59
+
60
+ def sync_wrapper(*args, **kwargs):
61
+ return self._task_manager.run_sync(api_method(*args, **kwargs))
62
+
63
+ return sync_wrapper
64
+
65
+
66
+ class FireApiProxy:
67
+ """Fire-and-forget proxy: submits async REST calls as background tasks, no return value."""
68
+
69
+ def __init__(self, api_client: ApiClient, task_manager: TaskManager):
70
+ self._api_client = api_client
71
+ self._task_manager = task_manager
72
+
73
+ def __getattr__(self, name):
74
+ if not hasattr(self._api_client, name):
75
+ raise AttributeError(f"ApiClient has no attribute '{name}'")
76
+
77
+ api_method = getattr(self._api_client, name)
78
+ if not inspect.iscoroutinefunction(api_method):
79
+ return api_method
80
+
81
+ def fire_wrapper(*args, **kwargs):
82
+ self._task_manager.create_task(api_method(*args, **kwargs))
83
+
84
+ return fire_wrapper
85
+
86
+
87
+ class PublicConnector(ABC):
88
+ def __init__(
89
+ self,
90
+ account_type: AccountType,
91
+ market: Mapping[str, BaseMarket],
92
+ market_id: Dict[str, str],
93
+ exchange_id: ExchangeType,
94
+ ws_client: WSClient,
95
+ msgbus: MessageBus,
96
+ clock: LiveClock,
97
+ api_client: ApiClient,
98
+ task_manager: TaskManager,
99
+ ):
100
+ self._log = logging.getLogger(name=type(self).__name__)
101
+ self._account_type = account_type
102
+ self._market = market
103
+ self._market_id = market_id
104
+ self._exchange_id = exchange_id
105
+ self._ws_client = ws_client
106
+ self._msgbus = msgbus
107
+ self._api_client = api_client
108
+ self._clock = clock
109
+ self._task_manager = task_manager
110
+
111
+ # Aggregator management - key: symbol, value: list of aggregators
112
+ self._aggregators: Dict[str, List[KlineAggregator]] = {}
113
+ self._msgbus.subscribe(
114
+ topic="trade", handler=self._handle_trade_for_aggregators
115
+ )
116
+
117
+ @property
118
+ def account_type(self):
119
+ return self._account_type
120
+
121
+ @abstractmethod
122
+ async def request_klines(
123
+ self,
124
+ symbol: str,
125
+ interval: KlineInterval,
126
+ limit: int | None = None,
127
+ start_time: int | None = None,
128
+ end_time: int | None = None,
129
+ ) -> KlineList:
130
+ """Request klines"""
131
+ pass
132
+
133
+ @abstractmethod
134
+ async def request_ticker(
135
+ self,
136
+ symbol: str,
137
+ ) -> Ticker:
138
+ """Request 24hr ticker data"""
139
+ pass
140
+
141
+ @abstractmethod
142
+ async def request_all_tickers(
143
+ self,
144
+ ) -> Dict[str, Ticker]:
145
+ """Request 24hr ticker data for multiple symbols"""
146
+ pass
147
+
148
+ @abstractmethod
149
+ async def request_index_klines(
150
+ self,
151
+ symbol: str,
152
+ interval: KlineInterval,
153
+ limit: int | None = None,
154
+ start_time: int | None = None,
155
+ end_time: int | None = None,
156
+ ) -> KlineList:
157
+ """Request index klines"""
158
+ pass
159
+
160
+ @abstractmethod
161
+ def subscribe_trade(self, symbol: str | List[str]):
162
+ """Subscribe to the trade data"""
163
+ pass
164
+
165
+ @abstractmethod
166
+ def unsubscribe_trade(self, symbol: str | List[str]):
167
+ """Unsubscribe from the trade data"""
168
+ pass
169
+
170
+ @abstractmethod
171
+ def subscribe_bookl1(self, symbol: str | List[str]):
172
+ """Subscribe to the bookl1 data"""
173
+ pass
174
+
175
+ @abstractmethod
176
+ def unsubscribe_bookl1(self, symbol: str | List[str]):
177
+ """Unsubscribe from the bookl1 data"""
178
+ pass
179
+
180
+ @abstractmethod
181
+ def subscribe_kline(
182
+ self,
183
+ symbol: str | List[str],
184
+ interval: KlineInterval,
185
+ ):
186
+ """Subscribe to the kline data
187
+
188
+ Args:
189
+ symbol: Symbol(s) to subscribe to
190
+ interval: Kline interval
191
+ use_aggregator: If True, use TimeKlineAggregator instead of exchange native klines
192
+ """
193
+ pass
194
+
195
+ @abstractmethod
196
+ def unsubscribe_kline(
197
+ self,
198
+ symbol: str | List[str],
199
+ interval: KlineInterval,
200
+ ):
201
+ """Unsubscribe from the kline data"""
202
+ pass
203
+
204
+ def subscribe_kline_aggregator(
205
+ self,
206
+ symbol: str,
207
+ interval: KlineInterval,
208
+ build_with_no_updates: bool,
209
+ ):
210
+ """Subscribe to time-based kline data using TimeKlineAggregator
211
+
212
+ Args:
213
+ symbol: Symbol to subscribe to
214
+ interval: Kline interval
215
+ """
216
+ # Ensure trade subscription for the symbol
217
+ self.subscribe_trade(symbol)
218
+
219
+ # Create and register time kline aggregator
220
+ aggregator = self._create_time_kline_aggregator(
221
+ symbol, interval, build_with_no_updates
222
+ )
223
+ self._add_aggregator(symbol, aggregator)
224
+
225
+ self._log.info(
226
+ f"Time kline aggregator created for {symbol} with interval {interval}"
227
+ )
228
+
229
+ def unsubscribe_kline_aggregator(
230
+ self,
231
+ symbol: str,
232
+ interval: KlineInterval,
233
+ ):
234
+ """Unsubscribe from time-based kline data using TimeKlineAggregator
235
+
236
+ Args:
237
+ symbol: Symbol to unsubscribe from
238
+ interval: Kline interval
239
+ """
240
+ aggregators = self._aggregators.get(symbol, [])
241
+ # Create a copy to avoid "list changed size during iteration" error
242
+ for aggregator in aggregators.copy():
243
+ if (
244
+ isinstance(aggregator, TimeKlineAggregator)
245
+ and aggregator.interval == interval
246
+ ):
247
+ aggregator.stop()
248
+ aggregators.remove(aggregator)
249
+ self._log.info(
250
+ f"Time kline aggregator stopped for {symbol} with interval {interval}"
251
+ )
252
+ if not aggregators:
253
+ self._aggregators.pop(symbol, None)
254
+ self.unsubscribe_trade(symbol)
255
+
256
+ def subscribe_volume_kline_aggregator(
257
+ self,
258
+ symbol: str,
259
+ volume_threshold: float,
260
+ volume_type: Literal["BUY", "SELL", "DEFAULT"],
261
+ ):
262
+ """Subscribe to volume-based kline data using VolumeKlineAggregator
263
+
264
+ Args:
265
+ symbol: Symbol to subscribe to
266
+ volume_threshold: Volume threshold for creating new klines
267
+ """
268
+ # Ensure trade subscription for the symbol
269
+ self.subscribe_trade(symbol)
270
+
271
+ # Create and register volume kline aggregator
272
+ aggregator = self._create_volume_kline_aggregator(
273
+ symbol, volume_threshold, volume_type
274
+ )
275
+ self._add_aggregator(symbol, aggregator)
276
+
277
+ self._log.info(
278
+ f"Volume kline aggregator created for {symbol} with threshold {volume_threshold} and type {volume_type}"
279
+ )
280
+
281
+ def unsubscribe_volume_kline_aggregator(
282
+ self, symbol: str, volume_threshold: float, volume_type: str
283
+ ):
284
+ """Unsubscribe from volume-based kline data using VolumeKlineAggregator
285
+
286
+ Args:
287
+ symbol: Symbol to unsubscribe from
288
+ volume_threshold: Volume threshold for the kline aggregator
289
+ """
290
+ aggregators = self._aggregators.get(symbol, [])
291
+ # Create a copy to avoid "list changed size during iteration" error
292
+ for aggregator in aggregators.copy():
293
+ if (
294
+ isinstance(aggregator, VolumeKlineAggregator)
295
+ and aggregator.volume_threshold == volume_threshold
296
+ ):
297
+ aggregators.remove(aggregator)
298
+ self._log.info(
299
+ f"Volume kline aggregator stopped for {symbol} with threshold {volume_threshold} and type {volume_type}"
300
+ )
301
+ if not aggregators:
302
+ self._aggregators.pop(symbol, None)
303
+ self.unsubscribe_trade(symbol)
304
+
305
+ @abstractmethod
306
+ def subscribe_bookl2(self, symbol: str | List[str], level: BookLevel):
307
+ """Subscribe to the bookl2 data"""
308
+ pass
309
+
310
+ @abstractmethod
311
+ def unsubscribe_bookl2(self, symbol: str | List[str], level: BookLevel):
312
+ """Unsubscribe from the bookl2 data"""
313
+ pass
314
+
315
+ @abstractmethod
316
+ def subscribe_funding_rate(self, symbol: str | List[str]):
317
+ """Subscribe to the funding rate data"""
318
+ pass
319
+
320
+ @abstractmethod
321
+ def unsubscribe_funding_rate(self, symbol: str | List[str]):
322
+ """Unsubscribe from the funding rate data"""
323
+ pass
324
+
325
+ @abstractmethod
326
+ def subscribe_index_price(self, symbol: str | List[str]):
327
+ """Subscribe to the index price data"""
328
+ pass
329
+
330
+ @abstractmethod
331
+ def unsubscribe_index_price(self, symbol: str | List[str]):
332
+ """Unsubscribe from the index price data"""
333
+ pass
334
+
335
+ @abstractmethod
336
+ def subscribe_mark_price(self, symbol: str | List[str]):
337
+ """Subscribe to the mark price data"""
338
+ pass
339
+
340
+ @abstractmethod
341
+ def unsubscribe_mark_price(self, symbol: str | List[str]):
342
+ """Unsubscribe from the mark price data"""
343
+ pass
344
+
345
+ def _create_time_kline_aggregator(
346
+ self,
347
+ symbol: str,
348
+ interval: KlineInterval,
349
+ build_with_no_updates: bool,
350
+ ) -> TimeKlineAggregator:
351
+ """Create a time-based kline aggregator."""
352
+ return TimeKlineAggregator(
353
+ exchange=self._exchange_id,
354
+ symbol=symbol,
355
+ interval=interval,
356
+ msgbus=self._msgbus,
357
+ clock=self._clock,
358
+ build_with_no_updates=build_with_no_updates,
359
+ )
360
+
361
+ def _create_volume_kline_aggregator(
362
+ self,
363
+ symbol: str,
364
+ volume_threshold: float,
365
+ volume_type: Literal["BUY", "SELL", "DEFAULT"],
366
+ ) -> VolumeKlineAggregator:
367
+ """Create a volume-based kline aggregator."""
368
+
369
+ return VolumeKlineAggregator(
370
+ exchange=self._exchange_id,
371
+ symbol=symbol,
372
+ msgbus=self._msgbus,
373
+ volume_threshold=volume_threshold,
374
+ volume_type=volume_type,
375
+ )
376
+
377
+ def _add_aggregator(self, symbol: str, aggregator) -> None:
378
+ """Add an aggregator for a symbol."""
379
+ if symbol not in self._aggregators:
380
+ self._aggregators[symbol] = []
381
+ self._aggregators[symbol].append(aggregator)
382
+
383
+ def _handle_trade_for_aggregators(self, trade: Trade) -> None:
384
+ """Route trade data to all aggregators for this symbol."""
385
+ aggregators = self._aggregators.get(trade.symbol)
386
+ if aggregators:
387
+ for aggregator in aggregators:
388
+ aggregator.handle_trade(trade)
389
+
390
+ async def connect(self):
391
+ """Connect to the exchange"""
392
+ await self._ws_client.connect()
393
+
394
+ async def wait_ready(self):
395
+ """Wait for the initial WebSocket connection to be established"""
396
+ await self._ws_client.wait_ready()
397
+
398
+ async def disconnect(self):
399
+ """Disconnect from the exchange"""
400
+ # Stop all aggregators
401
+ for aggregators in self._aggregators.values():
402
+ for aggregator in aggregators:
403
+ if hasattr(aggregator, "stop"):
404
+ aggregator.stop() # type: ignore
405
+ self._aggregators.clear()
406
+
407
+ # NOTE: no need to manually disconnect ws_client here
408
+ # self._ws_client.disconnect() # not needed to await
409
+ await self._api_client.close_session()
410
+
411
+
412
+ class PrivateConnector(ABC):
413
+ def __init__(
414
+ self,
415
+ account_type: AccountType,
416
+ market: Mapping[str, BaseMarket],
417
+ api_client: ApiClient,
418
+ task_manager: TaskManager,
419
+ oms: OrderManagementSystem,
420
+ ):
421
+ self._log = logging.getLogger(name=type(self).__name__)
422
+ self._account_type = account_type
423
+ self._market = market
424
+ self._api_client = api_client
425
+ self._oms = oms
426
+ self._task_manager = task_manager
427
+ self._api_proxy = ApiProxy(self._api_client, self._task_manager)
428
+ self._api_fire_proxy = FireApiProxy(self._api_client, self._task_manager)
429
+
430
+ @property
431
+ def account_type(self):
432
+ return self._account_type
433
+
434
+ @property
435
+ def api(self):
436
+ return self._api_proxy
437
+
438
+ @property
439
+ def api_fire(self):
440
+ return self._api_fire_proxy
441
+
442
+ def _price_to_precision(
443
+ self,
444
+ symbol: str,
445
+ price: float,
446
+ mode: Literal["round", "ceil", "floor"] = "round",
447
+ ) -> Decimal:
448
+ """
449
+ Convert the price to the precision of the market
450
+ """
451
+ market = self._market[symbol]
452
+ price_decimal: Decimal = Decimal(str(price))
453
+
454
+ decimal = market.precision.price
455
+
456
+ if decimal >= 1:
457
+ exp = Decimal(int(decimal))
458
+ precision_decimal = Decimal("1")
459
+ else:
460
+ exp = Decimal("1")
461
+ precision_decimal = Decimal(str(decimal))
462
+
463
+ if mode == "round":
464
+ format_price = (price_decimal / exp).quantize(
465
+ precision_decimal, rounding=ROUND_HALF_UP
466
+ ) * exp
467
+ elif mode == "ceil":
468
+ format_price = (price_decimal / exp).quantize(
469
+ precision_decimal, rounding=ROUND_CEILING
470
+ ) * exp
471
+ elif mode == "floor":
472
+ format_price = (price_decimal / exp).quantize(
473
+ precision_decimal, rounding=ROUND_FLOOR
474
+ ) * exp
475
+ return format_price
476
+
477
+ @abstractmethod
478
+ async def connect(self):
479
+ """Connect to the exchange"""
480
+ pass
481
+
482
+ async def wait_ready(self):
483
+ """Wait for the OMS WebSocket client(s) to be ready"""
484
+ await self._oms.wait_ready()
485
+
486
+ async def disconnect(self):
487
+ """Disconnect from the exchange"""
488
+ # self._oms._ws_client.disconnect() # not needed to await
489
+ await self._api_client.close_session()
490
+
491
+
492
+ # STALE: MockLinearConnector is no longer integrated into the engine.
493
+ # The _pnl persistence path (_sync_pnl / _pnl table) was never completed and
494
+ # has been removed from the backends. This class is kept for reference only
495
+ # and will be deleted in a future cleanup.
496
+ class MockLinearConnector:
497
+ """
498
+ open long -> cache.update_position
499
+ open short -> cache.update_position
500
+
501
+ close long -> cache.update_position -> cache.update_balance -> realized_pnl
502
+ close short -> cache.update_position -> cache.update_balance -> realized_pnl
503
+ """
504
+
505
+ def __init__(
506
+ self,
507
+ initial_balance: Dict[str, float],
508
+ account_type: AccountType, # LINEAR_MOCK
509
+ exchange: ExchangeManager,
510
+ msgbus: MessageBus,
511
+ clock: LiveClock,
512
+ cache: AsyncCache,
513
+ task_manager: TaskManager,
514
+ overwrite_balance: bool = False,
515
+ overwrite_position: bool = False,
516
+ fee_rate: float = 0.0005,
517
+ quote_currency: str = "USDT",
518
+ update_interval: int = 60, # seconds
519
+ leverage: int = 1,
520
+ ):
521
+ self._account_type = account_type
522
+ self._market = exchange.market
523
+ self._market_id = exchange.market_id
524
+ self._exchange_id = exchange.exchange_id
525
+ self._cache = cache
526
+ self._msgbus = msgbus
527
+ self._fee_rate = fee_rate
528
+ self._initial_balance = initial_balance
529
+ self._overwrite_balance = overwrite_balance
530
+ self._overwrite_position = overwrite_position
531
+ self._quote_currency = quote_currency
532
+ self._update_interval = update_interval
533
+ self._clock = clock
534
+ self._task_manager = task_manager
535
+ self._leverage = leverage
536
+ self._log = logging.getLogger(name=type(self).__name__)
537
+
538
+ async def _init_position(self):
539
+ for _, position in self._cache._get_all_positions_from_db(
540
+ self._exchange_id
541
+ ).items():
542
+ if not self._overwrite_position:
543
+ self._cache._apply_position(position)
544
+ await self._cache.sync_positions()
545
+
546
+ async def _init_balance(self):
547
+ balances = []
548
+ if not self._overwrite_balance:
549
+ balances = self._cache._get_all_balances_from_db(self._account_type)
550
+
551
+ if not balances:
552
+ balances = [
553
+ Balance(asset=asset, free=Decimal(str(amount)), locked=Decimal(0))
554
+ for asset, amount in self._initial_balance.items()
555
+ ]
556
+
557
+ self._cache._apply_balance(self._account_type, balances)
558
+ await self._cache.sync_balances()
559
+
560
+ async def cancel_order(self, symbol: str, order_id: str, **kwargs) -> None:
561
+ """Cancel an order"""
562
+ pass
563
+
564
+ async def cancel_all_orders(self, symbol: str) -> None:
565
+ """Cancel all orders"""
566
+ pass
567
+
568
+ async def create_order(
569
+ self,
570
+ symbol: str,
571
+ side: OrderSide,
572
+ type: OrderType,
573
+ amount: Decimal,
574
+ price: Decimal | None = None,
575
+ time_in_force: TimeInForce = TimeInForce.GTC,
576
+ **kwargs,
577
+ ) -> Order:
578
+ try:
579
+ if amount <= 0:
580
+ raise OrderError(f"Invalid order amount {amount}")
581
+
582
+ if price is not None and price <= 0:
583
+ raise OrderError(f"Invalid order price {price}")
584
+
585
+ market = self._market.get(symbol)
586
+ if not market:
587
+ raise OrderError(f"Symbol {symbol} not found")
588
+
589
+ if not market.linear:
590
+ raise OrderError(f"Symbol {symbol} is not a linear contract")
591
+
592
+ if market.quote not in self._cache.get_balance(self._account_type).balances:
593
+ raise OrderError(
594
+ f"Symbol {symbol}: Not enough balance for {market.quote}."
595
+ )
596
+
597
+ book = self._cache.bookl1(symbol)
598
+ if not book:
599
+ raise OrderError(
600
+ f"Please subscribe to the bookl1 data for {symbol} or data not ready"
601
+ )
602
+
603
+ quote_balance = float(
604
+ self._cache.get_balance(self._account_type).balance_total[
605
+ self._quote_currency
606
+ ]
607
+ )
608
+
609
+ position = self._cache.get_position(symbol).value_or(None)
610
+ if position:
611
+ # If position exists, check direction
612
+ if (
613
+ side.is_buy and position.side is not None and position.side.is_long
614
+ ) or (
615
+ side.is_sell
616
+ and position.side is not None
617
+ and position.side.is_short
618
+ ):
619
+ # Same direction, add
620
+ total_notional = self.total_notional + float(amount) * book.mid
621
+ else:
622
+ # Opposite direction, subtract
623
+ total_notional = self.total_notional - float(amount) * book.mid
624
+ else:
625
+ # No existing position, just add
626
+ total_notional = self.total_notional + float(amount) * book.mid
627
+
628
+ if abs(total_notional) / quote_balance > self._leverage:
629
+ raise OrderError(
630
+ f"Symbol {symbol}: Not enough margin for leverage: {self._leverage}"
631
+ )
632
+
633
+ if side == OrderSide.BUY: # NOTE: taker order
634
+ exec_price: float = book.ask
635
+ else:
636
+ exec_price = book.bid
637
+
638
+ fee = amount * Decimal(str(exec_price)) * Decimal(str(self._fee_rate))
639
+ fee_currency = market.quote
640
+
641
+ reduce_only = kwargs.get("reduce_only", False)
642
+
643
+ cost = amount * Decimal(str(exec_price))
644
+
645
+ order = Order(
646
+ exchange=self._exchange_id,
647
+ symbol=symbol,
648
+ status=OrderStatus.PENDING,
649
+ oid=UUID4().value,
650
+ amount=amount,
651
+ filled=Decimal(0),
652
+ timestamp=self._clock.timestamp_ms(),
653
+ type=type,
654
+ side=side,
655
+ time_in_force=time_in_force,
656
+ price=exec_price,
657
+ average=exec_price,
658
+ remaining=amount,
659
+ reduce_only=reduce_only,
660
+ fee=fee,
661
+ fee_currency=fee_currency,
662
+ cost=cost,
663
+ cum_cost=cost,
664
+ )
665
+
666
+ order_filled = Order(
667
+ exchange=self._exchange_id,
668
+ symbol=symbol,
669
+ status=OrderStatus.FILLED,
670
+ oid=order.oid,
671
+ amount=amount,
672
+ filled=amount,
673
+ timestamp=self._clock.timestamp_ms(),
674
+ type=type,
675
+ side=side,
676
+ time_in_force=time_in_force,
677
+ price=exec_price,
678
+ average=exec_price,
679
+ remaining=Decimal(0),
680
+ reduce_only=reduce_only,
681
+ fee=fee,
682
+ fee_currency=fee_currency,
683
+ cost=cost,
684
+ cum_cost=cost,
685
+ )
686
+
687
+ self._apply_position(order)
688
+ self._msgbus.send(
689
+ endpoint=f"{self._exchange_id.value}.order", msg=order_filled
690
+ )
691
+ return order
692
+ except OrderError as e:
693
+ self._log.error(f"Error creating order: {e}")
694
+ return Order(
695
+ exchange=self._exchange_id,
696
+ timestamp=self._clock.timestamp_ms(),
697
+ symbol=symbol,
698
+ status=OrderStatus.FAILED,
699
+ type=type,
700
+ side=side,
701
+ amount=amount,
702
+ price=float(price) if price else None,
703
+ time_in_force=time_in_force,
704
+ filled=Decimal(0),
705
+ remaining=amount,
706
+ )
707
+
708
+ @property
709
+ def pnl(self) -> float:
710
+ balances = self._cache.get_balance(self._account_type).balance_total
711
+ return float(str(balances[self._quote_currency]))
712
+
713
+ @property
714
+ def unrealized_pnl(self) -> float:
715
+ pnl = 0
716
+ for _, position in self._cache.get_all_positions(self._exchange_id).items():
717
+ pnl += position.unrealized_pnl
718
+ return pnl
719
+
720
+ @property
721
+ def total_notional(self) -> float:
722
+ notional = 0
723
+ for symbol, position in self._cache.get_all_positions(
724
+ self._exchange_id
725
+ ).items():
726
+ book = self._cache.bookl1(symbol)
727
+ if not book:
728
+ self._log.warning(
729
+ f"Please subscribe to the `bookl1` data for {symbol} or data not ready"
730
+ )
731
+ continue
732
+ notional += float(position.amount) * book.mid
733
+ return notional
734
+
735
+ def _update_unrealized_pnl(self):
736
+ for symbol, position in self._cache.get_all_positions(
737
+ self._exchange_id
738
+ ).items():
739
+ book = self._cache.bookl1(symbol)
740
+ if not book:
741
+ self._log.warning(
742
+ f"Please subscribe to the `bookl1` data for {symbol} or data not ready"
743
+ )
744
+ return
745
+
746
+ if position.is_long:
747
+ unrealized_pnl = float(position.amount) * (
748
+ book.mid - position.entry_price
749
+ )
750
+ else:
751
+ unrealized_pnl = float(position.amount) * (
752
+ position.entry_price - book.mid
753
+ )
754
+ position.unrealized_pnl = unrealized_pnl
755
+
756
+ def _apply_fee(self, order: Order):
757
+ """
758
+ apply fee to the balance
759
+ """
760
+ if order.fee_currency is None or order.fee is None:
761
+ return
762
+ self._cache._mem_account_balance[self._account_type]._update_free(
763
+ order.fee_currency, -order.fee
764
+ )
765
+
766
+ def _apply_position(self, order: Order):
767
+ """Update position for perpetual contract"""
768
+ order_amount = order.amount
769
+ order_price = order.price
770
+ if order_amount is None or order_price is None:
771
+ return
772
+ symbol = order.symbol
773
+ market = self._market.get(symbol)
774
+ if not market:
775
+ raise ValueError(f"Symbol {symbol} not found in market")
776
+
777
+ position = self._cache.get_position(symbol).value_or(None)
778
+
779
+ # Handle new position creation
780
+ if not position or position.is_closed:
781
+ if order.is_buy:
782
+ signed_amount: Decimal = order_amount
783
+ side = PositionSide.LONG
784
+ else:
785
+ signed_amount = -order_amount
786
+ side = PositionSide.SHORT
787
+
788
+ position = Position(
789
+ symbol=symbol,
790
+ exchange=self._exchange_id,
791
+ side=side,
792
+ signed_amount=signed_amount,
793
+ entry_price=order_price,
794
+ unrealized_pnl=0,
795
+ realized_pnl=0,
796
+ )
797
+ else:
798
+ pos_amount = position.amount
799
+ pos_entry = position.entry_price
800
+ if pos_amount is None or pos_entry is None:
801
+ return
802
+ # Calculate new position amount
803
+ is_same_direction = (
804
+ order.is_buy and position.side is not None and position.side.is_long
805
+ ) or (
806
+ order.is_sell and position.side is not None and position.side.is_short
807
+ )
808
+ new_amount = pos_amount + (
809
+ order_amount if is_same_direction else -order_amount
810
+ ) # -10 / 10 - 15 = -5
811
+
812
+ # Calculate realized PnL if closing or reducing position
813
+ if not is_same_direction:
814
+ price_diff = (
815
+ order_price - pos_entry
816
+ if position.is_long
817
+ else pos_entry - order_price
818
+ )
819
+ closed_amount = min(pos_amount, order_amount)
820
+ realized_pnl = float(closed_amount) * price_diff
821
+
822
+ position.realized_pnl += realized_pnl
823
+ self._cache._mem_account_balance[self._account_type]._update_free(
824
+ market.quote, Decimal(str(realized_pnl))
825
+ )
826
+
827
+ # Update position details
828
+ if new_amount > Decimal("0"):
829
+ # Position maintains direction but with updated amount
830
+ if is_same_direction: # NOTE: add to position
831
+ # Average entry price when adding to position
832
+ position.entry_price = (
833
+ float(order_amount) * order_price
834
+ + float(pos_amount) * pos_entry
835
+ ) / float(new_amount)
836
+ position.signed_amount = new_amount if position.is_long else -new_amount
837
+ elif new_amount < Decimal("0"):
838
+ # Position flips direction
839
+ position.side = (
840
+ PositionSide.SHORT if position.is_long else PositionSide.LONG
841
+ )
842
+ position.signed_amount = -new_amount if position.is_long else new_amount
843
+ position.entry_price = order_price
844
+ position.unrealized_pnl = 0
845
+ else:
846
+ # Position closed completely
847
+ position.side = None
848
+ position.signed_amount = Decimal("0")
849
+
850
+ self._cache._apply_position(position)
851
+ self._apply_fee(order)
852
+
853
+ async def connect(self):
854
+ self._log.debug(f"Starting mock connector for {self._account_type}")
855
+ await self._init_position()
856
+ await self._init_balance()
857
+
858
+ async def wait_ready(self):
859
+ """Mock connector is ready immediately after connect"""
860
+ pass
861
+
862
+ async def disconnect(self):
863
+ pass