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,213 @@
|
|
|
1
|
+
import warnings
|
|
2
|
+
import ccxt
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Dict, Any, List
|
|
5
|
+
import nexuslog as logging
|
|
6
|
+
|
|
7
|
+
from walrasquant.schema import BaseMarket, InstrumentId
|
|
8
|
+
from walrasquant.constants import ExchangeType, AccountType, ConfigType
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ExchangeManager(ABC):
|
|
12
|
+
def __init__(self, config: ConfigType | None = None):
|
|
13
|
+
if config is None:
|
|
14
|
+
raise ValueError("config is required")
|
|
15
|
+
|
|
16
|
+
self.config: ConfigType = config
|
|
17
|
+
self.api_key = config.get("apiKey")
|
|
18
|
+
self.secret = config.get("secret")
|
|
19
|
+
self.exchange_id = ExchangeType(config["exchange_id"])
|
|
20
|
+
self.api = self._init_exchange()
|
|
21
|
+
self._log = logging.getLogger(name=type(self).__name__)
|
|
22
|
+
self.is_testnet = config.get("sandbox", False)
|
|
23
|
+
self.market: Dict[str, BaseMarket] = {}
|
|
24
|
+
self.market_id: Dict[str, str] = {}
|
|
25
|
+
|
|
26
|
+
if not self.api_key or not self.secret:
|
|
27
|
+
warnings.warn(
|
|
28
|
+
"API Key and Secret not provided, So some features related to trading will not work"
|
|
29
|
+
)
|
|
30
|
+
self.load_markets()
|
|
31
|
+
|
|
32
|
+
def _init_exchange(self) -> ccxt.Exchange:
|
|
33
|
+
"""
|
|
34
|
+
Initialize the exchange
|
|
35
|
+
"""
|
|
36
|
+
try:
|
|
37
|
+
exchange_class = getattr(ccxt, self.config["exchange_id"])
|
|
38
|
+
except AttributeError:
|
|
39
|
+
raise AttributeError(
|
|
40
|
+
f"Exchange {self.config['exchange_id']} is not supported"
|
|
41
|
+
) from None
|
|
42
|
+
|
|
43
|
+
api = exchange_class(self.config)
|
|
44
|
+
api.set_sandbox_mode(
|
|
45
|
+
self.config.get("sandbox", False)
|
|
46
|
+
) # Set sandbox mode if demo trade is enabled
|
|
47
|
+
return api
|
|
48
|
+
|
|
49
|
+
def _parse_symbol(self, mkt: BaseMarket, exchange_suffix: str) -> str:
|
|
50
|
+
"""
|
|
51
|
+
Parse the symbol for the exchange
|
|
52
|
+
"""
|
|
53
|
+
if mkt.spot:
|
|
54
|
+
return f"{mkt.base}{mkt.quote}.{exchange_suffix}"
|
|
55
|
+
elif mkt.option:
|
|
56
|
+
symbol = mkt.symbol
|
|
57
|
+
parts = symbol.split("-")
|
|
58
|
+
expiry = parts[1]
|
|
59
|
+
strike = parts[2]
|
|
60
|
+
option_type = parts[3]
|
|
61
|
+
return f"{mkt.base}{mkt.quote}-{expiry}-{strike}-{option_type}.{exchange_suffix}"
|
|
62
|
+
elif mkt.future:
|
|
63
|
+
symbol = mkt.symbol
|
|
64
|
+
expiry_suffix = symbol.split("-")[-1]
|
|
65
|
+
return f"{mkt.base}{mkt.quote}-{expiry_suffix}.{exchange_suffix}"
|
|
66
|
+
elif mkt.linear:
|
|
67
|
+
return f"{mkt.base}{mkt.quote}-PERP.{exchange_suffix}"
|
|
68
|
+
elif mkt.inverse:
|
|
69
|
+
return f"{mkt.base}{mkt.quote}-PERP.{exchange_suffix}"
|
|
70
|
+
raise ValueError(f"Unsupported market type for symbol {mkt.symbol}")
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def load_markets(self, reload: bool = False) -> None:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
def linear(
|
|
77
|
+
self,
|
|
78
|
+
base: str | None = None,
|
|
79
|
+
quote: str | None = None,
|
|
80
|
+
exclude: List[str] | None = None,
|
|
81
|
+
) -> List[str]:
|
|
82
|
+
symbols = []
|
|
83
|
+
for symbol, market in self.market.items():
|
|
84
|
+
if not (
|
|
85
|
+
market.linear
|
|
86
|
+
and market.active
|
|
87
|
+
and not market.future
|
|
88
|
+
and not market.option
|
|
89
|
+
):
|
|
90
|
+
continue
|
|
91
|
+
|
|
92
|
+
base_match = base is None or market.base == base
|
|
93
|
+
quote_match = quote is None or market.quote == quote
|
|
94
|
+
|
|
95
|
+
if (
|
|
96
|
+
base_match
|
|
97
|
+
and quote_match
|
|
98
|
+
and (exclude is None or symbol not in exclude)
|
|
99
|
+
):
|
|
100
|
+
symbols.append(symbol)
|
|
101
|
+
return symbols
|
|
102
|
+
|
|
103
|
+
def inverse(
|
|
104
|
+
self,
|
|
105
|
+
base: str | None = None,
|
|
106
|
+
quote: str | None = None,
|
|
107
|
+
exclude: List[str] | None = None,
|
|
108
|
+
) -> List[str]:
|
|
109
|
+
symbols = []
|
|
110
|
+
for symbol, market in self.market.items():
|
|
111
|
+
if not (
|
|
112
|
+
market.inverse
|
|
113
|
+
and market.active
|
|
114
|
+
and not market.future
|
|
115
|
+
and not market.option
|
|
116
|
+
):
|
|
117
|
+
continue
|
|
118
|
+
|
|
119
|
+
base_match = base is None or market.base == base
|
|
120
|
+
quote_match = quote is None or market.quote == quote
|
|
121
|
+
|
|
122
|
+
if (
|
|
123
|
+
base_match
|
|
124
|
+
and quote_match
|
|
125
|
+
and (exclude is None or symbol not in exclude)
|
|
126
|
+
):
|
|
127
|
+
symbols.append(symbol)
|
|
128
|
+
return symbols
|
|
129
|
+
|
|
130
|
+
def spot(
|
|
131
|
+
self,
|
|
132
|
+
base: str | None = None,
|
|
133
|
+
quote: str | None = None,
|
|
134
|
+
exclude: List[str] | None = None,
|
|
135
|
+
) -> List[str]:
|
|
136
|
+
symbols = []
|
|
137
|
+
for symbol, market in self.market.items():
|
|
138
|
+
if not (market.spot and market.active and not market.option):
|
|
139
|
+
continue
|
|
140
|
+
|
|
141
|
+
base_match = base is None or market.base == base
|
|
142
|
+
quote_match = quote is None or market.quote == quote
|
|
143
|
+
|
|
144
|
+
if (
|
|
145
|
+
base_match
|
|
146
|
+
and quote_match
|
|
147
|
+
and (exclude is None or symbol not in exclude)
|
|
148
|
+
):
|
|
149
|
+
symbols.append(symbol)
|
|
150
|
+
return symbols
|
|
151
|
+
|
|
152
|
+
def future(
|
|
153
|
+
self,
|
|
154
|
+
base: str | None = None,
|
|
155
|
+
quote: str | None = None,
|
|
156
|
+
exclude: List[str] | None = None,
|
|
157
|
+
) -> List[str]:
|
|
158
|
+
symbols = []
|
|
159
|
+
for symbol, market in self.market.items():
|
|
160
|
+
if not (market.future and market.active and not market.option):
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
base_match = base is None or market.base == base
|
|
164
|
+
quote_match = quote is None or market.quote == quote
|
|
165
|
+
|
|
166
|
+
if (
|
|
167
|
+
base_match
|
|
168
|
+
and quote_match
|
|
169
|
+
and (exclude is None or symbol not in exclude)
|
|
170
|
+
):
|
|
171
|
+
symbols.append(symbol)
|
|
172
|
+
return symbols
|
|
173
|
+
|
|
174
|
+
def option(
|
|
175
|
+
self,
|
|
176
|
+
base: str | None = None,
|
|
177
|
+
quote: str | None = None,
|
|
178
|
+
exclude: List[str] | None = None,
|
|
179
|
+
) -> List[str]:
|
|
180
|
+
symbols = []
|
|
181
|
+
for symbol, market in self.market.items():
|
|
182
|
+
if not market.active:
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
base_match = base is None or market.base == base
|
|
186
|
+
quote_match = quote is None or market.quote == quote
|
|
187
|
+
|
|
188
|
+
if (
|
|
189
|
+
base_match
|
|
190
|
+
and quote_match
|
|
191
|
+
and (exclude is None or symbol not in exclude)
|
|
192
|
+
):
|
|
193
|
+
symbols.append(symbol)
|
|
194
|
+
return symbols
|
|
195
|
+
|
|
196
|
+
@abstractmethod
|
|
197
|
+
def validate_public_connector_config(
|
|
198
|
+
self, account_type: AccountType, basic_config: Any
|
|
199
|
+
) -> None:
|
|
200
|
+
"""Validate public connector configuration for this exchange"""
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
@abstractmethod
|
|
204
|
+
def validate_public_connector_limits(
|
|
205
|
+
self, existing_connectors: Dict[AccountType, Any]
|
|
206
|
+
) -> None:
|
|
207
|
+
"""Validate public connector limits for this exchange"""
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
@abstractmethod
|
|
211
|
+
def instrument_id_to_account_type(self, instrument_id: InstrumentId) -> AccountType:
|
|
212
|
+
"""Convert an instrument ID to the appropriate account type for this exchange"""
|
|
213
|
+
pass
|
walrasquant/base/oms.py
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
import asyncio
|
|
5
|
+
from typing import Dict, List, Literal, Optional, TYPE_CHECKING
|
|
6
|
+
from decimal import Decimal
|
|
7
|
+
import nexuslog as logging
|
|
8
|
+
|
|
9
|
+
from decimal import ROUND_HALF_UP, ROUND_CEILING, ROUND_FLOOR
|
|
10
|
+
from walrasquant.constants import AccountType, ExchangeType
|
|
11
|
+
from walrasquant.core.cache import AsyncCache
|
|
12
|
+
from walrasquant.core.nautilius_core import LiveClock, MessageBus
|
|
13
|
+
from walrasquant.core.registry import OrderRegistry
|
|
14
|
+
from walrasquant.core.entity import TaskManager
|
|
15
|
+
from walrasquant.base.api_client import ApiClient
|
|
16
|
+
from walrasquant.base.ws_client import WSClient
|
|
17
|
+
from walrasquant.schema import (
|
|
18
|
+
Order,
|
|
19
|
+
BaseMarket,
|
|
20
|
+
BatchOrderSubmit,
|
|
21
|
+
CancelOrderSubmit,
|
|
22
|
+
)
|
|
23
|
+
from walrasquant.constants import (
|
|
24
|
+
OrderSide,
|
|
25
|
+
OrderType,
|
|
26
|
+
TimeInForce,
|
|
27
|
+
TriggerType,
|
|
28
|
+
OrderStatus,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
if TYPE_CHECKING:
|
|
32
|
+
from walrasquant.config import OrderQueryConfig
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class OrderManagementSystem(ABC):
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
account_type: AccountType,
|
|
39
|
+
market: Dict[str, BaseMarket],
|
|
40
|
+
market_id: Dict[str, str],
|
|
41
|
+
registry: OrderRegistry,
|
|
42
|
+
cache: AsyncCache,
|
|
43
|
+
api_client: ApiClient,
|
|
44
|
+
ws_client: WSClient,
|
|
45
|
+
exchange_id: ExchangeType,
|
|
46
|
+
clock: LiveClock,
|
|
47
|
+
msgbus: MessageBus,
|
|
48
|
+
task_manager: TaskManager,
|
|
49
|
+
order_query_config: OrderQueryConfig,
|
|
50
|
+
):
|
|
51
|
+
self._log = logging.getLogger(name=type(self).__name__)
|
|
52
|
+
self._market = market
|
|
53
|
+
self._market_id = market_id
|
|
54
|
+
self._registry = registry
|
|
55
|
+
self._account_type = account_type
|
|
56
|
+
self._cache = cache
|
|
57
|
+
self._api_client = api_client
|
|
58
|
+
self._ws_client = ws_client
|
|
59
|
+
self._exchange_id = exchange_id
|
|
60
|
+
self._clock = clock
|
|
61
|
+
self._msgbus = msgbus
|
|
62
|
+
self._task_manager = task_manager
|
|
63
|
+
self._order_query_config = order_query_config
|
|
64
|
+
self._querying_oids: set[str] = set()
|
|
65
|
+
self._last_query_ms_by_oid: dict[str, int] = {}
|
|
66
|
+
self._order_query_started = False
|
|
67
|
+
|
|
68
|
+
async def _async_init(self):
|
|
69
|
+
"""Two-phase initialization: call after __init__ in async context."""
|
|
70
|
+
await self._init_account_balance()
|
|
71
|
+
await self._init_position()
|
|
72
|
+
await self._position_mode_check()
|
|
73
|
+
|
|
74
|
+
def order_status_update(self, order: Order):
|
|
75
|
+
if order.oid is None:
|
|
76
|
+
return
|
|
77
|
+
|
|
78
|
+
if not self._registry.is_registered(order.oid):
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
valid = self._cache._order_status_update(order) # INITIALIZED -> PENDING
|
|
82
|
+
match order.status:
|
|
83
|
+
case OrderStatus.PENDING:
|
|
84
|
+
self._log.debug(f"ORDER STATUS PENDING: {str(order)}")
|
|
85
|
+
self._msgbus.send(endpoint="pending", msg=order)
|
|
86
|
+
case OrderStatus.FAILED:
|
|
87
|
+
self._log.debug(f"ORDER STATUS FAILED: {str(order)}")
|
|
88
|
+
self._msgbus.send(endpoint="failed", msg=order)
|
|
89
|
+
case OrderStatus.ACCEPTED:
|
|
90
|
+
self._log.debug(f"ORDER STATUS ACCEPTED: {str(order)}")
|
|
91
|
+
self._msgbus.send(endpoint="accepted", msg=order)
|
|
92
|
+
case OrderStatus.PARTIALLY_FILLED:
|
|
93
|
+
self._log.debug(f"ORDER STATUS PARTIALLY FILLED: {str(order)}")
|
|
94
|
+
self._msgbus.send(endpoint="partially_filled", msg=order)
|
|
95
|
+
case OrderStatus.CANCELED:
|
|
96
|
+
self._log.debug(f"ORDER STATUS CANCELED: {str(order)}")
|
|
97
|
+
self._msgbus.send(endpoint="canceled", msg=order)
|
|
98
|
+
case OrderStatus.CANCELING:
|
|
99
|
+
self._log.debug(f"ORDER STATUS CANCELING: {str(order)}")
|
|
100
|
+
self._msgbus.send(endpoint="canceling", msg=order)
|
|
101
|
+
case OrderStatus.CANCEL_FAILED:
|
|
102
|
+
self._log.debug(f"ORDER STATUS CANCEL FAILED: {str(order)}")
|
|
103
|
+
self._msgbus.send(endpoint="cancel_failed", msg=order)
|
|
104
|
+
case OrderStatus.FILLED:
|
|
105
|
+
self._log.debug(f"ORDER STATUS FILLED: {str(order)}")
|
|
106
|
+
self._msgbus.send(endpoint="filled", msg=order)
|
|
107
|
+
case OrderStatus.EXPIRED:
|
|
108
|
+
self._log.debug(f"ORDER STATUS EXPIRED: {str(order)}")
|
|
109
|
+
self._msgbus.send(endpoint="expired", msg=order)
|
|
110
|
+
|
|
111
|
+
if valid and order.status == OrderStatus.CANCEL_FAILED:
|
|
112
|
+
self._schedule_query_order(order, reason="cancel_failed")
|
|
113
|
+
|
|
114
|
+
if valid and order.is_closed:
|
|
115
|
+
self._last_query_ms_by_oid.pop(order.oid, None)
|
|
116
|
+
self._registry.unregister_order(order.oid)
|
|
117
|
+
self._registry.unregister_tmp_order(order.oid)
|
|
118
|
+
|
|
119
|
+
def start_order_query(self) -> None:
|
|
120
|
+
if not self._order_query_enabled or self._order_query_started:
|
|
121
|
+
return
|
|
122
|
+
if self._order_query_config.check_interval_seconds == -1:
|
|
123
|
+
return
|
|
124
|
+
self._order_query_started = True
|
|
125
|
+
self._task_manager.create_task(
|
|
126
|
+
self._order_query_scan_loop(),
|
|
127
|
+
name=f"order_query:{self._exchange_id.value}:{self._account_type.value}",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def _order_query_enabled(self) -> bool:
|
|
132
|
+
return self._order_query_config.enabled
|
|
133
|
+
|
|
134
|
+
async def _order_query_scan_loop(self) -> None:
|
|
135
|
+
try:
|
|
136
|
+
while True:
|
|
137
|
+
await asyncio.sleep(self._order_query_config.check_interval_seconds)
|
|
138
|
+
self._scan_timed_out_orders()
|
|
139
|
+
except asyncio.CancelledError:
|
|
140
|
+
raise
|
|
141
|
+
except Exception as exc:
|
|
142
|
+
self._log.error(f"Order query scan loop stopped unexpectedly: {exc}")
|
|
143
|
+
raise
|
|
144
|
+
|
|
145
|
+
def _scan_timed_out_orders(self) -> None:
|
|
146
|
+
now_ms = self._clock.timestamp_ms()
|
|
147
|
+
oids = self._cache.get_open_orders(
|
|
148
|
+
exchange=self._exchange_id,
|
|
149
|
+
include_canceling=True,
|
|
150
|
+
)
|
|
151
|
+
for oid in oids:
|
|
152
|
+
order = self._cache.get_order(oid).value_or(None)
|
|
153
|
+
if order is None:
|
|
154
|
+
continue
|
|
155
|
+
if order.oid is None or order.timestamp is None:
|
|
156
|
+
continue
|
|
157
|
+
if not self._should_query_order(order):
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
timeout_seconds = self._order_query_config.timeout_seconds.get(order.status)
|
|
161
|
+
if timeout_seconds is None:
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
baseline_ms = max(
|
|
165
|
+
int(order.timestamp),
|
|
166
|
+
self._last_query_ms_by_oid.get(order.oid, 0),
|
|
167
|
+
)
|
|
168
|
+
age_seconds = (now_ms - baseline_ms) / 1000
|
|
169
|
+
if age_seconds >= timeout_seconds:
|
|
170
|
+
self._schedule_query_order(order, reason="timeout")
|
|
171
|
+
|
|
172
|
+
def _should_query_order(self, order: Order) -> bool:
|
|
173
|
+
if not self._order_query_enabled:
|
|
174
|
+
return False
|
|
175
|
+
if order.exchange != self._exchange_id:
|
|
176
|
+
return False
|
|
177
|
+
market = self._market.get(order.symbol)
|
|
178
|
+
if market is None:
|
|
179
|
+
return False
|
|
180
|
+
return self._supports_query_market(market)
|
|
181
|
+
|
|
182
|
+
def _supports_query_market(self, market: BaseMarket) -> bool:
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
def _schedule_query_order(self, order: Order, *, reason: str) -> None:
|
|
186
|
+
if not self._should_query_order(order):
|
|
187
|
+
return
|
|
188
|
+
|
|
189
|
+
oid = order.oid
|
|
190
|
+
if oid is None:
|
|
191
|
+
return
|
|
192
|
+
if oid in self._querying_oids:
|
|
193
|
+
return
|
|
194
|
+
|
|
195
|
+
self._querying_oids.add(oid)
|
|
196
|
+
self._last_query_ms_by_oid[oid] = self._clock.timestamp_ms()
|
|
197
|
+
self._task_manager.create_task(
|
|
198
|
+
self._query_order_safe(order=order, reason=reason),
|
|
199
|
+
name=(
|
|
200
|
+
f"query_order:{self._exchange_id.value}:"
|
|
201
|
+
f"{self._account_type.value}:{oid}"
|
|
202
|
+
),
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
async def _query_order_safe(self, *, order: Order, reason: str) -> None:
|
|
206
|
+
oid = order.oid
|
|
207
|
+
if oid is None:
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
self._log.debug(
|
|
212
|
+
f"Querying order status: reason={reason} symbol={order.symbol} oid={oid}"
|
|
213
|
+
)
|
|
214
|
+
queried_order = await self.query_order(oid=oid, symbol=order.symbol)
|
|
215
|
+
if queried_order is None:
|
|
216
|
+
self._log.warning(
|
|
217
|
+
f"Order status query returned no order: reason={reason} "
|
|
218
|
+
f"symbol={order.symbol} oid={oid}"
|
|
219
|
+
)
|
|
220
|
+
return
|
|
221
|
+
if queried_order.oid is None:
|
|
222
|
+
self._log.error(
|
|
223
|
+
f"Order status query returned order without oid: "
|
|
224
|
+
f"symbol={order.symbol} oid={oid}"
|
|
225
|
+
)
|
|
226
|
+
return
|
|
227
|
+
self.order_status_update(queried_order)
|
|
228
|
+
except asyncio.CancelledError:
|
|
229
|
+
raise
|
|
230
|
+
except Exception as exc:
|
|
231
|
+
self._log.error(
|
|
232
|
+
f"Order status query failed: reason={reason} "
|
|
233
|
+
f"symbol={order.symbol} oid={oid}: {exc}"
|
|
234
|
+
)
|
|
235
|
+
finally:
|
|
236
|
+
self._querying_oids.discard(oid)
|
|
237
|
+
|
|
238
|
+
def _rate_limit_failed_order(
|
|
239
|
+
self,
|
|
240
|
+
*,
|
|
241
|
+
oid: str,
|
|
242
|
+
symbol: str,
|
|
243
|
+
side: OrderSide,
|
|
244
|
+
type: OrderType,
|
|
245
|
+
amount: Decimal,
|
|
246
|
+
price: Decimal | None,
|
|
247
|
+
time_in_force: TimeInForce | None,
|
|
248
|
+
reduce_only: bool,
|
|
249
|
+
exc: Exception,
|
|
250
|
+
) -> Order:
|
|
251
|
+
return Order(
|
|
252
|
+
oid=oid,
|
|
253
|
+
exchange=self._exchange_id,
|
|
254
|
+
timestamp=self._clock.timestamp_ms(),
|
|
255
|
+
symbol=symbol,
|
|
256
|
+
type=type,
|
|
257
|
+
side=side,
|
|
258
|
+
amount=amount,
|
|
259
|
+
price=float(price) if price else None,
|
|
260
|
+
time_in_force=time_in_force,
|
|
261
|
+
status=OrderStatus.FAILED,
|
|
262
|
+
filled=Decimal(0),
|
|
263
|
+
remaining=amount,
|
|
264
|
+
reduce_only=reduce_only,
|
|
265
|
+
reason=f"rate_limit (retry_after={getattr(exc, 'retry_after', None)}): {exc}",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
async def wait_ready(self):
|
|
269
|
+
"""Wait for the WebSocket client(s) to be ready.
|
|
270
|
+
|
|
271
|
+
This method waits for the main WebSocket client and optionally
|
|
272
|
+
for the WebSocket API client if it exists.
|
|
273
|
+
"""
|
|
274
|
+
await self._ws_client.wait_ready()
|
|
275
|
+
# Check if subclass has a WebSocket API client
|
|
276
|
+
ws_api_client = getattr(self, "_ws_api_client", None)
|
|
277
|
+
if ws_api_client is not None:
|
|
278
|
+
await ws_api_client.wait_ready()
|
|
279
|
+
|
|
280
|
+
def _price_to_precision(
|
|
281
|
+
self,
|
|
282
|
+
symbol: str,
|
|
283
|
+
price: float,
|
|
284
|
+
mode: Literal["round", "ceil", "floor"] = "round",
|
|
285
|
+
) -> Decimal:
|
|
286
|
+
"""
|
|
287
|
+
Convert the price to the precision of the market
|
|
288
|
+
"""
|
|
289
|
+
market = self._market[symbol]
|
|
290
|
+
price_decimal: Decimal = Decimal(str(price))
|
|
291
|
+
|
|
292
|
+
decimal = market.precision.price
|
|
293
|
+
|
|
294
|
+
if decimal >= 1:
|
|
295
|
+
exp = Decimal(int(decimal))
|
|
296
|
+
precision_decimal = Decimal("1")
|
|
297
|
+
else:
|
|
298
|
+
exp = Decimal("1")
|
|
299
|
+
precision_decimal = Decimal(str(decimal))
|
|
300
|
+
|
|
301
|
+
if mode == "round":
|
|
302
|
+
format_price = (price_decimal / exp).quantize(
|
|
303
|
+
precision_decimal, rounding=ROUND_HALF_UP
|
|
304
|
+
) * exp
|
|
305
|
+
elif mode == "ceil":
|
|
306
|
+
format_price = (price_decimal / exp).quantize(
|
|
307
|
+
precision_decimal, rounding=ROUND_CEILING
|
|
308
|
+
) * exp
|
|
309
|
+
elif mode == "floor":
|
|
310
|
+
format_price = (price_decimal / exp).quantize(
|
|
311
|
+
precision_decimal, rounding=ROUND_FLOOR
|
|
312
|
+
) * exp
|
|
313
|
+
return format_price
|
|
314
|
+
|
|
315
|
+
@abstractmethod
|
|
316
|
+
async def _init_account_balance(self):
|
|
317
|
+
"""Initialize the account balance"""
|
|
318
|
+
pass
|
|
319
|
+
|
|
320
|
+
@abstractmethod
|
|
321
|
+
async def _init_position(self):
|
|
322
|
+
"""Initialize the position"""
|
|
323
|
+
pass
|
|
324
|
+
|
|
325
|
+
@abstractmethod
|
|
326
|
+
async def _position_mode_check(self):
|
|
327
|
+
"""Check the position mode"""
|
|
328
|
+
pass
|
|
329
|
+
|
|
330
|
+
@abstractmethod
|
|
331
|
+
async def create_tp_sl_order(
|
|
332
|
+
self,
|
|
333
|
+
oid: str,
|
|
334
|
+
symbol: str,
|
|
335
|
+
side: OrderSide,
|
|
336
|
+
type: OrderType,
|
|
337
|
+
amount: Decimal,
|
|
338
|
+
price: Decimal | None = None,
|
|
339
|
+
time_in_force: TimeInForce | None = TimeInForce.GTC,
|
|
340
|
+
tp_order_type: OrderType | None = None,
|
|
341
|
+
tp_trigger_price: Decimal | None = None,
|
|
342
|
+
tp_price: Decimal | None = None,
|
|
343
|
+
tp_trigger_type: TriggerType | None = TriggerType.LAST_PRICE,
|
|
344
|
+
sl_order_type: OrderType | None = None,
|
|
345
|
+
sl_trigger_price: Decimal | None = None,
|
|
346
|
+
sl_price: Decimal | None = None,
|
|
347
|
+
sl_trigger_type: TriggerType | None = TriggerType.LAST_PRICE,
|
|
348
|
+
**kwargs,
|
|
349
|
+
) -> Order:
|
|
350
|
+
"""Create a take profit and stop loss order"""
|
|
351
|
+
pass
|
|
352
|
+
|
|
353
|
+
@abstractmethod
|
|
354
|
+
async def create_order(
|
|
355
|
+
self,
|
|
356
|
+
oid: str,
|
|
357
|
+
symbol: str,
|
|
358
|
+
side: OrderSide,
|
|
359
|
+
type: OrderType,
|
|
360
|
+
amount: Decimal,
|
|
361
|
+
price: Optional[Decimal],
|
|
362
|
+
time_in_force: Optional[TimeInForce],
|
|
363
|
+
reduce_only: bool,
|
|
364
|
+
**kwargs,
|
|
365
|
+
) -> Order:
|
|
366
|
+
"""Create an order"""
|
|
367
|
+
pass
|
|
368
|
+
|
|
369
|
+
@abstractmethod
|
|
370
|
+
async def create_order_ws(
|
|
371
|
+
self,
|
|
372
|
+
oid: str,
|
|
373
|
+
symbol: str,
|
|
374
|
+
side: OrderSide,
|
|
375
|
+
type: OrderType,
|
|
376
|
+
amount: Decimal,
|
|
377
|
+
price: Optional[Decimal],
|
|
378
|
+
time_in_force: Optional[TimeInForce],
|
|
379
|
+
reduce_only: bool,
|
|
380
|
+
**kwargs,
|
|
381
|
+
):
|
|
382
|
+
pass
|
|
383
|
+
|
|
384
|
+
@abstractmethod
|
|
385
|
+
async def create_batch_orders(
|
|
386
|
+
self,
|
|
387
|
+
orders: List[BatchOrderSubmit],
|
|
388
|
+
) -> List[Order]:
|
|
389
|
+
"""Create a batch of orders"""
|
|
390
|
+
pass
|
|
391
|
+
|
|
392
|
+
@abstractmethod
|
|
393
|
+
async def cancel_order(self, oid: str, symbol: str, **kwargs) -> Order:
|
|
394
|
+
"""Cancel an order"""
|
|
395
|
+
pass
|
|
396
|
+
|
|
397
|
+
@abstractmethod
|
|
398
|
+
async def cancel_order_ws(self, oid: str, symbol: str, **kwargs):
|
|
399
|
+
"""Cancel an order"""
|
|
400
|
+
pass
|
|
401
|
+
|
|
402
|
+
@abstractmethod
|
|
403
|
+
async def query_order(self, oid: str, symbol: str) -> Order | None:
|
|
404
|
+
"""Query an order by the local/client order id."""
|
|
405
|
+
pass
|
|
406
|
+
|
|
407
|
+
@abstractmethod
|
|
408
|
+
async def modify_order(
|
|
409
|
+
self,
|
|
410
|
+
oid: str,
|
|
411
|
+
symbol: str,
|
|
412
|
+
side: OrderSide | None = None,
|
|
413
|
+
price: Decimal | None = None,
|
|
414
|
+
amount: Decimal | None = None,
|
|
415
|
+
**kwargs,
|
|
416
|
+
) -> Order:
|
|
417
|
+
"""Modify an order"""
|
|
418
|
+
pass
|
|
419
|
+
|
|
420
|
+
@abstractmethod
|
|
421
|
+
async def cancel_batch_orders(self, orders: List[CancelOrderSubmit]) -> None:
|
|
422
|
+
"""Cancel multiple orders in a single batch request"""
|
|
423
|
+
pass
|
|
424
|
+
|
|
425
|
+
@abstractmethod
|
|
426
|
+
async def cancel_all_orders(self, symbol: str) -> bool:
|
|
427
|
+
"""Cancel all orders"""
|
|
428
|
+
pass
|