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,1199 @@
|
|
|
1
|
+
import msgspec
|
|
2
|
+
import warnings
|
|
3
|
+
from typing import Dict, List, cast
|
|
4
|
+
from decimal import Decimal
|
|
5
|
+
from walrasquant.error import PositionModeError
|
|
6
|
+
from walrasquant.config import OrderQueryConfig
|
|
7
|
+
from walrasquant.exchange.okx.error import (
|
|
8
|
+
OkxRateLimitError,
|
|
9
|
+
OkxHttpError,
|
|
10
|
+
OkxRequestError,
|
|
11
|
+
)
|
|
12
|
+
from walrasquant.exchange.okx import OkxAccountType
|
|
13
|
+
from walrasquant.exchange.okx.websockets import OkxWSClient, OkxWSApiClient
|
|
14
|
+
from walrasquant.exchange.okx.schema import OkxWsGeneralMsg
|
|
15
|
+
from walrasquant.schema import (
|
|
16
|
+
Order,
|
|
17
|
+
Position,
|
|
18
|
+
BatchOrderSubmit,
|
|
19
|
+
CancelOrderSubmit,
|
|
20
|
+
InstrumentId,
|
|
21
|
+
BaseMarket,
|
|
22
|
+
)
|
|
23
|
+
from walrasquant.exchange.okx.schema import (
|
|
24
|
+
OkxMarket,
|
|
25
|
+
OkxWsOrderMsg,
|
|
26
|
+
OkxWsPositionMsg,
|
|
27
|
+
OkxWsAccountMsg,
|
|
28
|
+
OkxBalanceResponse,
|
|
29
|
+
OkxOrderResponse,
|
|
30
|
+
OkxPositionResponse,
|
|
31
|
+
OkxWsApiOrderResponse,
|
|
32
|
+
)
|
|
33
|
+
from walrasquant.constants import (
|
|
34
|
+
OrderStatus,
|
|
35
|
+
TimeInForce,
|
|
36
|
+
ExchangeType,
|
|
37
|
+
PositionSide,
|
|
38
|
+
TriggerType,
|
|
39
|
+
)
|
|
40
|
+
from walrasquant.core.nautilius_core import LiveClock, MessageBus
|
|
41
|
+
from walrasquant.core.cache import AsyncCache
|
|
42
|
+
from walrasquant.core.entity import TaskManager
|
|
43
|
+
from walrasquant.exchange.okx.rest_api import OkxApiClient
|
|
44
|
+
from walrasquant.constants import OrderSide, OrderType
|
|
45
|
+
from walrasquant.exchange.okx.constants import (
|
|
46
|
+
OkxTdMode,
|
|
47
|
+
OkxEnumParser,
|
|
48
|
+
)
|
|
49
|
+
from walrasquant.base import OrderManagementSystem
|
|
50
|
+
from walrasquant.core.registry import OrderRegistry
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OkxOrderManagementSystem(OrderManagementSystem):
|
|
54
|
+
_ws_client: OkxWSClient
|
|
55
|
+
_account_type: OkxAccountType
|
|
56
|
+
_market: Dict[str, OkxMarket]
|
|
57
|
+
_market_id: Dict[str, str]
|
|
58
|
+
_api_client: OkxApiClient
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
account_type: OkxAccountType,
|
|
63
|
+
api_key: str,
|
|
64
|
+
secret: str,
|
|
65
|
+
passphrase: str,
|
|
66
|
+
market: Dict[str, OkxMarket],
|
|
67
|
+
market_id: Dict[str, str],
|
|
68
|
+
registry: OrderRegistry,
|
|
69
|
+
cache: AsyncCache,
|
|
70
|
+
api_client: OkxApiClient,
|
|
71
|
+
exchange_id: ExchangeType,
|
|
72
|
+
clock: LiveClock,
|
|
73
|
+
msgbus: MessageBus,
|
|
74
|
+
task_manager: TaskManager,
|
|
75
|
+
enable_rate_limit: bool,
|
|
76
|
+
order_query_config: OrderQueryConfig,
|
|
77
|
+
max_subscriptions_per_client: int | None = None,
|
|
78
|
+
max_clients: int | None = None,
|
|
79
|
+
):
|
|
80
|
+
super().__init__(
|
|
81
|
+
account_type=account_type,
|
|
82
|
+
market=cast(dict[str, BaseMarket], market),
|
|
83
|
+
market_id=market_id,
|
|
84
|
+
registry=registry,
|
|
85
|
+
cache=cache,
|
|
86
|
+
api_client=api_client,
|
|
87
|
+
ws_client=OkxWSClient(
|
|
88
|
+
account_type=account_type,
|
|
89
|
+
handler=self._ws_msg_handler,
|
|
90
|
+
clock=clock,
|
|
91
|
+
task_manager=task_manager,
|
|
92
|
+
api_key=api_key,
|
|
93
|
+
secret=secret,
|
|
94
|
+
passphrase=passphrase,
|
|
95
|
+
max_subscriptions_per_client=max_subscriptions_per_client,
|
|
96
|
+
max_clients=max_clients,
|
|
97
|
+
),
|
|
98
|
+
exchange_id=exchange_id,
|
|
99
|
+
clock=clock,
|
|
100
|
+
msgbus=msgbus,
|
|
101
|
+
task_manager=task_manager,
|
|
102
|
+
order_query_config=order_query_config,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
self._ws_api_client = OkxWSApiClient(
|
|
106
|
+
account_type=account_type,
|
|
107
|
+
api_key=api_key,
|
|
108
|
+
secret=secret,
|
|
109
|
+
passphrase=passphrase,
|
|
110
|
+
handler=self._ws_api_msg_handler,
|
|
111
|
+
task_manager=task_manager,
|
|
112
|
+
clock=clock,
|
|
113
|
+
enable_rate_limit=enable_rate_limit,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
self._decoder_ws_general_msg = msgspec.json.Decoder(OkxWsGeneralMsg)
|
|
117
|
+
self._decoder_ws_order_msg = msgspec.json.Decoder(OkxWsOrderMsg, strict=False)
|
|
118
|
+
self._decoder_ws_position_msg = msgspec.json.Decoder(
|
|
119
|
+
OkxWsPositionMsg, strict=False
|
|
120
|
+
)
|
|
121
|
+
self._decoder_ws_account_msg = msgspec.json.Decoder(
|
|
122
|
+
OkxWsAccountMsg, strict=False
|
|
123
|
+
)
|
|
124
|
+
self._ws_msg_ws_api_response_decoder = msgspec.json.Decoder(
|
|
125
|
+
OkxWsApiOrderResponse
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def _ws_api_msg_handler(self, raw: bytes):
|
|
129
|
+
# if raw == b"pong":
|
|
130
|
+
# self._ws_api_client._transport.notify_user_specific_pong_received()
|
|
131
|
+
# self._log.debug(f"Pong received: {str(raw)}")
|
|
132
|
+
# return
|
|
133
|
+
try:
|
|
134
|
+
ws_msg: OkxWsGeneralMsg = self._decoder_ws_general_msg.decode(raw)
|
|
135
|
+
if ws_msg.is_event_msg:
|
|
136
|
+
self._handle_event_msg(ws_msg)
|
|
137
|
+
else:
|
|
138
|
+
msg = self._ws_msg_ws_api_response_decoder.decode(raw)
|
|
139
|
+
oid = msg.id
|
|
140
|
+
|
|
141
|
+
tmp_order = self._registry.get_tmp_order(oid)
|
|
142
|
+
if not tmp_order:
|
|
143
|
+
return
|
|
144
|
+
symbol = tmp_order.symbol
|
|
145
|
+
amount = tmp_order.amount
|
|
146
|
+
type = tmp_order.type
|
|
147
|
+
side = tmp_order.side
|
|
148
|
+
price = tmp_order.price
|
|
149
|
+
time_in_force = tmp_order.time_in_force
|
|
150
|
+
reduce_only = tmp_order.reduce_only
|
|
151
|
+
ts = self._clock.timestamp_ms()
|
|
152
|
+
if msg.op.is_place_order:
|
|
153
|
+
if msg.is_success:
|
|
154
|
+
ordId = msg.data[0].ordId
|
|
155
|
+
self._log.debug(
|
|
156
|
+
f"[{symbol}] new order success: oid: {oid} eid: {ordId}"
|
|
157
|
+
)
|
|
158
|
+
order = Order(
|
|
159
|
+
exchange=self._exchange_id,
|
|
160
|
+
symbol=symbol,
|
|
161
|
+
oid=oid,
|
|
162
|
+
eid=ordId,
|
|
163
|
+
status=OrderStatus.PENDING,
|
|
164
|
+
amount=amount,
|
|
165
|
+
type=type,
|
|
166
|
+
side=side,
|
|
167
|
+
price=price,
|
|
168
|
+
time_in_force=time_in_force,
|
|
169
|
+
reduce_only=reduce_only,
|
|
170
|
+
timestamp=ts,
|
|
171
|
+
)
|
|
172
|
+
self.order_status_update(order)
|
|
173
|
+
else:
|
|
174
|
+
self._log.error(
|
|
175
|
+
f"[{symbol}] new order failed: oid: {oid} {msg.error_msg}"
|
|
176
|
+
)
|
|
177
|
+
order = Order(
|
|
178
|
+
exchange=self._exchange_id,
|
|
179
|
+
symbol=symbol,
|
|
180
|
+
oid=oid,
|
|
181
|
+
status=OrderStatus.FAILED,
|
|
182
|
+
amount=amount,
|
|
183
|
+
side=side,
|
|
184
|
+
type=type,
|
|
185
|
+
price=price,
|
|
186
|
+
timestamp=ts,
|
|
187
|
+
time_in_force=time_in_force,
|
|
188
|
+
reduce_only=reduce_only,
|
|
189
|
+
reason=msg.error_msg,
|
|
190
|
+
)
|
|
191
|
+
self.order_status_update(order)
|
|
192
|
+
elif msg.op.is_cancel_order:
|
|
193
|
+
if msg.is_success:
|
|
194
|
+
ordId = msg.data[0].ordId
|
|
195
|
+
self._log.debug(
|
|
196
|
+
f"[{symbol}] canceling order success: oid: {oid} eid: {ordId}"
|
|
197
|
+
)
|
|
198
|
+
order = Order(
|
|
199
|
+
exchange=self._exchange_id,
|
|
200
|
+
symbol=symbol,
|
|
201
|
+
oid=oid,
|
|
202
|
+
eid=ordId,
|
|
203
|
+
side=side,
|
|
204
|
+
status=OrderStatus.CANCELING,
|
|
205
|
+
amount=amount,
|
|
206
|
+
type=type,
|
|
207
|
+
price=price,
|
|
208
|
+
time_in_force=time_in_force,
|
|
209
|
+
timestamp=ts,
|
|
210
|
+
reduce_only=reduce_only,
|
|
211
|
+
)
|
|
212
|
+
self.order_status_update(order)
|
|
213
|
+
else:
|
|
214
|
+
self._log.error(
|
|
215
|
+
f"[{symbol}] canceling order failed: oid: {oid} {msg.error_msg}"
|
|
216
|
+
)
|
|
217
|
+
order = Order(
|
|
218
|
+
exchange=self._exchange_id,
|
|
219
|
+
symbol=symbol,
|
|
220
|
+
oid=oid,
|
|
221
|
+
status=OrderStatus.CANCEL_FAILED,
|
|
222
|
+
amount=amount,
|
|
223
|
+
side=side,
|
|
224
|
+
type=type,
|
|
225
|
+
price=price,
|
|
226
|
+
timestamp=ts,
|
|
227
|
+
time_in_force=time_in_force,
|
|
228
|
+
reduce_only=reduce_only,
|
|
229
|
+
reason=msg.error_msg,
|
|
230
|
+
)
|
|
231
|
+
self.order_status_update(order)
|
|
232
|
+
except msgspec.DecodeError as e:
|
|
233
|
+
self._log.error(f"Error decoding WebSocket API message: {str(raw)} {e}")
|
|
234
|
+
|
|
235
|
+
async def _position_mode_check(self):
|
|
236
|
+
res = await self._api_client.get_api_v5_account_config()
|
|
237
|
+
for data in res.data:
|
|
238
|
+
if not data.posMode.is_one_way_mode:
|
|
239
|
+
raise PositionModeError(
|
|
240
|
+
"Please Set Position Mode to `One-Way Mode` in OKX App"
|
|
241
|
+
)
|
|
242
|
+
if data.acctLv.is_portfolio_margin:
|
|
243
|
+
warnings.warn(
|
|
244
|
+
"For Portfolio Margin Account, `Reduce Only` is not supported"
|
|
245
|
+
)
|
|
246
|
+
self._acctLv = data.acctLv
|
|
247
|
+
|
|
248
|
+
async def _init_account_balance(self):
|
|
249
|
+
res: OkxBalanceResponse = await self._api_client.get_api_v5_account_balance()
|
|
250
|
+
for data in res.data:
|
|
251
|
+
self._cache._apply_balance(self._account_type, data.parse_to_balances())
|
|
252
|
+
|
|
253
|
+
async def _init_position(self):
|
|
254
|
+
res: OkxPositionResponse = await self._api_client.get_api_v5_account_positions()
|
|
255
|
+
for data in res.data:
|
|
256
|
+
side = data.posSide.parse_to_position_side()
|
|
257
|
+
if side == PositionSide.FLAT:
|
|
258
|
+
signed_amount = Decimal(data.pos)
|
|
259
|
+
if signed_amount > 0:
|
|
260
|
+
side = PositionSide.LONG
|
|
261
|
+
elif signed_amount < 0:
|
|
262
|
+
side = PositionSide.SHORT
|
|
263
|
+
else:
|
|
264
|
+
side = None
|
|
265
|
+
elif side == PositionSide.LONG:
|
|
266
|
+
signed_amount = Decimal(data.pos)
|
|
267
|
+
elif side == PositionSide.SHORT:
|
|
268
|
+
signed_amount = -Decimal(data.pos)
|
|
269
|
+
|
|
270
|
+
symbol = self._market_id.get(data.instId)
|
|
271
|
+
if not symbol:
|
|
272
|
+
warnings.warn(f"Symbol {data.instId} not found in market")
|
|
273
|
+
continue
|
|
274
|
+
|
|
275
|
+
market = self._market[symbol]
|
|
276
|
+
|
|
277
|
+
if market.info.ctVal:
|
|
278
|
+
ct_val = Decimal(market.info.ctVal)
|
|
279
|
+
else:
|
|
280
|
+
ct_val = Decimal("1")
|
|
281
|
+
|
|
282
|
+
position = Position(
|
|
283
|
+
symbol=symbol,
|
|
284
|
+
exchange=self._exchange_id,
|
|
285
|
+
side=side,
|
|
286
|
+
signed_amount=signed_amount * ct_val,
|
|
287
|
+
entry_price=float(data.avgPx) if data.avgPx else 0,
|
|
288
|
+
unrealized_pnl=float(data.upl) if data.upl else 0,
|
|
289
|
+
realized_pnl=float(data.realizedPnl) if data.realizedPnl else 0,
|
|
290
|
+
)
|
|
291
|
+
self._cache._apply_position(position)
|
|
292
|
+
|
|
293
|
+
def _handle_event_msg(self, msg: OkxWsGeneralMsg):
|
|
294
|
+
if msg.event == "error":
|
|
295
|
+
self._log.error(msg.error_msg)
|
|
296
|
+
elif msg.event == "login":
|
|
297
|
+
self._log.debug(msg.login_msg)
|
|
298
|
+
elif msg.event == "subscribe":
|
|
299
|
+
self._log.debug(f"Subscribed to {msg.subscribe_msg}")
|
|
300
|
+
|
|
301
|
+
def _ws_msg_handler(self, raw: bytes):
|
|
302
|
+
# if raw == b"pong":
|
|
303
|
+
# self._ws_client._transport.notify_user_specific_pong_received()
|
|
304
|
+
# self._log.debug(f"Pong received: {str(raw)}")
|
|
305
|
+
# return
|
|
306
|
+
try:
|
|
307
|
+
ws_msg: OkxWsGeneralMsg = self._decoder_ws_general_msg.decode(raw)
|
|
308
|
+
if ws_msg.is_event_msg:
|
|
309
|
+
self._handle_event_msg(ws_msg)
|
|
310
|
+
else:
|
|
311
|
+
if ws_msg.arg is None:
|
|
312
|
+
return
|
|
313
|
+
channel = ws_msg.arg.channel
|
|
314
|
+
if channel == "orders":
|
|
315
|
+
self._handle_orders(raw)
|
|
316
|
+
elif channel == "positions":
|
|
317
|
+
self._handle_positions(raw)
|
|
318
|
+
elif channel == "account":
|
|
319
|
+
self._handle_account(raw)
|
|
320
|
+
except msgspec.DecodeError as e:
|
|
321
|
+
self._log.error(f"Error decoding message: {str(raw)} {e}")
|
|
322
|
+
|
|
323
|
+
def _handle_orders(self, raw: bytes):
|
|
324
|
+
msg: OkxWsOrderMsg = self._decoder_ws_order_msg.decode(raw)
|
|
325
|
+
self._log.debug(f"Order update: {str(msg)}")
|
|
326
|
+
for data in msg.data:
|
|
327
|
+
symbol = self._market_id[data.instId]
|
|
328
|
+
|
|
329
|
+
market = self._market[symbol]
|
|
330
|
+
|
|
331
|
+
if not market.spot:
|
|
332
|
+
ct_val = Decimal(market.info.ctVal or "1") # contract size
|
|
333
|
+
else:
|
|
334
|
+
ct_val = Decimal("1")
|
|
335
|
+
|
|
336
|
+
order = Order(
|
|
337
|
+
exchange=self._exchange_id,
|
|
338
|
+
symbol=symbol,
|
|
339
|
+
status=OkxEnumParser.parse_order_status(data.state),
|
|
340
|
+
eid=data.ordId,
|
|
341
|
+
amount=Decimal(data.sz) * ct_val,
|
|
342
|
+
filled=Decimal(data.accFillSz) * ct_val,
|
|
343
|
+
oid=data.clOrdId,
|
|
344
|
+
timestamp=data.uTime,
|
|
345
|
+
type=OkxEnumParser.parse_order_type(data.ordType),
|
|
346
|
+
side=OkxEnumParser.parse_order_side(data.side),
|
|
347
|
+
time_in_force=OkxEnumParser.parse_time_in_force(data.ordType),
|
|
348
|
+
price=float(data.px) if data.px else None,
|
|
349
|
+
average=float(data.avgPx) if data.avgPx else None,
|
|
350
|
+
last_filled_price=float(data.fillPx) if data.fillPx else None,
|
|
351
|
+
last_filled=Decimal(data.fillSz) * ct_val
|
|
352
|
+
if data.fillSz
|
|
353
|
+
else Decimal(0),
|
|
354
|
+
remaining=Decimal(data.sz) * ct_val - Decimal(data.accFillSz) * ct_val,
|
|
355
|
+
fee=Decimal(data.fee or "0"), # accumalated fee
|
|
356
|
+
fee_currency=data.feeCcy, # accumalated fee currency
|
|
357
|
+
cost=Decimal(data.avgPx or "0") * Decimal(data.fillSz or "0") * ct_val,
|
|
358
|
+
cum_cost=Decimal(data.avgPx or "0")
|
|
359
|
+
* Decimal(data.accFillSz or "0")
|
|
360
|
+
* ct_val,
|
|
361
|
+
reduce_only=data.reduceOnly,
|
|
362
|
+
position_side=OkxEnumParser.parse_position_side(data.posSide),
|
|
363
|
+
)
|
|
364
|
+
self.order_status_update(order)
|
|
365
|
+
|
|
366
|
+
def _handle_positions(self, raw: bytes):
|
|
367
|
+
position_msg = self._decoder_ws_position_msg.decode(raw)
|
|
368
|
+
self._log.debug(f"Okx Position Msg: {str(position_msg)}")
|
|
369
|
+
|
|
370
|
+
for data in position_msg.data:
|
|
371
|
+
symbol = self._market_id.get(data.instId)
|
|
372
|
+
if not symbol:
|
|
373
|
+
continue
|
|
374
|
+
market = self._market[symbol]
|
|
375
|
+
|
|
376
|
+
if market.info.ctVal:
|
|
377
|
+
ct_val = Decimal(market.info.ctVal or "1")
|
|
378
|
+
else:
|
|
379
|
+
ct_val = Decimal("1")
|
|
380
|
+
|
|
381
|
+
side = data.posSide.parse_to_position_side()
|
|
382
|
+
if side == PositionSide.LONG:
|
|
383
|
+
signed_amount = Decimal(data.pos)
|
|
384
|
+
elif side == PositionSide.SHORT:
|
|
385
|
+
signed_amount = -Decimal(data.pos)
|
|
386
|
+
elif side == PositionSide.FLAT:
|
|
387
|
+
# one way mode, posSide always is 'net' from OKX ws msg, and pos amount is signed
|
|
388
|
+
signed_amount = Decimal(data.pos)
|
|
389
|
+
if signed_amount > 0:
|
|
390
|
+
side = PositionSide.LONG
|
|
391
|
+
elif signed_amount < 0:
|
|
392
|
+
side = PositionSide.SHORT
|
|
393
|
+
else:
|
|
394
|
+
side = None
|
|
395
|
+
else:
|
|
396
|
+
self._log.warning(f"Invalid position side: {side}")
|
|
397
|
+
|
|
398
|
+
position = Position(
|
|
399
|
+
symbol=symbol,
|
|
400
|
+
exchange=self._exchange_id,
|
|
401
|
+
side=side,
|
|
402
|
+
signed_amount=signed_amount * ct_val,
|
|
403
|
+
entry_price=float(data.avgPx) if data.avgPx else 0,
|
|
404
|
+
unrealized_pnl=float(data.upl) if data.upl else 0,
|
|
405
|
+
realized_pnl=float(data.realizedPnl) if data.realizedPnl else 0,
|
|
406
|
+
)
|
|
407
|
+
self._log.debug(f"Position updated: {str(position)}")
|
|
408
|
+
self._cache._apply_position(position)
|
|
409
|
+
|
|
410
|
+
def _handle_account(self, raw: bytes):
|
|
411
|
+
account_msg: OkxWsAccountMsg = self._decoder_ws_account_msg.decode(raw)
|
|
412
|
+
self._log.debug(f"Account update: {str(account_msg)}")
|
|
413
|
+
|
|
414
|
+
for data in account_msg.data:
|
|
415
|
+
balances = data.parse_to_balance()
|
|
416
|
+
self._cache._apply_balance(self._account_type, balances)
|
|
417
|
+
|
|
418
|
+
def _get_td_mode(self, market: OkxMarket):
|
|
419
|
+
if (
|
|
420
|
+
not market.spot
|
|
421
|
+
or self._acctLv.is_portfolio_margin
|
|
422
|
+
or self._acctLv.is_multi_currency_margin
|
|
423
|
+
):
|
|
424
|
+
return OkxTdMode.CROSS
|
|
425
|
+
else:
|
|
426
|
+
return OkxTdMode.CASH
|
|
427
|
+
|
|
428
|
+
async def create_tp_sl_order(
|
|
429
|
+
self,
|
|
430
|
+
oid: str,
|
|
431
|
+
symbol: str,
|
|
432
|
+
side: OrderSide,
|
|
433
|
+
type: OrderType,
|
|
434
|
+
amount: Decimal,
|
|
435
|
+
price: Decimal | None = None,
|
|
436
|
+
time_in_force: TimeInForce | None = TimeInForce.GTC,
|
|
437
|
+
tp_order_type: OrderType | None = None,
|
|
438
|
+
tp_trigger_price: Decimal | None = None,
|
|
439
|
+
tp_price: Decimal | None = None,
|
|
440
|
+
tp_trigger_type: TriggerType | None = TriggerType.LAST_PRICE,
|
|
441
|
+
sl_order_type: OrderType | None = None,
|
|
442
|
+
sl_trigger_price: Decimal | None = None,
|
|
443
|
+
sl_price: Decimal | None = None,
|
|
444
|
+
sl_trigger_type: TriggerType | None = TriggerType.LAST_PRICE,
|
|
445
|
+
**kwargs,
|
|
446
|
+
) -> Order:
|
|
447
|
+
"""Create a take profit and stop loss order"""
|
|
448
|
+
market = self._market.get(symbol)
|
|
449
|
+
if not market:
|
|
450
|
+
raise ValueError(f"Symbol {symbol} formated wrongly, or not supported")
|
|
451
|
+
inst_id = market.id
|
|
452
|
+
|
|
453
|
+
td_mode = kwargs.pop("td_mode", None) or kwargs.pop("tdMode", None)
|
|
454
|
+
if not td_mode:
|
|
455
|
+
td_mode = self._get_td_mode(market)
|
|
456
|
+
else:
|
|
457
|
+
td_mode = OkxTdMode(td_mode)
|
|
458
|
+
|
|
459
|
+
if not market.spot:
|
|
460
|
+
ct_val = Decimal(market.info.ctVal or "1") # contract size
|
|
461
|
+
sz = format(amount / ct_val, "f")
|
|
462
|
+
else:
|
|
463
|
+
sz = str(amount)
|
|
464
|
+
|
|
465
|
+
params = {
|
|
466
|
+
"inst_id": inst_id,
|
|
467
|
+
"td_mode": td_mode.value,
|
|
468
|
+
"side": OkxEnumParser.to_okx_order_side(side).value,
|
|
469
|
+
"ord_type": OkxEnumParser.to_okx_order_type(
|
|
470
|
+
type, time_in_force or TimeInForce.GTC
|
|
471
|
+
).value,
|
|
472
|
+
"sz": sz,
|
|
473
|
+
"clOrdId": oid,
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if type.is_limit or type.is_post_only:
|
|
477
|
+
if not price:
|
|
478
|
+
raise ValueError("Price is required for limit order")
|
|
479
|
+
params["px"] = str(price)
|
|
480
|
+
else:
|
|
481
|
+
if market.spot and not self._acctLv.is_futures and not td_mode.is_isolated:
|
|
482
|
+
params["tgtCcy"] = "base_ccy"
|
|
483
|
+
|
|
484
|
+
if (
|
|
485
|
+
market.spot
|
|
486
|
+
and self._acctLv.is_futures
|
|
487
|
+
and (td_mode.is_cross or td_mode.is_isolated)
|
|
488
|
+
):
|
|
489
|
+
if side == OrderSide.BUY:
|
|
490
|
+
params["ccy"] = market.quote
|
|
491
|
+
else:
|
|
492
|
+
params["ccy"] = market.base
|
|
493
|
+
|
|
494
|
+
attachAlgoOrds = {}
|
|
495
|
+
if tp_trigger_price is not None:
|
|
496
|
+
if tp_trigger_type is None or tp_order_type is None:
|
|
497
|
+
raise ValueError("tp_trigger_type and tp_order_type are required")
|
|
498
|
+
attachAlgoOrds["tpTriggerPx"] = str(tp_trigger_price)
|
|
499
|
+
attachAlgoOrds["tpTriggerPxType"] = OkxEnumParser.to_okx_trigger_type(
|
|
500
|
+
tp_trigger_type
|
|
501
|
+
).value
|
|
502
|
+
if tp_order_type.is_limit:
|
|
503
|
+
attachAlgoOrds["tpOrdPx"] = str(tp_price)
|
|
504
|
+
else:
|
|
505
|
+
attachAlgoOrds["tpOrdPx"] = "-1"
|
|
506
|
+
|
|
507
|
+
if sl_trigger_price is not None:
|
|
508
|
+
if sl_trigger_type is None or sl_order_type is None:
|
|
509
|
+
raise ValueError("sl_trigger_type and sl_order_type are required")
|
|
510
|
+
attachAlgoOrds["slTriggerPx"] = str(sl_trigger_price)
|
|
511
|
+
attachAlgoOrds["slTriggerPxType"] = OkxEnumParser.to_okx_trigger_type(
|
|
512
|
+
sl_trigger_type
|
|
513
|
+
).value
|
|
514
|
+
if sl_order_type.is_limit:
|
|
515
|
+
attachAlgoOrds["slOrdPx"] = str(sl_price)
|
|
516
|
+
else:
|
|
517
|
+
attachAlgoOrds["slOrdPx"] = "-1"
|
|
518
|
+
|
|
519
|
+
if attachAlgoOrds:
|
|
520
|
+
params["attachAlgoOrds"] = attachAlgoOrds
|
|
521
|
+
|
|
522
|
+
params.update(kwargs)
|
|
523
|
+
|
|
524
|
+
try:
|
|
525
|
+
res = await self._api_client.post_api_v5_trade_order(**params)
|
|
526
|
+
res = res.data[0]
|
|
527
|
+
|
|
528
|
+
order = Order(
|
|
529
|
+
exchange=self._exchange_id,
|
|
530
|
+
eid=res.ordId,
|
|
531
|
+
oid=oid,
|
|
532
|
+
timestamp=int(res.ts),
|
|
533
|
+
symbol=symbol,
|
|
534
|
+
type=type,
|
|
535
|
+
side=side,
|
|
536
|
+
amount=amount,
|
|
537
|
+
price=float(price) if price else None,
|
|
538
|
+
time_in_force=time_in_force,
|
|
539
|
+
status=OrderStatus.PENDING,
|
|
540
|
+
filled=Decimal(0),
|
|
541
|
+
remaining=amount,
|
|
542
|
+
)
|
|
543
|
+
return order
|
|
544
|
+
except Exception as e:
|
|
545
|
+
error_msg = f"{e.__class__.__name__}: {str(e)}"
|
|
546
|
+
self._log.error(f"Error creating order: {error_msg} params: {str(params)}")
|
|
547
|
+
order = Order(
|
|
548
|
+
exchange=self._exchange_id,
|
|
549
|
+
timestamp=self._clock.timestamp_ms(),
|
|
550
|
+
symbol=symbol,
|
|
551
|
+
oid=oid,
|
|
552
|
+
type=type,
|
|
553
|
+
side=side,
|
|
554
|
+
amount=amount,
|
|
555
|
+
price=float(price) if price else None,
|
|
556
|
+
time_in_force=time_in_force,
|
|
557
|
+
status=OrderStatus.FAILED,
|
|
558
|
+
filled=Decimal(0),
|
|
559
|
+
remaining=amount,
|
|
560
|
+
reason=error_msg,
|
|
561
|
+
)
|
|
562
|
+
return order
|
|
563
|
+
|
|
564
|
+
async def create_batch_orders(
|
|
565
|
+
self,
|
|
566
|
+
orders: List[BatchOrderSubmit],
|
|
567
|
+
):
|
|
568
|
+
if not orders:
|
|
569
|
+
raise ValueError("Orders list cannot be empty")
|
|
570
|
+
|
|
571
|
+
batch_orders = []
|
|
572
|
+
for order in orders:
|
|
573
|
+
market = self._market.get(order.symbol)
|
|
574
|
+
if not market:
|
|
575
|
+
raise ValueError(
|
|
576
|
+
f"Symbol {order.symbol} formated wrongly, or not supported"
|
|
577
|
+
)
|
|
578
|
+
inst_id = market.id
|
|
579
|
+
|
|
580
|
+
td_mode = order.kwargs.pop("td_mode", None) or order.kwargs.pop(
|
|
581
|
+
"tdMode", None
|
|
582
|
+
)
|
|
583
|
+
if not td_mode:
|
|
584
|
+
td_mode = self._get_td_mode(market)
|
|
585
|
+
else:
|
|
586
|
+
td_mode = OkxTdMode(td_mode)
|
|
587
|
+
|
|
588
|
+
if not market.spot:
|
|
589
|
+
ct_val = Decimal(market.info.ctVal or "1")
|
|
590
|
+
sz = format(order.amount / ct_val, "f")
|
|
591
|
+
else:
|
|
592
|
+
sz = str(order.amount)
|
|
593
|
+
|
|
594
|
+
params = {
|
|
595
|
+
"inst_id": inst_id,
|
|
596
|
+
"td_mode": td_mode.value,
|
|
597
|
+
"side": OkxEnumParser.to_okx_order_side(order.side).value,
|
|
598
|
+
"ord_type": OkxEnumParser.to_okx_order_type(
|
|
599
|
+
order.type, order.time_in_force
|
|
600
|
+
).value,
|
|
601
|
+
"sz": sz,
|
|
602
|
+
"clOrdId": order.oid,
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if order.type.is_limit or order.type.is_post_only:
|
|
606
|
+
if not order.price:
|
|
607
|
+
raise ValueError("Price is required for limit order")
|
|
608
|
+
params["px"] = str(order.price)
|
|
609
|
+
else:
|
|
610
|
+
if (
|
|
611
|
+
market.spot
|
|
612
|
+
and not self._acctLv.is_futures
|
|
613
|
+
and not td_mode.is_isolated
|
|
614
|
+
):
|
|
615
|
+
params["tgtCcy"] = "base_ccy"
|
|
616
|
+
|
|
617
|
+
if (
|
|
618
|
+
market.spot
|
|
619
|
+
and self._acctLv.is_futures
|
|
620
|
+
and (td_mode.is_cross or td_mode.is_isolated)
|
|
621
|
+
):
|
|
622
|
+
if order.side == OrderSide.BUY:
|
|
623
|
+
params["ccy"] = market.quote
|
|
624
|
+
else:
|
|
625
|
+
params["ccy"] = market.base
|
|
626
|
+
|
|
627
|
+
params["reduceOnly"] = order.reduce_only
|
|
628
|
+
|
|
629
|
+
params.update(order.kwargs)
|
|
630
|
+
batch_orders.append(params)
|
|
631
|
+
|
|
632
|
+
try:
|
|
633
|
+
res = await self._api_client.post_api_v5_trade_batch_orders(
|
|
634
|
+
payload=batch_orders
|
|
635
|
+
)
|
|
636
|
+
for order, res_order in zip(orders, res.data):
|
|
637
|
+
if res_order.sCode == "0":
|
|
638
|
+
order_result = Order(
|
|
639
|
+
exchange=self._exchange_id,
|
|
640
|
+
oid=order.oid,
|
|
641
|
+
eid=res_order.ordId,
|
|
642
|
+
timestamp=int(res_order.ts),
|
|
643
|
+
symbol=order.symbol,
|
|
644
|
+
type=order.type,
|
|
645
|
+
side=order.side,
|
|
646
|
+
amount=order.amount,
|
|
647
|
+
price=float(order.price) if order.price else None,
|
|
648
|
+
time_in_force=order.time_in_force,
|
|
649
|
+
status=OrderStatus.PENDING,
|
|
650
|
+
filled=Decimal(0),
|
|
651
|
+
remaining=order.amount,
|
|
652
|
+
reduce_only=order.reduce_only,
|
|
653
|
+
)
|
|
654
|
+
else:
|
|
655
|
+
order_result = Order(
|
|
656
|
+
exchange=self._exchange_id,
|
|
657
|
+
oid=order.oid,
|
|
658
|
+
timestamp=self._clock.timestamp_ms(),
|
|
659
|
+
symbol=order.symbol,
|
|
660
|
+
type=order.type,
|
|
661
|
+
side=order.side,
|
|
662
|
+
amount=order.amount,
|
|
663
|
+
price=float(order.price) if order.price else None,
|
|
664
|
+
time_in_force=order.time_in_force,
|
|
665
|
+
status=OrderStatus.FAILED,
|
|
666
|
+
filled=Decimal(0),
|
|
667
|
+
remaining=order.amount,
|
|
668
|
+
reduce_only=order.reduce_only,
|
|
669
|
+
reason=f"code={res_order.sCode}, msg={res_order.sMsg}",
|
|
670
|
+
)
|
|
671
|
+
self._log.error(
|
|
672
|
+
f"Failed to create order for {order.symbol}: {res_order.sMsg}: {res_order.sCode}: {order.oid}"
|
|
673
|
+
)
|
|
674
|
+
self.order_status_update(order_result)
|
|
675
|
+
except Exception as e:
|
|
676
|
+
error_msg = f"{e.__class__.__name__}: {str(e)}"
|
|
677
|
+
self._log.error(
|
|
678
|
+
f"Error creating batch orders: {error_msg} params: {str(orders)}"
|
|
679
|
+
)
|
|
680
|
+
for order in orders:
|
|
681
|
+
order_result = Order(
|
|
682
|
+
exchange=self._exchange_id,
|
|
683
|
+
timestamp=self._clock.timestamp_ms(),
|
|
684
|
+
symbol=order.symbol,
|
|
685
|
+
oid=order.oid,
|
|
686
|
+
type=order.type,
|
|
687
|
+
side=order.side,
|
|
688
|
+
amount=order.amount,
|
|
689
|
+
price=float(order.price) if order.price else None,
|
|
690
|
+
time_in_force=order.time_in_force,
|
|
691
|
+
status=OrderStatus.FAILED,
|
|
692
|
+
filled=Decimal(0),
|
|
693
|
+
remaining=order.amount,
|
|
694
|
+
reason=error_msg,
|
|
695
|
+
)
|
|
696
|
+
self.order_status_update(order_result)
|
|
697
|
+
|
|
698
|
+
async def cancel_batch_orders(self, orders: List[CancelOrderSubmit]) -> bool:
|
|
699
|
+
payload = []
|
|
700
|
+
for order in orders:
|
|
701
|
+
market = self._market.get(order.symbol)
|
|
702
|
+
if not market:
|
|
703
|
+
raise ValueError(f"Symbol {order.symbol} not found")
|
|
704
|
+
payload.append({"instId": market.id, "clOrdId": order.oid})
|
|
705
|
+
|
|
706
|
+
all_success = True
|
|
707
|
+
for i in range(0, len(payload), 20):
|
|
708
|
+
chunk_payload = payload[i : i + 20]
|
|
709
|
+
chunk_orders = orders[i : i + 20]
|
|
710
|
+
try:
|
|
711
|
+
res = await self._api_client.post_api_v5_trade_cancel_batch_order(
|
|
712
|
+
payload=chunk_payload
|
|
713
|
+
)
|
|
714
|
+
for res_order, submit in zip(res.data, chunk_orders):
|
|
715
|
+
if res_order.sCode == "0":
|
|
716
|
+
order = Order(
|
|
717
|
+
exchange=self._exchange_id,
|
|
718
|
+
symbol=submit.symbol,
|
|
719
|
+
oid=res_order.clOrdId,
|
|
720
|
+
eid=res_order.ordId,
|
|
721
|
+
timestamp=int(res_order.ts),
|
|
722
|
+
status=OrderStatus.CANCELING,
|
|
723
|
+
)
|
|
724
|
+
else:
|
|
725
|
+
order = Order(
|
|
726
|
+
exchange=self._exchange_id,
|
|
727
|
+
symbol=submit.symbol,
|
|
728
|
+
oid=submit.oid,
|
|
729
|
+
timestamp=self._clock.timestamp_ms(),
|
|
730
|
+
status=OrderStatus.CANCEL_FAILED,
|
|
731
|
+
reason=f"code={res_order.sCode}, msg={res_order.sMsg}",
|
|
732
|
+
)
|
|
733
|
+
all_success = False
|
|
734
|
+
self._log.error(
|
|
735
|
+
f"Batch cancel failed for {submit.symbol} oid={submit.oid}: {res_order.sMsg}"
|
|
736
|
+
)
|
|
737
|
+
self.order_status_update(order)
|
|
738
|
+
except Exception as e:
|
|
739
|
+
error_msg = f"{e.__class__.__name__}: {str(e)}"
|
|
740
|
+
self._log.error(f"Error canceling batch orders: {error_msg}")
|
|
741
|
+
all_success = False
|
|
742
|
+
for submit in chunk_orders:
|
|
743
|
+
order = Order(
|
|
744
|
+
exchange=self._exchange_id,
|
|
745
|
+
symbol=submit.symbol,
|
|
746
|
+
oid=submit.oid,
|
|
747
|
+
timestamp=self._clock.timestamp_ms(),
|
|
748
|
+
status=OrderStatus.CANCEL_FAILED,
|
|
749
|
+
reason=error_msg,
|
|
750
|
+
)
|
|
751
|
+
self.order_status_update(order)
|
|
752
|
+
return all_success
|
|
753
|
+
|
|
754
|
+
async def create_order_ws(
|
|
755
|
+
self,
|
|
756
|
+
oid: str,
|
|
757
|
+
symbol: str,
|
|
758
|
+
side: OrderSide,
|
|
759
|
+
type: OrderType,
|
|
760
|
+
amount: Decimal,
|
|
761
|
+
price: Decimal | None = None,
|
|
762
|
+
time_in_force: TimeInForce | None = TimeInForce.GTC,
|
|
763
|
+
reduce_only: bool = False,
|
|
764
|
+
**kwargs,
|
|
765
|
+
) -> None:
|
|
766
|
+
self._registry.register_tmp_order(
|
|
767
|
+
order=Order(
|
|
768
|
+
oid=oid,
|
|
769
|
+
exchange=self._exchange_id,
|
|
770
|
+
symbol=symbol,
|
|
771
|
+
status=OrderStatus.INITIALIZED,
|
|
772
|
+
amount=amount,
|
|
773
|
+
type=type,
|
|
774
|
+
price=float(price) if price else None,
|
|
775
|
+
time_in_force=time_in_force,
|
|
776
|
+
timestamp=self._clock.timestamp_ms(),
|
|
777
|
+
reduce_only=reduce_only,
|
|
778
|
+
)
|
|
779
|
+
)
|
|
780
|
+
market = self._market.get(symbol)
|
|
781
|
+
if not market:
|
|
782
|
+
raise ValueError(f"Symbol {symbol} formated wrongly, or not supported")
|
|
783
|
+
inst_id_code = market.info.instIdCode
|
|
784
|
+
if inst_id_code is None:
|
|
785
|
+
raise ValueError("instIdCode is required for OKX websocket orders")
|
|
786
|
+
|
|
787
|
+
td_mode = kwargs.pop("td_mode", None) or kwargs.pop("tdMode", None)
|
|
788
|
+
if not td_mode:
|
|
789
|
+
td_mode = self._get_td_mode(market)
|
|
790
|
+
else:
|
|
791
|
+
td_mode = OkxTdMode(td_mode)
|
|
792
|
+
|
|
793
|
+
if not market.spot:
|
|
794
|
+
ct_val = Decimal(market.info.ctVal or "1") # contract size
|
|
795
|
+
sz = format(amount / ct_val, "f")
|
|
796
|
+
else:
|
|
797
|
+
sz = str(amount)
|
|
798
|
+
|
|
799
|
+
params = {
|
|
800
|
+
"instIdCode": int(inst_id_code),
|
|
801
|
+
"tdMode": td_mode.value,
|
|
802
|
+
"side": OkxEnumParser.to_okx_order_side(side).value,
|
|
803
|
+
"ordType": OkxEnumParser.to_okx_order_type(
|
|
804
|
+
type, time_in_force or TimeInForce.GTC
|
|
805
|
+
).value,
|
|
806
|
+
"sz": sz,
|
|
807
|
+
"clOrdId": oid,
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
if type.is_limit or type.is_post_only:
|
|
811
|
+
if not price:
|
|
812
|
+
raise ValueError("Price is required for limit order")
|
|
813
|
+
params["px"] = str(price)
|
|
814
|
+
else:
|
|
815
|
+
if market.spot and not self._acctLv.is_futures and not td_mode.is_isolated:
|
|
816
|
+
params["tgtCcy"] = "base_ccy"
|
|
817
|
+
|
|
818
|
+
if (
|
|
819
|
+
market.spot
|
|
820
|
+
and self._acctLv.is_futures
|
|
821
|
+
and (td_mode.is_cross or td_mode.is_isolated)
|
|
822
|
+
):
|
|
823
|
+
if side == OrderSide.BUY:
|
|
824
|
+
params["ccy"] = market.quote
|
|
825
|
+
else:
|
|
826
|
+
params["ccy"] = market.base
|
|
827
|
+
|
|
828
|
+
params["reduceOnly"] = reduce_only
|
|
829
|
+
|
|
830
|
+
params.update(kwargs)
|
|
831
|
+
|
|
832
|
+
try:
|
|
833
|
+
await self._ws_api_client.place_order(
|
|
834
|
+
id=oid,
|
|
835
|
+
instIdCode=int(params["instIdCode"]),
|
|
836
|
+
tdMode=str(params["tdMode"]),
|
|
837
|
+
side=str(params["side"]),
|
|
838
|
+
ordType=str(params["ordType"]),
|
|
839
|
+
sz=str(params["sz"]),
|
|
840
|
+
**{
|
|
841
|
+
k: v
|
|
842
|
+
for k, v in params.items()
|
|
843
|
+
if k not in {"instIdCode", "tdMode", "side", "ordType", "sz"}
|
|
844
|
+
},
|
|
845
|
+
)
|
|
846
|
+
except OkxRateLimitError as e:
|
|
847
|
+
order = self._rate_limit_failed_order(
|
|
848
|
+
oid=oid,
|
|
849
|
+
symbol=symbol,
|
|
850
|
+
side=side,
|
|
851
|
+
type=type,
|
|
852
|
+
amount=amount,
|
|
853
|
+
price=price,
|
|
854
|
+
time_in_force=time_in_force,
|
|
855
|
+
reduce_only=reduce_only,
|
|
856
|
+
exc=e,
|
|
857
|
+
)
|
|
858
|
+
self.order_status_update(order)
|
|
859
|
+
|
|
860
|
+
async def create_order(
|
|
861
|
+
self,
|
|
862
|
+
oid: str,
|
|
863
|
+
symbol: str,
|
|
864
|
+
side: OrderSide,
|
|
865
|
+
type: OrderType,
|
|
866
|
+
amount: Decimal,
|
|
867
|
+
price: Decimal | None = None,
|
|
868
|
+
time_in_force: TimeInForce | None = TimeInForce.GTC,
|
|
869
|
+
reduce_only: bool = False,
|
|
870
|
+
**kwargs,
|
|
871
|
+
) -> Order:
|
|
872
|
+
market = self._market.get(symbol)
|
|
873
|
+
if not market:
|
|
874
|
+
raise ValueError(f"Symbol {symbol} formated wrongly, or not supported")
|
|
875
|
+
inst_id = market.id
|
|
876
|
+
|
|
877
|
+
td_mode = kwargs.pop("td_mode", None) or kwargs.pop("tdMode", None)
|
|
878
|
+
if not td_mode:
|
|
879
|
+
td_mode = self._get_td_mode(market)
|
|
880
|
+
else:
|
|
881
|
+
td_mode = OkxTdMode(td_mode)
|
|
882
|
+
|
|
883
|
+
if not market.spot:
|
|
884
|
+
ct_val = Decimal(market.info.ctVal or "1") # contract size
|
|
885
|
+
sz = format(amount / ct_val, "f")
|
|
886
|
+
else:
|
|
887
|
+
sz = str(amount)
|
|
888
|
+
|
|
889
|
+
params = {
|
|
890
|
+
"inst_id": inst_id,
|
|
891
|
+
"td_mode": td_mode.value,
|
|
892
|
+
"side": OkxEnumParser.to_okx_order_side(side).value,
|
|
893
|
+
"ord_type": OkxEnumParser.to_okx_order_type(
|
|
894
|
+
type, time_in_force or TimeInForce.GTC
|
|
895
|
+
).value,
|
|
896
|
+
"sz": sz,
|
|
897
|
+
"clOrdId": oid,
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
if type.is_limit or type.is_post_only:
|
|
901
|
+
if not price:
|
|
902
|
+
raise ValueError("Price is required for limit order")
|
|
903
|
+
params["px"] = str(price)
|
|
904
|
+
else:
|
|
905
|
+
if market.spot and not self._acctLv.is_futures and not td_mode.is_isolated:
|
|
906
|
+
params["tgtCcy"] = "base_ccy"
|
|
907
|
+
|
|
908
|
+
if (
|
|
909
|
+
market.spot
|
|
910
|
+
and self._acctLv.is_futures
|
|
911
|
+
and (td_mode.is_cross or td_mode.is_isolated)
|
|
912
|
+
):
|
|
913
|
+
if side == OrderSide.BUY:
|
|
914
|
+
params["ccy"] = market.quote
|
|
915
|
+
else:
|
|
916
|
+
params["ccy"] = market.base
|
|
917
|
+
|
|
918
|
+
# if position_side:
|
|
919
|
+
# params["posSide"] = OkxEnumParser.to_okx_position_side(position_side).value
|
|
920
|
+
|
|
921
|
+
params["reduceOnly"] = reduce_only
|
|
922
|
+
|
|
923
|
+
params.update(kwargs)
|
|
924
|
+
|
|
925
|
+
try:
|
|
926
|
+
res = await self._api_client.post_api_v5_trade_order(**params)
|
|
927
|
+
res = res.data[0]
|
|
928
|
+
order = Order(
|
|
929
|
+
oid=oid,
|
|
930
|
+
eid=res.ordId,
|
|
931
|
+
exchange=self._exchange_id,
|
|
932
|
+
timestamp=int(res.ts),
|
|
933
|
+
symbol=symbol,
|
|
934
|
+
type=type,
|
|
935
|
+
side=side,
|
|
936
|
+
amount=amount,
|
|
937
|
+
price=float(price) if price else None,
|
|
938
|
+
time_in_force=time_in_force,
|
|
939
|
+
reduce_only=reduce_only,
|
|
940
|
+
status=OrderStatus.PENDING,
|
|
941
|
+
filled=Decimal(0),
|
|
942
|
+
remaining=amount,
|
|
943
|
+
)
|
|
944
|
+
except Exception as e:
|
|
945
|
+
error_msg = f"{e.__class__.__name__}: {str(e)}"
|
|
946
|
+
self._log.error(f"Error creating order: {error_msg} params: {str(params)}")
|
|
947
|
+
order = Order(
|
|
948
|
+
oid=oid,
|
|
949
|
+
exchange=self._exchange_id,
|
|
950
|
+
timestamp=self._clock.timestamp_ms(),
|
|
951
|
+
symbol=symbol,
|
|
952
|
+
type=type,
|
|
953
|
+
side=side,
|
|
954
|
+
amount=amount,
|
|
955
|
+
price=float(price) if price else None,
|
|
956
|
+
time_in_force=time_in_force,
|
|
957
|
+
reduce_only=reduce_only,
|
|
958
|
+
status=OrderStatus.FAILED,
|
|
959
|
+
filled=Decimal(0),
|
|
960
|
+
remaining=amount,
|
|
961
|
+
reason=error_msg,
|
|
962
|
+
)
|
|
963
|
+
self.order_status_update(order)
|
|
964
|
+
return order
|
|
965
|
+
|
|
966
|
+
async def cancel_order_ws(self, oid: str, symbol: str, **kwargs) -> None:
|
|
967
|
+
market = self._market.get(symbol)
|
|
968
|
+
if not market:
|
|
969
|
+
raise ValueError(f"Symbol {symbol} formated wrongly, or not supported")
|
|
970
|
+
inst_id_code = market.info.instIdCode
|
|
971
|
+
if inst_id_code is None:
|
|
972
|
+
raise ValueError("instIdCode is required for OKX websocket orders")
|
|
973
|
+
|
|
974
|
+
params = {"instIdCode": int(inst_id_code), "clOrdId": oid}
|
|
975
|
+
await self._ws_api_client.cancel_order(
|
|
976
|
+
id=oid,
|
|
977
|
+
instIdCode=int(params["instIdCode"]),
|
|
978
|
+
clOrdId=str(params["clOrdId"]),
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
async def cancel_order(self, oid: str, symbol: str, **kwargs) -> Order:
|
|
982
|
+
market = self._market.get(symbol)
|
|
983
|
+
if not market:
|
|
984
|
+
raise ValueError(f"Symbol {symbol} formated wrongly, or not supported")
|
|
985
|
+
inst_id = market.id
|
|
986
|
+
|
|
987
|
+
params = {"instId": inst_id, "clOrdId": oid}
|
|
988
|
+
params.update(kwargs)
|
|
989
|
+
|
|
990
|
+
try:
|
|
991
|
+
res = await self._api_client.post_api_v5_trade_cancel_order(**params)
|
|
992
|
+
res = res.data[0]
|
|
993
|
+
order = Order(
|
|
994
|
+
exchange=self._exchange_id,
|
|
995
|
+
eid=res.ordId,
|
|
996
|
+
oid=oid,
|
|
997
|
+
timestamp=int(res.ts),
|
|
998
|
+
symbol=symbol,
|
|
999
|
+
status=OrderStatus.CANCELING,
|
|
1000
|
+
)
|
|
1001
|
+
except OkxRateLimitError as e:
|
|
1002
|
+
error_msg = f"rate_limit (retry_after={e.retry_after:.1f}s): {str(e)}"
|
|
1003
|
+
self._log.error(f"Error canceling order: {error_msg} params: {str(params)}")
|
|
1004
|
+
order = Order(
|
|
1005
|
+
oid=oid,
|
|
1006
|
+
exchange=self._exchange_id,
|
|
1007
|
+
timestamp=self._clock.timestamp_ms(),
|
|
1008
|
+
symbol=symbol,
|
|
1009
|
+
status=OrderStatus.CANCEL_FAILED,
|
|
1010
|
+
reason=error_msg,
|
|
1011
|
+
)
|
|
1012
|
+
except (OkxHttpError, OkxRequestError) as e:
|
|
1013
|
+
error_msg = str(e)
|
|
1014
|
+
self._log.error(f"Error canceling order: {error_msg} params: {str(params)}")
|
|
1015
|
+
order = Order(
|
|
1016
|
+
oid=oid,
|
|
1017
|
+
exchange=self._exchange_id,
|
|
1018
|
+
timestamp=self._clock.timestamp_ms(),
|
|
1019
|
+
symbol=symbol,
|
|
1020
|
+
status=OrderStatus.CANCEL_FAILED,
|
|
1021
|
+
reason=error_msg,
|
|
1022
|
+
)
|
|
1023
|
+
except Exception as e:
|
|
1024
|
+
error_msg = f"{e.__class__.__name__}: {str(e)}"
|
|
1025
|
+
self._log.error(f"Error canceling order: {error_msg} params: {str(params)}")
|
|
1026
|
+
order = Order(
|
|
1027
|
+
oid=oid,
|
|
1028
|
+
exchange=self._exchange_id,
|
|
1029
|
+
timestamp=self._clock.timestamp_ms(),
|
|
1030
|
+
symbol=symbol,
|
|
1031
|
+
status=OrderStatus.CANCEL_FAILED,
|
|
1032
|
+
reason=error_msg,
|
|
1033
|
+
)
|
|
1034
|
+
self.order_status_update(order)
|
|
1035
|
+
return order
|
|
1036
|
+
|
|
1037
|
+
async def modify_order(
|
|
1038
|
+
self,
|
|
1039
|
+
oid: str,
|
|
1040
|
+
symbol: str,
|
|
1041
|
+
side: OrderSide | None = None,
|
|
1042
|
+
price: Decimal | None = None,
|
|
1043
|
+
amount: Decimal | None = None,
|
|
1044
|
+
**kwargs,
|
|
1045
|
+
) -> Order:
|
|
1046
|
+
# NOTE: modify order with side is not supported by OKX
|
|
1047
|
+
if price is None and amount is None:
|
|
1048
|
+
raise ValueError("Either price or amount must be provided")
|
|
1049
|
+
market = self._market.get(symbol)
|
|
1050
|
+
if not market:
|
|
1051
|
+
raise ValueError(f"Symbol {symbol} formated wrongly, or not supported")
|
|
1052
|
+
inst_id = market.id
|
|
1053
|
+
|
|
1054
|
+
if not market.spot:
|
|
1055
|
+
ct_val = Decimal(market.info.ctVal or "1") # contract size
|
|
1056
|
+
sz = format(amount / ct_val, "f") if amount else None
|
|
1057
|
+
else:
|
|
1058
|
+
sz = str(amount) if amount else None
|
|
1059
|
+
|
|
1060
|
+
params = {
|
|
1061
|
+
"instId": inst_id,
|
|
1062
|
+
"newPx": str(price) if price else None,
|
|
1063
|
+
"newSz": sz,
|
|
1064
|
+
"clOrdId": oid,
|
|
1065
|
+
**kwargs,
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
try:
|
|
1069
|
+
res = await self._api_client.post_api_v5_trade_amend_order(
|
|
1070
|
+
instId=inst_id,
|
|
1071
|
+
newPx=str(price) if price else None,
|
|
1072
|
+
newSz=sz,
|
|
1073
|
+
clOrdId=oid,
|
|
1074
|
+
**kwargs,
|
|
1075
|
+
)
|
|
1076
|
+
res = res.data[0]
|
|
1077
|
+
order = Order(
|
|
1078
|
+
exchange=self._exchange_id,
|
|
1079
|
+
eid=res.ordId,
|
|
1080
|
+
oid=oid,
|
|
1081
|
+
timestamp=int(res.ts),
|
|
1082
|
+
symbol=symbol,
|
|
1083
|
+
status=OrderStatus.PENDING,
|
|
1084
|
+
)
|
|
1085
|
+
except Exception as e:
|
|
1086
|
+
error_msg = f"{e.__class__.__name__}: {str(e)}"
|
|
1087
|
+
self._log.error(f"Error modifying order: {error_msg} params: {str(params)}")
|
|
1088
|
+
order = Order(
|
|
1089
|
+
exchange=self._exchange_id,
|
|
1090
|
+
oid=oid,
|
|
1091
|
+
timestamp=self._clock.timestamp_ms(),
|
|
1092
|
+
symbol=symbol,
|
|
1093
|
+
status=OrderStatus.FAILED,
|
|
1094
|
+
reason=error_msg,
|
|
1095
|
+
)
|
|
1096
|
+
self.order_status_update(order)
|
|
1097
|
+
return order
|
|
1098
|
+
|
|
1099
|
+
def _parse_order_data(
|
|
1100
|
+
self,
|
|
1101
|
+
*,
|
|
1102
|
+
symbol: str,
|
|
1103
|
+
market: OkxMarket,
|
|
1104
|
+
data,
|
|
1105
|
+
fallback_oid: str | None = None,
|
|
1106
|
+
) -> Order:
|
|
1107
|
+
if not market.spot:
|
|
1108
|
+
ct_val = Decimal(market.info.ctVal or "1")
|
|
1109
|
+
else:
|
|
1110
|
+
ct_val = Decimal("1")
|
|
1111
|
+
|
|
1112
|
+
reduce_only = data.reduceOnly
|
|
1113
|
+
if isinstance(reduce_only, str):
|
|
1114
|
+
reduce_only = reduce_only.lower() == "true"
|
|
1115
|
+
|
|
1116
|
+
return Order(
|
|
1117
|
+
exchange=self._exchange_id,
|
|
1118
|
+
symbol=symbol,
|
|
1119
|
+
status=OkxEnumParser.parse_order_status(data.state),
|
|
1120
|
+
eid=data.ordId,
|
|
1121
|
+
amount=Decimal(data.sz) * ct_val,
|
|
1122
|
+
filled=Decimal(data.accFillSz) * ct_val,
|
|
1123
|
+
oid=data.clOrdId or fallback_oid,
|
|
1124
|
+
timestamp=int(data.uTime),
|
|
1125
|
+
type=OkxEnumParser.parse_order_type(data.ordType),
|
|
1126
|
+
side=OkxEnumParser.parse_order_side(data.side),
|
|
1127
|
+
time_in_force=OkxEnumParser.parse_time_in_force(data.ordType),
|
|
1128
|
+
price=float(data.px) if data.px else None,
|
|
1129
|
+
average=float(data.avgPx) if data.avgPx else None,
|
|
1130
|
+
last_filled_price=float(data.fillPx) if data.fillPx else None,
|
|
1131
|
+
last_filled=Decimal(data.fillSz) * ct_val if data.fillSz else Decimal(0),
|
|
1132
|
+
remaining=Decimal(data.sz) * ct_val - Decimal(data.accFillSz) * ct_val,
|
|
1133
|
+
fee=Decimal(data.fee or "0"),
|
|
1134
|
+
fee_currency=data.feeCcy,
|
|
1135
|
+
cost=Decimal(data.avgPx or "0") * Decimal(data.fillSz or "0") * ct_val,
|
|
1136
|
+
cum_cost=Decimal(data.avgPx or "0")
|
|
1137
|
+
* Decimal(data.accFillSz or "0")
|
|
1138
|
+
* ct_val,
|
|
1139
|
+
reduce_only=reduce_only,
|
|
1140
|
+
position_side=OkxEnumParser.parse_position_side(data.posSide),
|
|
1141
|
+
)
|
|
1142
|
+
|
|
1143
|
+
async def query_order(self, oid: str, symbol: str) -> Order | None:
|
|
1144
|
+
market = self._market.get(symbol)
|
|
1145
|
+
if not market:
|
|
1146
|
+
return None
|
|
1147
|
+
try:
|
|
1148
|
+
res: OkxOrderResponse = await self._api_client.get_api_v5_trade_order(
|
|
1149
|
+
inst_id=market.id, cl_ord_id=oid
|
|
1150
|
+
)
|
|
1151
|
+
if not res.data:
|
|
1152
|
+
return None
|
|
1153
|
+
return self._parse_order_data(
|
|
1154
|
+
symbol=symbol,
|
|
1155
|
+
market=market,
|
|
1156
|
+
data=res.data[0],
|
|
1157
|
+
fallback_oid=oid,
|
|
1158
|
+
)
|
|
1159
|
+
except Exception as e:
|
|
1160
|
+
error_msg = f"{e.__class__.__name__}: {str(e)}"
|
|
1161
|
+
self._log.error(f"Error querying order: {error_msg}")
|
|
1162
|
+
return None
|
|
1163
|
+
|
|
1164
|
+
async def get_order(self, symbol: str, order_id: str):
|
|
1165
|
+
market = self._market.get(symbol)
|
|
1166
|
+
if not market:
|
|
1167
|
+
return None
|
|
1168
|
+
try:
|
|
1169
|
+
res: OkxOrderResponse = await self._api_client.get_api_v5_trade_order(
|
|
1170
|
+
inst_id=market.id, ord_id=order_id
|
|
1171
|
+
)
|
|
1172
|
+
if not res.data:
|
|
1173
|
+
return None
|
|
1174
|
+
|
|
1175
|
+
data = res.data[0]
|
|
1176
|
+
return self._parse_order_data(symbol=symbol, market=market, data=data)
|
|
1177
|
+
except Exception as e:
|
|
1178
|
+
error_msg = f"{e.__class__.__name__}: {str(e)}"
|
|
1179
|
+
self._log.error(f"Error querying order: {error_msg}")
|
|
1180
|
+
return None
|
|
1181
|
+
|
|
1182
|
+
async def cancel_all_orders(self, symbol: str) -> bool:
|
|
1183
|
+
"""
|
|
1184
|
+
OKX has no native cancel-all endpoint, so cancel cached open orders in batches.
|
|
1185
|
+
"""
|
|
1186
|
+
await self._cache.wait_for_inflight_orders(symbol)
|
|
1187
|
+
oids = self._cache.get_open_orders(symbol, include_canceling=True)
|
|
1188
|
+
if not oids:
|
|
1189
|
+
return True
|
|
1190
|
+
|
|
1191
|
+
orders = [
|
|
1192
|
+
CancelOrderSubmit(
|
|
1193
|
+
symbol=symbol,
|
|
1194
|
+
instrument_id=InstrumentId.from_str(symbol),
|
|
1195
|
+
oid=oid,
|
|
1196
|
+
)
|
|
1197
|
+
for oid in oids
|
|
1198
|
+
]
|
|
1199
|
+
return await self.cancel_batch_orders(orders)
|