siglab-py 0.1.0__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.
Potentially problematic release.
This version of siglab-py might be problematic. Click here for more details.
- siglab_py/__init__.py +0 -0
- siglab_py/constants.py +3 -0
- siglab_py/exchanges/__init__.py +0 -0
- siglab_py/exchanges/any_exchange.py +20 -0
- siglab_py/market_data_providers/__init__.py +0 -0
- siglab_py/market_data_providers/aggregated_orderbook_provider.py +451 -0
- siglab_py/market_data_providers/candles_provider.py +342 -0
- siglab_py/market_data_providers/candles_ta_provider.py +263 -0
- siglab_py/market_data_providers/deribit_options_expiry_provider.py +197 -0
- siglab_py/market_data_providers/orderbooks_provider.py +359 -0
- siglab_py/market_data_providers/test_provider.py +70 -0
- siglab_py/ordergateway/__init__.py +0 -0
- siglab_py/ordergateway/client.py +137 -0
- siglab_py/ordergateway/encrypt_keys_util.py +43 -0
- siglab_py/ordergateway/gateway.py +658 -0
- siglab_py/ordergateway/test_ordergateway.py +140 -0
- siglab_py/tests/__init__.py +0 -0
- siglab_py/tests/integration/__init__.py +0 -0
- siglab_py/tests/integration/market_data_util_tests.py +123 -0
- siglab_py/tests/unit/__init__.py +0 -0
- siglab_py/util/__init__.py +0 -0
- siglab_py/util/analytic_util.py +792 -0
- siglab_py/util/aws_util.py +47 -0
- siglab_py/util/market_data_util.py +385 -0
- siglab_py/util/retry_util.py +15 -0
- siglab_py-0.1.0.dist-info/METADATA +36 -0
- siglab_py-0.1.0.dist-info/RECORD +29 -0
- siglab_py-0.1.0.dist-info/WHEEL +5 -0
- siglab_py-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,658 @@
|
|
|
1
|
+
from ctypes import ArgumentError
|
|
2
|
+
import sys
|
|
3
|
+
import traceback
|
|
4
|
+
import os
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
from enum import Enum
|
|
7
|
+
import argparse
|
|
8
|
+
import time
|
|
9
|
+
from datetime import datetime, timedelta
|
|
10
|
+
from typing import List, Dict, Union, Any
|
|
11
|
+
import hashlib
|
|
12
|
+
from collections import deque
|
|
13
|
+
import logging
|
|
14
|
+
import json
|
|
15
|
+
from io import StringIO
|
|
16
|
+
import re
|
|
17
|
+
from re import Pattern
|
|
18
|
+
from redis import StrictRedis
|
|
19
|
+
import asyncio
|
|
20
|
+
|
|
21
|
+
from util.aws_util import AwsKmsUtil
|
|
22
|
+
|
|
23
|
+
import ccxt.pro as ccxtpro
|
|
24
|
+
|
|
25
|
+
from exchanges.any_exchange import AnyExchange
|
|
26
|
+
from ordergateway.client import Order, DivisiblePosition
|
|
27
|
+
|
|
28
|
+
'''
|
|
29
|
+
Usage:
|
|
30
|
+
python gateway.py --gateway_id bybit_01 --default_type linear --rate_limit_ms 100
|
|
31
|
+
|
|
32
|
+
--default_type defaults to linear
|
|
33
|
+
--rate_limit_ms defaults to 100
|
|
34
|
+
|
|
35
|
+
This script is pypy compatible:
|
|
36
|
+
pypy gateway.py --gateway_id bybit_01 --default_type linear --rate_limit_ms 100
|
|
37
|
+
|
|
38
|
+
In above example, $GATEWAY_ID$ is 'bybit_01'.
|
|
39
|
+
|
|
40
|
+
You should place .env.$GATEWAY_ID$ in same folder as gateway.py. Formmat should be.
|
|
41
|
+
ENCRYPT_DECRYPT_WITH_AWS_KMS=Y
|
|
42
|
+
AWS_KMS_KEY_ID=xxx
|
|
43
|
+
APIKEY=xxx
|
|
44
|
+
SECRET=xxx
|
|
45
|
+
PASSPHRASE=xxx
|
|
46
|
+
|
|
47
|
+
If ENCRYPT_DECRYPT_WITH_AWS_KMS set to N, APIKEY, SECRET and PASSPHRASE in un-encrypted format(Bad idea in general but if you want to quickly test things out).
|
|
48
|
+
If ENCRYPT_DECRYPT_WITH_AWS_KMS set to Y, APIKEY, SECRET and PASSPHRASE are decrypted using AWS KMS (You can use 'encrypt_keys_util.py' to encrypt your credentials)
|
|
49
|
+
|
|
50
|
+
Optionally, credentials can be passed in as command line arguments, which will override credentials from .env
|
|
51
|
+
python gateway.py --gateway_id bybit_01 --default_type linear --rate_limit_ms 100 --encrypt_decrypt_with_aws_kms Y --aws_kms_key_id xxx --apikey xxx --secret xxx --passphrase xxx
|
|
52
|
+
|
|
53
|
+
Please lookup 'defaultType' (Whether you're trading spot? Or perpectuals) via ccxt library. It's generally under exchange's method 'describe'. Looks under 'options' tag, look for 'defaultType'.
|
|
54
|
+
'Perpetual contracts' are generally referred to as 'linear' or 'swap'.
|
|
55
|
+
|
|
56
|
+
Examples,
|
|
57
|
+
binance spot, future, margin, delivery, option https://github.com/ccxt/ccxt/blob/master/python/ccxt/binance.py#L1298
|
|
58
|
+
Deribit spot, swap, future https://github.com/ccxt/ccxt/blob/master/python/ccxt/deribit.py#L360
|
|
59
|
+
bybit supports spot, linear, inverse, futures https://github.com/ccxt/ccxt/blob/master/python/ccxt/bybit.py#L1041
|
|
60
|
+
okx supports funding, spot, margin, future, swap, option https://github.com/ccxt/ccxt/blob/master/python/ccxt/okx.py#L1144
|
|
61
|
+
hyperliquid swap only https://github.com/ccxt/ccxt/blob/master/python/ccxt/hyperliquid.py#L225
|
|
62
|
+
|
|
63
|
+
To add exchange, extend "instantiate_exchange".
|
|
64
|
+
|
|
65
|
+
To debug from vscode, launch.json:
|
|
66
|
+
{
|
|
67
|
+
"version": "0.2.0",
|
|
68
|
+
"configurations": [
|
|
69
|
+
{
|
|
70
|
+
"name": "Python Debugger: Current File",
|
|
71
|
+
"type": "debugpy",
|
|
72
|
+
"request": "launch",
|
|
73
|
+
"program": "${file}",
|
|
74
|
+
"console": "integratedTerminal",
|
|
75
|
+
"args" : [
|
|
76
|
+
"--gateway_id", "bybit_01",
|
|
77
|
+
"--default_type", "linear",
|
|
78
|
+
"--rate_limit_ms", "100",
|
|
79
|
+
|
|
80
|
+
"--encrypt_decrypt_with_aws_kms", "N",
|
|
81
|
+
"--aws_kms_key_id", "",
|
|
82
|
+
"--apikey", "xxx",
|
|
83
|
+
"--secret", "xxx",
|
|
84
|
+
"--passphrase", "xxx"
|
|
85
|
+
],
|
|
86
|
+
"env": {
|
|
87
|
+
"PYTHONPATH": "${workspaceFolder}"
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
gateway.py takes orders from redis. Strategies should publish orders under topic specified under param['incoming_orders_topic_regex'].
|
|
94
|
+
|
|
95
|
+
Expected order format:
|
|
96
|
+
[
|
|
97
|
+
{
|
|
98
|
+
"ticker": "SUSHI/USDT:USDT",
|
|
99
|
+
"side": "sell",
|
|
100
|
+
"amount": 10,
|
|
101
|
+
"order_type": "limit",
|
|
102
|
+
"leg_room_bps": 5,
|
|
103
|
+
"slices": 5,
|
|
104
|
+
"wait_fill_threshold_ms": 15000,
|
|
105
|
+
"executions": {},
|
|
106
|
+
"filled_amount": 0,
|
|
107
|
+
"average_cost": 0
|
|
108
|
+
}
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
After executions, gateway.py publish back to redis under topic param['executions_publish_topic'].
|
|
112
|
+
|
|
113
|
+
Format:
|
|
114
|
+
[
|
|
115
|
+
{
|
|
116
|
+
"ticker": "SUSHI/USDT:USDT",
|
|
117
|
+
"side": "sell",
|
|
118
|
+
"amount": 10,
|
|
119
|
+
"order_type": "limit",
|
|
120
|
+
"leg_room_bps": 5,
|
|
121
|
+
"slices": 5,
|
|
122
|
+
"wait_fill_threshold_ms": 15000,
|
|
123
|
+
"executions": {
|
|
124
|
+
"xxx": { <-- order id from exchange
|
|
125
|
+
"info": { <-- ccxt convention, raw response from exchanges under info tag
|
|
126
|
+
...
|
|
127
|
+
},
|
|
128
|
+
"id": "xxx", <-- order id from exchange
|
|
129
|
+
"clientOrderId": "xxx",
|
|
130
|
+
"timestamp": xxx,
|
|
131
|
+
"datetime": "xxx",
|
|
132
|
+
"lastTradeTimestamp": xxx,
|
|
133
|
+
"lastUpdateTimestamp": xxx,
|
|
134
|
+
"symbol": "SUSHI/USDT:USDT",
|
|
135
|
+
"type": "limit",
|
|
136
|
+
"timeInForce": null,
|
|
137
|
+
"postOnly": null,
|
|
138
|
+
"side": "sell",
|
|
139
|
+
"price": 0.8897,
|
|
140
|
+
"stopLossPrice": null,
|
|
141
|
+
"takeProfitPrice": null,
|
|
142
|
+
"triggerPrice": null,
|
|
143
|
+
"average": 0.8901,
|
|
144
|
+
"cost": 1.7802,
|
|
145
|
+
"amount": 2,
|
|
146
|
+
"filled": 2,
|
|
147
|
+
"remaining": 0,
|
|
148
|
+
"status": "closed",
|
|
149
|
+
"fee": {
|
|
150
|
+
"cost": 0.00053406,
|
|
151
|
+
"currency": "USDT"
|
|
152
|
+
},
|
|
153
|
+
"trades": [],
|
|
154
|
+
"reduceOnly": false,
|
|
155
|
+
"fees": [
|
|
156
|
+
{
|
|
157
|
+
"cost": 0.00053406,
|
|
158
|
+
"currency": "USDT"
|
|
159
|
+
}
|
|
160
|
+
],
|
|
161
|
+
"stopPrice": null,
|
|
162
|
+
"multiplier": 1
|
|
163
|
+
},
|
|
164
|
+
"filled_amount": 10, <-- aggregates computed by gateway.py
|
|
165
|
+
"average_cost": 0.88979 <-- aggregates computed by gateway.py
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
... more executions ...
|
|
169
|
+
]
|
|
170
|
+
'''
|
|
171
|
+
class LogLevel(Enum):
|
|
172
|
+
CRITICAL = 50
|
|
173
|
+
ERROR = 40
|
|
174
|
+
WARNING = 30
|
|
175
|
+
INFO = 20
|
|
176
|
+
DEBUG = 10
|
|
177
|
+
NOTSET = 0
|
|
178
|
+
|
|
179
|
+
param : Dict = {
|
|
180
|
+
'gateway_id' : '---',
|
|
181
|
+
|
|
182
|
+
"incoming_orders_topic_regex" : r"ordergateway_pending_orders_$GATEWAY_ID$",
|
|
183
|
+
"executions_publish_topic" : r"ordergateway_executions_$GATEWAY_ID$",
|
|
184
|
+
|
|
185
|
+
"fetch_order_status_poll_freq_ms" : 500,
|
|
186
|
+
|
|
187
|
+
'mds' : {
|
|
188
|
+
'topics' : {
|
|
189
|
+
|
|
190
|
+
},
|
|
191
|
+
'redis' : {
|
|
192
|
+
'host' : 'localhost',
|
|
193
|
+
'port' : 6379,
|
|
194
|
+
'db' : 0,
|
|
195
|
+
'ttl_ms' : 1000*60*15 # 15 min?
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
logging.Formatter.converter = time.gmtime
|
|
201
|
+
logger = logging.getLogger()
|
|
202
|
+
log_level = logging.INFO # DEBUG --> INFO --> WARNING --> ERROR
|
|
203
|
+
logger.setLevel(log_level)
|
|
204
|
+
format_str = '%(asctime)s %(message)s'
|
|
205
|
+
formatter = logging.Formatter(format_str)
|
|
206
|
+
sh = logging.StreamHandler()
|
|
207
|
+
sh.setLevel(log_level)
|
|
208
|
+
sh.setFormatter(formatter)
|
|
209
|
+
logger.addHandler(sh)
|
|
210
|
+
|
|
211
|
+
def log(message : str, log_level : LogLevel = LogLevel.INFO):
|
|
212
|
+
if log_level.value<LogLevel.WARNING.value:
|
|
213
|
+
logger.info(f"{datetime.now()} {message}")
|
|
214
|
+
|
|
215
|
+
elif log_level.value==LogLevel.WARNING.value:
|
|
216
|
+
logger.warning(f"{datetime.now()} {message}")
|
|
217
|
+
|
|
218
|
+
elif log_level.value==LogLevel.ERROR.value:
|
|
219
|
+
logger.error(f"{datetime.now()} {message}")
|
|
220
|
+
|
|
221
|
+
def parse_args():
|
|
222
|
+
parser = argparse.ArgumentParser() # type: ignore
|
|
223
|
+
|
|
224
|
+
parser.add_argument("--gateway_id", help="gateway_id: Where are you sending your order?", default=None)
|
|
225
|
+
parser.add_argument("--default_type", help="default_type: spot, linear, inverse, futures ...etc", default='linear')
|
|
226
|
+
parser.add_argument("--rate_limit_ms", help="rate_limit_ms: Check your exchange rules", default=100)
|
|
227
|
+
|
|
228
|
+
parser.add_argument("--encrypt_decrypt_with_aws_kms", help="Y or N. If encrypt_decrypt_with_aws_kms=N, pass in apikey, secret and passphrase unencrypted (Not recommended, for testing only). If Y, they will be decrypted using AMS KMS key.", default='N')
|
|
229
|
+
parser.add_argument("--aws_kms_key_id", help="AWS KMS key ID", default=None)
|
|
230
|
+
parser.add_argument("--apikey", help="Exchange apikey", default=None)
|
|
231
|
+
parser.add_argument("--secret", help="Exchange secret", default=None)
|
|
232
|
+
parser.add_argument("--passphrase", help="Exchange passphrase", default=None)
|
|
233
|
+
|
|
234
|
+
args = parser.parse_args()
|
|
235
|
+
param['gateway_id'] = args.gateway_id
|
|
236
|
+
param['default_type'] = args.default_type
|
|
237
|
+
param['rate_limit_ms'] = int(args.rate_limit_ms)
|
|
238
|
+
|
|
239
|
+
if args.encrypt_decrypt_with_aws_kms:
|
|
240
|
+
if args.encrypt_decrypt_with_aws_kms=='Y':
|
|
241
|
+
param['encrypt_decrypt_with_aws_kms'] = True
|
|
242
|
+
else:
|
|
243
|
+
param['encrypt_decrypt_with_aws_kms'] = False
|
|
244
|
+
else:
|
|
245
|
+
param['encrypt_decrypt_with_aws_kms'] = False
|
|
246
|
+
|
|
247
|
+
param['aws_kms_key_id'] = args.aws_kms_key_id
|
|
248
|
+
param['apikey'] = args.apikey
|
|
249
|
+
param['secret'] = args.secret
|
|
250
|
+
param['passphrase'] = args.passphrase
|
|
251
|
+
|
|
252
|
+
def init_redis_client() -> StrictRedis:
|
|
253
|
+
redis_client : StrictRedis = StrictRedis(
|
|
254
|
+
host = param['mds']['redis']['host'],
|
|
255
|
+
port = param['mds']['redis']['port'],
|
|
256
|
+
db = 0,
|
|
257
|
+
ssl = False
|
|
258
|
+
)
|
|
259
|
+
try:
|
|
260
|
+
redis_client.keys()
|
|
261
|
+
except ConnectionError as redis_conn_error:
|
|
262
|
+
err_msg = f"Failed to connect to redis: {param['mds']['redis']['host']}, port: {param['mds']['redis']['port']}"
|
|
263
|
+
raise ConnectionError(err_msg)
|
|
264
|
+
|
|
265
|
+
return redis_client
|
|
266
|
+
|
|
267
|
+
def instantiate_exchange(
|
|
268
|
+
gateway_id : str,
|
|
269
|
+
api_key : str,
|
|
270
|
+
secret : str,
|
|
271
|
+
passphrase : str,
|
|
272
|
+
default_type : str,
|
|
273
|
+
rate_limit_ms : float = 100
|
|
274
|
+
) -> Union[AnyExchange, None]:
|
|
275
|
+
exchange : Union[AnyExchange, None] = None
|
|
276
|
+
exchange_name : str = gateway_id.split('_')[0]
|
|
277
|
+
exchange_name =exchange_name.lower().strip()
|
|
278
|
+
|
|
279
|
+
# Look at ccxt exchange.describe. under 'options' \ 'defaultType' (and 'defaultSubType') for what markets the exchange support.
|
|
280
|
+
# https://docs.ccxt.com/en/latest/manual.html#instantiation
|
|
281
|
+
exchange_params : Dict[str, Any]= {
|
|
282
|
+
'apiKey' : api_key,
|
|
283
|
+
'secret' : secret,
|
|
284
|
+
'enableRateLimit' : True,
|
|
285
|
+
'rateLimit' : rate_limit_ms,
|
|
286
|
+
'options' : {
|
|
287
|
+
'defaultType' : default_type
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if exchange_name=='binance':
|
|
292
|
+
# spot, future, margin, delivery, option
|
|
293
|
+
# https://github.com/ccxt/ccxt/blob/master/python/ccxt/binance.py#L1298
|
|
294
|
+
exchange = ccxtpro.binance(exchange_params) # type: ignore
|
|
295
|
+
elif exchange_name=='bybit':
|
|
296
|
+
# spot, linear, inverse, futures
|
|
297
|
+
# https://github.com/ccxt/ccxt/blob/master/python/ccxt/bybit.py#L1041
|
|
298
|
+
exchange = ccxtpro.bybit(exchange_params) # type: ignore
|
|
299
|
+
elif exchange_name=='okx':
|
|
300
|
+
# 'funding', spot, margin, future, swap, option
|
|
301
|
+
# https://github.com/ccxt/ccxt/blob/master/python/ccxt/okx.py#L1144
|
|
302
|
+
exchange_params['password'] = passphrase
|
|
303
|
+
exchange = ccxtpro.okx(exchange_params) # type: ignore
|
|
304
|
+
elif exchange_name=='deribit':
|
|
305
|
+
# spot, swap, future
|
|
306
|
+
# https://github.com/ccxt/ccxt/blob/master/python/ccxt/deribit.py#L360
|
|
307
|
+
exchange = ccxtpro.deribit(exchange_params) # type: ignore
|
|
308
|
+
elif exchange_name=='hyperliquid':
|
|
309
|
+
# swap
|
|
310
|
+
# https://github.com/ccxt/ccxt/blob/master/python/ccxt/hyperliquid.py#L225
|
|
311
|
+
exchange = ccxtpro.hyperliquid(exchange_params) # type: ignore
|
|
312
|
+
else:
|
|
313
|
+
raise ArgumentError(f"Unsupported exchange {exchange_name}, check gateway_id {gateway_id}.")
|
|
314
|
+
|
|
315
|
+
return exchange
|
|
316
|
+
|
|
317
|
+
async def watch_orders_task(
|
|
318
|
+
exchange : AnyExchange,
|
|
319
|
+
executions : Dict[str, Dict[str, Any]]
|
|
320
|
+
):
|
|
321
|
+
while True:
|
|
322
|
+
try:
|
|
323
|
+
order_updates = await exchange.watch_orders() # type: ignore
|
|
324
|
+
for order_update in order_updates:
|
|
325
|
+
order_id = order_update['id']
|
|
326
|
+
executions[order_id] = order_update
|
|
327
|
+
|
|
328
|
+
log(f"order updates: {order_updates}", log_level=LogLevel.INFO)
|
|
329
|
+
except Exception as loop_err:
|
|
330
|
+
print(f"watch_orders_task error: {loop_err}")
|
|
331
|
+
|
|
332
|
+
await asyncio.sleep(int(param['fetch_order_status_poll_freq_ms']/1000))
|
|
333
|
+
|
|
334
|
+
async def execute_one_position(
|
|
335
|
+
exchange : AnyExchange,
|
|
336
|
+
position : DivisiblePosition,
|
|
337
|
+
param : Dict,
|
|
338
|
+
executions : Dict[str, Dict[str, Any]]
|
|
339
|
+
):
|
|
340
|
+
await exchange.load_markets() # type: ignore
|
|
341
|
+
try:
|
|
342
|
+
await exchange.authenticate() # type: ignore
|
|
343
|
+
except Exception as swallow_this_error:
|
|
344
|
+
pass # @todo, perhaps a better way for handling this?
|
|
345
|
+
|
|
346
|
+
market : Dict[str, Any] = exchange.markets[position.ticker] if position.ticker in exchange.markets else None # type: ignore
|
|
347
|
+
if not market:
|
|
348
|
+
raise ArgumentError(f"Market not found for {position.ticker} under {exchange.name}") # type: ignore
|
|
349
|
+
|
|
350
|
+
min_amount = float(market['limits']['amount']['min']) # type: ignore
|
|
351
|
+
multiplier = market['contractSize'] if 'contractSize' in market and market['contractSize'] else 1
|
|
352
|
+
position.multiplier = multiplier
|
|
353
|
+
|
|
354
|
+
slices : List[Order] = position.to_slices()
|
|
355
|
+
i = 0
|
|
356
|
+
for slice in slices:
|
|
357
|
+
try:
|
|
358
|
+
slice_amount_in_base_ccy : float = slice.amount
|
|
359
|
+
rounded_slice_amount_in_base_ccy = slice_amount_in_base_ccy / multiplier # After devided by multiplier, rounded_slice_amount_in_base_ccy in number of contracts actually (Not in base ccy).
|
|
360
|
+
rounded_slice_amount_in_base_ccy = exchange.amount_to_precision(position.ticker, rounded_slice_amount_in_base_ccy) # type: ignore
|
|
361
|
+
rounded_slice_amount_in_base_ccy = float(rounded_slice_amount_in_base_ccy)
|
|
362
|
+
rounded_slice_amount_in_base_ccy = rounded_slice_amount_in_base_ccy if rounded_slice_amount_in_base_ccy>min_amount else min_amount
|
|
363
|
+
|
|
364
|
+
limit_price : float = 0
|
|
365
|
+
rounded_limit_price : float = 0
|
|
366
|
+
if position.order_type=='limit':
|
|
367
|
+
orderbook = await exchange.fetch_order_book(symbol=position.ticker, limit=3) # type: ignore
|
|
368
|
+
if position.side=='buy':
|
|
369
|
+
asks = [ ask[0] for ask in orderbook['asks'] ]
|
|
370
|
+
best_asks = min(asks)
|
|
371
|
+
limit_price = best_asks * (1 + position.leg_room_bps/10000)
|
|
372
|
+
else:
|
|
373
|
+
bids = [ bid[0] for bid in orderbook['bids'] ]
|
|
374
|
+
best_bid = max(bids)
|
|
375
|
+
limit_price = best_bid * (1 - position.leg_room_bps/10000)
|
|
376
|
+
|
|
377
|
+
rounded_limit_price = exchange.price_to_precision(position.ticker, limit_price) # type: ignore
|
|
378
|
+
rounded_limit_price = float(rounded_limit_price)
|
|
379
|
+
|
|
380
|
+
executed_order = await exchange.create_order( # type: ignore
|
|
381
|
+
symbol = position.ticker,
|
|
382
|
+
type = position.order_type,
|
|
383
|
+
amount = rounded_slice_amount_in_base_ccy,
|
|
384
|
+
price = rounded_limit_price,
|
|
385
|
+
side = position.side
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
else:
|
|
389
|
+
executed_order = await exchange.create_order( # type: ignore
|
|
390
|
+
symbol = position.ticker,
|
|
391
|
+
type = position.order_type,
|
|
392
|
+
amount = rounded_slice_amount_in_base_ccy,
|
|
393
|
+
side = position.side
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
executed_order['slice_id'] = i
|
|
397
|
+
|
|
398
|
+
'''
|
|
399
|
+
Format of executed_order:
|
|
400
|
+
executed_order
|
|
401
|
+
{'info': {'clOrdId': 'xxx', 'ordId': '2245241151525347328', 'sCode': '0', 'sMsg': 'Order placed', 'tag': 'xxx', 'ts': '1739415800635'}, 'id': '2245241151525347328', 'clientOrderId': 'xxx', 'timestamp': None, 'datetime': None, 'lastTradeTimestamp': None, 'lastUpdateTimestamp': None, 'symbol': 'SUSHI/USDT:USDT', 'type': 'limit', 'timeInForce': None, 'postOnly': None, 'side': 'buy', 'price': None, 'stopLossPrice': None, 'takeProfitPrice': None, 'triggerPrice': None, 'average': None, 'cost': None, 'amount': None, 'filled': None, 'remaining': None, 'status': None, 'fee': None, 'trades': [], 'reduceOnly': False, 'fees': [], 'stopPrice': None}
|
|
402
|
+
special variables:
|
|
403
|
+
function variables:
|
|
404
|
+
'info': {'clOrdId': 'xxx', 'ordId': '2245241151525347328', 'sCode': '0', 'sMsg': 'Order placed', 'tag': 'xxx', 'ts': '1739415800635'}
|
|
405
|
+
'id': '2245241151525347328'
|
|
406
|
+
'clientOrderId': 'xxx'
|
|
407
|
+
'timestamp': None
|
|
408
|
+
'datetime': None
|
|
409
|
+
'lastTradeTimestamp': None
|
|
410
|
+
'lastUpdateTimestamp': None
|
|
411
|
+
'symbol': 'SUSHI/USDT:USDT'
|
|
412
|
+
'type': 'limit'
|
|
413
|
+
'timeInForce': None
|
|
414
|
+
'postOnly': None
|
|
415
|
+
'side': 'buy'
|
|
416
|
+
'price': None
|
|
417
|
+
'stopLossPrice': None
|
|
418
|
+
'takeProfitPrice': None
|
|
419
|
+
'triggerPrice': None
|
|
420
|
+
'average': None
|
|
421
|
+
'cost': None
|
|
422
|
+
'amount': None
|
|
423
|
+
'filled': None
|
|
424
|
+
'remaining': None
|
|
425
|
+
'status': None
|
|
426
|
+
'fee': None
|
|
427
|
+
'trades': []
|
|
428
|
+
'reduceOnly': False
|
|
429
|
+
'fees': []
|
|
430
|
+
'stopPrice': None
|
|
431
|
+
'''
|
|
432
|
+
order_id = executed_order['id']
|
|
433
|
+
order_status = executed_order['status']
|
|
434
|
+
filled_amount = executed_order['filled']
|
|
435
|
+
remaining_amount = executed_order['remaining']
|
|
436
|
+
executed_order['multiplier'] = multiplier
|
|
437
|
+
position.append_execution(order_id, executed_order)
|
|
438
|
+
|
|
439
|
+
log(f"Order dispatched: {order_id}. status: {order_status}, filled_amount: {filled_amount}, remaining_amount: {remaining_amount}")
|
|
440
|
+
|
|
441
|
+
if not order_status or order_status!='closed':
|
|
442
|
+
start_time = time.time()
|
|
443
|
+
wait_threshold_sec = position.wait_fill_threshold_ms / 1000
|
|
444
|
+
|
|
445
|
+
elapsed_sec = time.time() - start_time
|
|
446
|
+
while elapsed_sec < wait_threshold_sec:
|
|
447
|
+
order_update = None
|
|
448
|
+
if order_id in executions:
|
|
449
|
+
order_update = executions[order_id]
|
|
450
|
+
|
|
451
|
+
if order_update:
|
|
452
|
+
order_status = order_update['status']
|
|
453
|
+
filled_amount = order_update['filled']
|
|
454
|
+
remaining_amount = order_update['remaining']
|
|
455
|
+
order_update['multiplier'] = multiplier
|
|
456
|
+
position.append_execution(order_id, order_update)
|
|
457
|
+
|
|
458
|
+
if remaining_amount <= 0:
|
|
459
|
+
log(f"Limit order fully filled: {order_id}", log_level=LogLevel.INFO)
|
|
460
|
+
break
|
|
461
|
+
|
|
462
|
+
await asyncio.sleep(int(param['fetch_order_status_poll_freq_ms']/1000))
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
# Cancel hung limit order, resend as market
|
|
467
|
+
if order_status!='closed':
|
|
468
|
+
# If no update from websocket, do one last fetch via REST
|
|
469
|
+
order_update = await exchange.fetch_order(order_id, position.ticker) # type: ignore
|
|
470
|
+
order_status = order_update['status']
|
|
471
|
+
filled_amount = order_update['filled']
|
|
472
|
+
remaining_amount = order_update['remaining']
|
|
473
|
+
order_update['multiplier'] = multiplier
|
|
474
|
+
position.append_execution(order_id, order_update)
|
|
475
|
+
|
|
476
|
+
if order_status!='closed':
|
|
477
|
+
order_status = order_update['status']
|
|
478
|
+
filled_amount = order_update['filled']
|
|
479
|
+
remaining_amount = order_update['remaining']
|
|
480
|
+
|
|
481
|
+
await exchange.cancel_order(order_id, position.ticker) # type: ignore
|
|
482
|
+
position.get_execution(order_id)['status'] = 'canceled'
|
|
483
|
+
log(f"Canceled unfilled/partial filled order: {order_id}. Resending remaining_amount: {remaining_amount} as market order.", log_level=LogLevel.INFO)
|
|
484
|
+
|
|
485
|
+
rounded_slice_amount_in_base_ccy = exchange.amount_to_precision(position.ticker, remaining_amount) # type: ignore
|
|
486
|
+
rounded_slice_amount_in_base_ccy = float(rounded_slice_amount_in_base_ccy)
|
|
487
|
+
rounded_slice_amount_in_base_ccy = rounded_slice_amount_in_base_ccy if rounded_slice_amount_in_base_ccy>min_amount else min_amount
|
|
488
|
+
if rounded_slice_amount_in_base_ccy>0:
|
|
489
|
+
executed_resent_order = await exchange.create_order( # type: ignore
|
|
490
|
+
symbol=position.ticker,
|
|
491
|
+
type='market',
|
|
492
|
+
amount=remaining_amount,
|
|
493
|
+
side=position.side
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
order_id = executed_resent_order['id']
|
|
497
|
+
order_status = executed_resent_order['status']
|
|
498
|
+
executed_resent_order['multiplier'] = multiplier
|
|
499
|
+
position.append_execution(order_id, executed_resent_order)
|
|
500
|
+
|
|
501
|
+
while not order_status or order_status!='closed':
|
|
502
|
+
order_update = None
|
|
503
|
+
if order_id in executions:
|
|
504
|
+
order_update = executions[order_id]
|
|
505
|
+
|
|
506
|
+
if order_update:
|
|
507
|
+
order_id = order_update['id']
|
|
508
|
+
order_status = order_update['status']
|
|
509
|
+
filled_amount = order_update['filled']
|
|
510
|
+
remaining_amount = order_update['remaining']
|
|
511
|
+
|
|
512
|
+
log(f"Waiting for resent market order to close {order_id} ...")
|
|
513
|
+
|
|
514
|
+
await asyncio.sleep(int(param['fetch_order_status_poll_freq_ms']/1000))
|
|
515
|
+
|
|
516
|
+
log(f"Resent market order{order_id} filled. status: {order_status}, filled_amount: {filled_amount}, remaining_amount: {remaining_amount}")
|
|
517
|
+
|
|
518
|
+
log(f"Executed slice #{i}", log_level=LogLevel.INFO)
|
|
519
|
+
log(f"{position.ticker}, multiplier: {multiplier}, slice_amount_in_base_ccy: {slice_amount_in_base_ccy}, rounded_slice_amount_in_base_ccy, {rounded_slice_amount_in_base_ccy}", log_level=LogLevel.INFO)
|
|
520
|
+
if position.order_type=='limit':
|
|
521
|
+
log(f"{position.ticker}, limit_price: {limit_price}, rounded_limit_price, {rounded_limit_price}", log_level=LogLevel.INFO)
|
|
522
|
+
|
|
523
|
+
except Exception as slice_err:
|
|
524
|
+
log(
|
|
525
|
+
f"Failed to execute #{i} slice: {slice.to_dict()}. {slice_err} {str(sys.exc_info()[0])} {str(sys.exc_info()[1])} {traceback.format_exc()}",
|
|
526
|
+
log_level=LogLevel.ERROR
|
|
527
|
+
)
|
|
528
|
+
finally:
|
|
529
|
+
i += 1
|
|
530
|
+
|
|
531
|
+
async def work(
|
|
532
|
+
param : Dict,
|
|
533
|
+
exchange : AnyExchange,
|
|
534
|
+
redis_client : StrictRedis
|
|
535
|
+
):
|
|
536
|
+
incoming_orders_topic_regex : str = param['incoming_orders_topic_regex']
|
|
537
|
+
incoming_orders_topic_regex = incoming_orders_topic_regex.replace("$GATEWAY_ID$", param['gateway_id'])
|
|
538
|
+
incoming_orders_topic_regex_pattern : Pattern = re.compile(incoming_orders_topic_regex)
|
|
539
|
+
|
|
540
|
+
executions_publish_topic : str = param['executions_publish_topic'].replace("$GATEWAY_ID$", param['gateway_id'])
|
|
541
|
+
|
|
542
|
+
# This is how we avoid reprocess same message twice. We check message hash and cache it.
|
|
543
|
+
processed_hash_queue = deque(maxlen=10)
|
|
544
|
+
|
|
545
|
+
executions : Dict[str, Dict[str, Any]] = {}
|
|
546
|
+
asyncio.create_task(watch_orders_task(exchange, executions))
|
|
547
|
+
|
|
548
|
+
while True:
|
|
549
|
+
try:
|
|
550
|
+
keys = redis_client.keys()
|
|
551
|
+
for key in keys:
|
|
552
|
+
try:
|
|
553
|
+
s_key : str = key.decode("utf-8")
|
|
554
|
+
if incoming_orders_topic_regex_pattern.match(s_key):
|
|
555
|
+
orders = None
|
|
556
|
+
message = redis_client.get(key)
|
|
557
|
+
if message:
|
|
558
|
+
message_hash = hashlib.sha256(message).hexdigest()
|
|
559
|
+
message = message.decode('utf-8')
|
|
560
|
+
if message_hash not in processed_hash_queue: # Dont process what's been processed before.
|
|
561
|
+
processed_hash_queue.append(message_hash)
|
|
562
|
+
|
|
563
|
+
orders = json.loads(message)
|
|
564
|
+
positions : List[DivisiblePosition] = [
|
|
565
|
+
DivisiblePosition(
|
|
566
|
+
ticker=order['ticker'],
|
|
567
|
+
side=order['side'],
|
|
568
|
+
amount=order['amount'],
|
|
569
|
+
order_type=order['order_type'],
|
|
570
|
+
leg_room_bps=order['leg_room_bps'],
|
|
571
|
+
slices=order['slices'],
|
|
572
|
+
wait_fill_threshold_ms=order['wait_fill_threshold_ms']
|
|
573
|
+
)
|
|
574
|
+
for order in orders
|
|
575
|
+
]
|
|
576
|
+
|
|
577
|
+
start = time.time()
|
|
578
|
+
pending_executions = [ execute_one_position(exchange, position, param, executions) for position in positions ]
|
|
579
|
+
await asyncio.gather(*pending_executions)
|
|
580
|
+
order_dispatch_elapsed_ms = int((time.time() - start) *1000)
|
|
581
|
+
|
|
582
|
+
i = 0
|
|
583
|
+
for position in positions:
|
|
584
|
+
log(f"{i} {position.ticker}, {position.side} # executions: {len(position.get_executions())}, filled_amount: {position.get_filled_amount()}, average_cost: {position.get_average_cost()}, order_dispatch_elapsed_ms: {order_dispatch_elapsed_ms}")
|
|
585
|
+
i += 1
|
|
586
|
+
|
|
587
|
+
start = time.time()
|
|
588
|
+
if redis_client:
|
|
589
|
+
redis_client.delete(key)
|
|
590
|
+
|
|
591
|
+
'''
|
|
592
|
+
https://redis.io/commands/set/
|
|
593
|
+
'''
|
|
594
|
+
expiry_sec : int = 60*15 # additional 15min
|
|
595
|
+
_positions = [ position.to_dict() for position in positions ]
|
|
596
|
+
redis_client.set(name=executions_publish_topic, value=json.dumps(_positions).encode('utf-8'), ex=60*15)
|
|
597
|
+
redis_set_elapsed_ms = int((time.time() - start) *1000)
|
|
598
|
+
|
|
599
|
+
log(f"positions published back to redis, redis_set_elapsed_ms: {redis_set_elapsed_ms}")
|
|
600
|
+
|
|
601
|
+
except Exception as key_error:
|
|
602
|
+
log(
|
|
603
|
+
f"Failed to process {key}. Error: {key_error} {str(sys.exc_info()[0])} {str(sys.exc_info()[1])} {traceback.format_exc()}",
|
|
604
|
+
log_level=LogLevel.ERROR
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
except Exception as loop_error:
|
|
608
|
+
log(f"Error: {loop_error} {str(sys.exc_info()[0])} {str(sys.exc_info()[1])} {traceback.format_exc()}")
|
|
609
|
+
|
|
610
|
+
async def main():
|
|
611
|
+
parse_args()
|
|
612
|
+
|
|
613
|
+
if not param['apikey']:
|
|
614
|
+
log("Loading credentials from .env")
|
|
615
|
+
|
|
616
|
+
load_dotenv()
|
|
617
|
+
|
|
618
|
+
encrypt_decrypt_with_aws_kms = os.getenv('ENCRYPT_DECRYPT_WITH_AWS_KMS')
|
|
619
|
+
encrypt_decrypt_with_aws_kms = True if encrypt_decrypt_with_aws_kms=='Y' else False
|
|
620
|
+
|
|
621
|
+
api_key : str = str(os.getenv('APIKEY'))
|
|
622
|
+
secret : str = str(os.getenv('SECRET'))
|
|
623
|
+
passphrase : str = str(os.getenv('PASSPHRASE'))
|
|
624
|
+
else:
|
|
625
|
+
log("Loading credentials from command line args")
|
|
626
|
+
|
|
627
|
+
encrypt_decrypt_with_aws_kms = param['encrypt_decrypt_with_aws_kms']
|
|
628
|
+
api_key : str = param['apikey']
|
|
629
|
+
secret : str = param['secret']
|
|
630
|
+
passphrase : str = param['passphrase']
|
|
631
|
+
|
|
632
|
+
if encrypt_decrypt_with_aws_kms:
|
|
633
|
+
aws_kms_key_id = str(os.getenv('AWS_KMS_KEY_ID'))
|
|
634
|
+
|
|
635
|
+
aws_kms = AwsKmsUtil(key_id=aws_kms_key_id, profile_name=None)
|
|
636
|
+
api_key = aws_kms.decrypt(api_key.encode())
|
|
637
|
+
secret = aws_kms.decrypt(secret.encode())
|
|
638
|
+
if passphrase:
|
|
639
|
+
passphrase = aws_kms.decrypt(passphrase.encode())
|
|
640
|
+
|
|
641
|
+
redis_client : StrictRedis = init_redis_client()
|
|
642
|
+
|
|
643
|
+
exchange : Union[AnyExchange, None] = instantiate_exchange(
|
|
644
|
+
gateway_id=param['gateway_id'],
|
|
645
|
+
api_key=api_key,
|
|
646
|
+
secret=secret,
|
|
647
|
+
passphrase=passphrase,
|
|
648
|
+
default_type=param['default_type'],
|
|
649
|
+
rate_limit_ms=param['rate_limit_ms']
|
|
650
|
+
)
|
|
651
|
+
if exchange:
|
|
652
|
+
# Once exchange instantiated, try fetch_balance to confirm connectivity and test credentials.
|
|
653
|
+
balances = await exchange.fetch_balance() # type: ignore
|
|
654
|
+
log(f"{param['gateway_id']}: account balances {balances}")
|
|
655
|
+
|
|
656
|
+
await work(param=param, exchange=exchange, redis_client=redis_client)
|
|
657
|
+
|
|
658
|
+
asyncio.run(main())
|