siglab-py 0.5.30__py3-none-any.whl → 0.6.12__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/constants.py +5 -0
- siglab_py/exchanges/binance.py +38 -0
- siglab_py/exchanges/deribit.py +83 -0
- siglab_py/exchanges/futubull.py +11 -2
- siglab_py/market_data_providers/candles_provider.py +2 -2
- siglab_py/market_data_providers/candles_ta_provider.py +3 -3
- siglab_py/market_data_providers/futu_candles_ta_to_csv.py +6 -4
- siglab_py/market_data_providers/google_monitor.py +320 -0
- siglab_py/market_data_providers/orderbooks_provider.py +15 -12
- siglab_py/market_data_providers/tg_monitor.py +6 -2
- siglab_py/market_data_providers/{test_provider.py → trigger_provider.py} +9 -8
- siglab_py/ordergateway/encrypt_keys_util.py +1 -1
- siglab_py/ordergateway/gateway.py +97 -35
- siglab_py/tests/integration/market_data_util_tests.py +34 -0
- siglab_py/tests/unit/analytic_util_tests.py +37 -10
- siglab_py/tests/unit/simple_math_tests.py +235 -0
- siglab_py/tests/unit/trading_util_tests.py +0 -21
- siglab_py/util/analytic_util.py +195 -33
- siglab_py/util/market_data_util.py +177 -59
- siglab_py/util/notification_util.py +1 -1
- siglab_py/util/simple_math.py +240 -0
- siglab_py/util/trading_util.py +0 -12
- {siglab_py-0.5.30.dist-info → siglab_py-0.6.12.dist-info}/METADATA +1 -1
- siglab_py-0.6.12.dist-info/RECORD +44 -0
- {siglab_py-0.5.30.dist-info → siglab_py-0.6.12.dist-info}/WHEEL +1 -1
- siglab_py-0.5.30.dist-info/RECORD +0 -39
- {siglab_py-0.5.30.dist-info → siglab_py-0.6.12.dist-info}/top_level.txt +0 -0
|
@@ -12,20 +12,25 @@ from redis.client import PubSub
|
|
|
12
12
|
|
|
13
13
|
'''
|
|
14
14
|
set PYTHONPATH=%PYTHONPATH%;D:\dev\siglab\siglab_py
|
|
15
|
-
python
|
|
15
|
+
python trigger_provider.py --provider_id aaa --tickers BTC/USDC:USDC,ETH/USDC:USDC,SOL/USDC:USDC
|
|
16
16
|
'''
|
|
17
17
|
|
|
18
|
-
param : Dict[str, str] = {
|
|
18
|
+
param : Dict[str, str|List[str]] = {
|
|
19
19
|
'provider_id' : '---'
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
def parse_args():
|
|
23
23
|
parser = argparse.ArgumentParser() # type: ignore
|
|
24
24
|
parser.add_argument("--provider_id", help="candle_provider will go to work if from redis a matching topic partition_assign_topic with provider_id in it.", default=None)
|
|
25
|
+
parser.add_argument("--tickers", help="Ticker(s) you're trading, comma separated list. Example BTC/USDC:USDC,ETH/USDC:USDC,SOL/USDC:USDC", default=None)
|
|
25
26
|
|
|
26
27
|
args = parser.parse_args()
|
|
27
28
|
param['provider_id'] = args.provider_id
|
|
28
29
|
|
|
30
|
+
tickers = args.tickers.split(',')
|
|
31
|
+
assert(len(tickers)>0)
|
|
32
|
+
param['tickers'] = [ ticker.strip() for ticker in tickers ]
|
|
33
|
+
|
|
29
34
|
def init_redis_client() -> StrictRedis:
|
|
30
35
|
redis_client : StrictRedis = StrictRedis(
|
|
31
36
|
host = 'localhost',
|
|
@@ -51,16 +56,12 @@ def trigger_producers(
|
|
|
51
56
|
if __name__ == '__main__':
|
|
52
57
|
parse_args()
|
|
53
58
|
|
|
54
|
-
provider_id : str = param['provider_id']
|
|
59
|
+
provider_id : str = param['provider_id'] # type: ignore
|
|
55
60
|
partition_assign_topic = 'mds_assign_$PROVIDER_ID$'
|
|
56
61
|
candles_partition_assign_topic = partition_assign_topic.replace("$PROVIDER_ID$", provider_id)
|
|
57
62
|
redis_client : StrictRedis = init_redis_client()
|
|
58
63
|
|
|
59
|
-
exchange_tickers : List[str] = [
|
|
60
|
-
'okx_linear|BTC/USDT:USDT',
|
|
61
|
-
'okx_linear|ETH/USDT:USDT',
|
|
62
|
-
'okx_linear|SOL/USDT:USDT',
|
|
63
|
-
]
|
|
64
|
+
exchange_tickers : List[str] = param['tickers'] # type: ignore
|
|
64
65
|
trigger_producers(
|
|
65
66
|
redis_client=redis_client,
|
|
66
67
|
exchange_tickers=exchange_tickers,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
# type: ignore
|
|
1
2
|
import sys
|
|
2
3
|
import traceback
|
|
3
4
|
import os
|
|
@@ -19,15 +20,15 @@ from re import Pattern
|
|
|
19
20
|
from redis import StrictRedis
|
|
20
21
|
import asyncio
|
|
21
22
|
|
|
22
|
-
from util.aws_util import AwsKmsUtil
|
|
23
|
-
|
|
24
23
|
import ccxt.pro as ccxtpro
|
|
25
24
|
|
|
25
|
+
from siglab_py.util.retry_util import retry
|
|
26
|
+
from siglab_py.util.aws_util import AwsKmsUtil
|
|
26
27
|
from siglab_py.exchanges.any_exchange import AnyExchange
|
|
27
|
-
from util.market_data_util import async_instantiate_exchange
|
|
28
|
+
from siglab_py.util.market_data_util import async_instantiate_exchange
|
|
28
29
|
from siglab_py.ordergateway.client import Order, DivisiblePosition
|
|
29
|
-
from siglab_py.constants import LogLevel
|
|
30
|
-
from util.notification_util import dispatch_notification
|
|
30
|
+
from siglab_py.constants import LogLevel
|
|
31
|
+
from siglab_py.util.notification_util import dispatch_notification
|
|
31
32
|
|
|
32
33
|
current_filename = os.path.basename(__file__)
|
|
33
34
|
|
|
@@ -41,14 +42,17 @@ if sys.platform == 'win32':
|
|
|
41
42
|
'''
|
|
42
43
|
Usage:
|
|
43
44
|
set PYTHONPATH=%PYTHONPATH%;D:\dev\siglab\siglab_py
|
|
44
|
-
python gateway.py --gateway_id hyperliquid_01 --default_type linear --rate_limit_ms 100 --encrypt_decrypt_with_aws_kms Y --aws_kms_key_id xxx --apikey xxx --secret xxxx --slack_info_url=https://hooks.slack.com/services/xxx --slack_critial_url=https://hooks.slack.com/services/xxx --slack_alert_url=https://hooks.slack.com/services/xxx
|
|
45
|
+
python gateway.py --gateway_id hyperliquid_01 --default_type linear --rate_limit_ms 100 --encrypt_decrypt_with_aws_kms Y --aws_kms_key_id xxx --apikey xxx --secret xxxx --verbose N --slack_info_url=https://hooks.slack.com/services/xxx --slack_critial_url=https://hooks.slack.com/services/xxx --slack_alert_url=https://hooks.slack.com/services/xxx
|
|
45
46
|
|
|
46
47
|
--default_type defaults to linear
|
|
48
|
+
--default_sub_type defaults to None (Depends on your exchange/broker, if they requires this)
|
|
47
49
|
--rate_limit_ms defaults to 100
|
|
48
50
|
--encrypt_decrypt_with_aws_kms If you encrypt apikey and secret using AMS KMS, then set to Y. If apikey and secret in unencrypted plain text, set to N.
|
|
49
51
|
--passphrase is optional, this depends on the exchange.
|
|
52
|
+
--verbose logging verbosity, Y or N (default)
|
|
50
53
|
--gateway_id contains two parts separated by underscore. Gateway.py will parse 'hyperliquid_01' into two parts: 'hyperliquid' (Exchange name) and '01' (Use this for your sub account ID). Exchange name need be spelt exactly. Please have a look at market_data_util async_instantiate_exchange.
|
|
51
54
|
--slack_info_url, --slack_critical_url and --slack_alert_url are if you want gateway to dispatch Slack notification on events.
|
|
55
|
+
--order_amount_randomize_max_pct adds small variance to sliced order amount (Default max 10% on sliced amount) to cover your track in order executions, this is useful especially when executing bigger orders during quieter hours.
|
|
52
56
|
How to get Slack webhook urls? https://medium.com/@natalia_assad/how-send-a-table-to-slack-using-python-d1a20b08abe0
|
|
53
57
|
|
|
54
58
|
Another example:
|
|
@@ -106,6 +110,7 @@ To debug from vscode, launch.json:
|
|
|
106
110
|
"--apikey", "xxx",
|
|
107
111
|
"--secret", "xxx",
|
|
108
112
|
"--passphrase", "xxx",
|
|
113
|
+
"--verbose", "N",
|
|
109
114
|
|
|
110
115
|
"--slack_info_url", "https://hooks.slack.com/services/xxx",
|
|
111
116
|
"--slack_critial_url", "https://hooks.slack.com/services/xxx",
|
|
@@ -212,6 +217,7 @@ param : Dict = {
|
|
|
212
217
|
"executions_publish_topic" : r"ordergateway_executions_$GATEWAY_ID$",
|
|
213
218
|
|
|
214
219
|
"default_fees_ccy" : None,
|
|
220
|
+
"order_amount_randomize_max_pct" : 0,
|
|
215
221
|
"loop_freq_ms" : 500, # reduce this if you need trade faster
|
|
216
222
|
"loops_random_delay_multiplier" : 1, # Add randomness to time between slices are sent off. Set to 1 if no random delay needed.
|
|
217
223
|
"wait_fill_threshold_ms" : 5000,
|
|
@@ -293,14 +299,16 @@ def log(message : str, log_level : LogLevel = LogLevel.INFO):
|
|
|
293
299
|
logger.error(f"{datetime.now()} {message}")
|
|
294
300
|
|
|
295
301
|
def parse_args():
|
|
296
|
-
parser = argparse.ArgumentParser()
|
|
302
|
+
parser = argparse.ArgumentParser()
|
|
297
303
|
|
|
298
304
|
parser.add_argument("--gateway_id", help="gateway_id: Where are you sending your order?", default=None)
|
|
299
305
|
parser.add_argument("--dry_run", help="Y or N (default, for testing). If Y, orders won't be dispatched to exchange, gateway will fake reply.", default='N')
|
|
300
306
|
parser.add_argument("--default_type", help="default_type: spot, linear, inverse, futures ...etc", default='linear')
|
|
307
|
+
parser.add_argument("--default_sub_type", help="default_sub_type", default=None)
|
|
301
308
|
parser.add_argument("--rate_limit_ms", help="rate_limit_ms: Check your exchange rules", default=100)
|
|
302
309
|
|
|
303
310
|
parser.add_argument("--default_fees_ccy", help="If you're trading crypto, CEX fees USDT, DEX fees USDC in many cases. Default None, in which case gateway won't aggregatge fees from executions for you.", default=None)
|
|
311
|
+
parser.add_argument("--order_amount_randomize_max_pct", help="Randomize order amount to hide your track? This is max percentage variance applied on sliced amount (not entire order amount)", default=10)
|
|
304
312
|
parser.add_argument("--loop_freq_ms", help="Loop delays. Reduce this if you want to trade faster.", default=500)
|
|
305
313
|
parser.add_argument("--wait_fill_threshold_ms", help="Wait for fills for how long?", default=5000)
|
|
306
314
|
|
|
@@ -309,6 +317,7 @@ def parse_args():
|
|
|
309
317
|
parser.add_argument("--apikey", help="Exchange apikey", default=None)
|
|
310
318
|
parser.add_argument("--secret", help="Exchange secret", default=None)
|
|
311
319
|
parser.add_argument("--passphrase", help="Exchange passphrase", default=None)
|
|
320
|
+
parser.add_argument("--verbose", help="logging verbosity, Y or N (default).", default='N')
|
|
312
321
|
|
|
313
322
|
parser.add_argument("--slack_info_url", help="Slack webhook url for INFO", default=None)
|
|
314
323
|
parser.add_argument("--slack_critial_url", help="Slack webhook url for CRITICAL", default=None)
|
|
@@ -326,8 +335,10 @@ def parse_args():
|
|
|
326
335
|
param['dry_run'] = False
|
|
327
336
|
|
|
328
337
|
param['default_type'] = args.default_type
|
|
338
|
+
param['default_sub_type'] = args.default_sub_type
|
|
329
339
|
param['rate_limit_ms'] = int(args.rate_limit_ms)
|
|
330
340
|
param['default_fees_ccy'] = args.default_fees_ccy
|
|
341
|
+
param['order_amount_randomize_max_pct'] = float(args.order_amount_randomize_max_pct)
|
|
331
342
|
param['loop_freq_ms'] = int(args.loop_freq_ms)
|
|
332
343
|
param['wait_fill_threshold_ms'] = int(args.wait_fill_threshold_ms)
|
|
333
344
|
|
|
@@ -343,6 +354,13 @@ def parse_args():
|
|
|
343
354
|
param['apikey'] = args.apikey
|
|
344
355
|
param['secret'] = args.secret
|
|
345
356
|
param['passphrase'] = args.passphrase
|
|
357
|
+
if args.verbose:
|
|
358
|
+
if args.verbose=='Y':
|
|
359
|
+
param['verbose'] = True
|
|
360
|
+
else:
|
|
361
|
+
param['verbose'] = False
|
|
362
|
+
else:
|
|
363
|
+
param['verbose'] = False
|
|
346
364
|
|
|
347
365
|
param['notification']['slack']['info']['webhook_url'] = args.slack_info_url
|
|
348
366
|
param['notification']['slack']['critical']['webhook_url'] = args.slack_critial_url
|
|
@@ -371,7 +389,7 @@ async def watch_orders_task(
|
|
|
371
389
|
):
|
|
372
390
|
while True:
|
|
373
391
|
try:
|
|
374
|
-
order_updates = await exchange.watch_orders()
|
|
392
|
+
order_updates = await exchange.watch_orders()
|
|
375
393
|
for order_update in order_updates:
|
|
376
394
|
order_id = order_update['id']
|
|
377
395
|
executions[order_id] = order_update
|
|
@@ -383,6 +401,9 @@ async def watch_orders_task(
|
|
|
383
401
|
await asyncio.sleep(param['loop_freq_ms']/1000)
|
|
384
402
|
|
|
385
403
|
async def send_heartbeat(exchange):
|
|
404
|
+
if not exchange.clients:
|
|
405
|
+
log(f'Please check https://github.com/ccxt/ccxt/blob/master/python/ccxt/pro/{exchange.name}, exchange.clients empty?')
|
|
406
|
+
return
|
|
386
407
|
|
|
387
408
|
await asyncio.sleep(10)
|
|
388
409
|
|
|
@@ -390,11 +411,15 @@ async def send_heartbeat(exchange):
|
|
|
390
411
|
try:
|
|
391
412
|
first_ws_url = next(iter(exchange.clients))
|
|
392
413
|
client = exchange.clients[first_ws_url]
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
414
|
+
if exchange.ping:
|
|
415
|
+
message = exchange.ping(client)
|
|
416
|
+
await client.send(message)
|
|
417
|
+
log('Heartbeat sent')
|
|
418
|
+
else:
|
|
419
|
+
log(f'Please check https://github.com/ccxt/ccxt/blob/master/python/ccxt/pro/{exchange.name} if ping was implemented')
|
|
420
|
+
|
|
396
421
|
except Exception as hb_error:
|
|
397
|
-
log(f'Failed to send heartbeat: {hb_error}')
|
|
422
|
+
log(f'Failed to send heartbeat: {hb_error} {str(sys.exc_info()[0])} {str(sys.exc_info()[1])} {traceback.format_exc()}')
|
|
398
423
|
finally:
|
|
399
424
|
await asyncio.sleep(20)
|
|
400
425
|
|
|
@@ -406,15 +431,17 @@ async def execute_one_position(
|
|
|
406
431
|
notification_params : Dict[str, Any]
|
|
407
432
|
):
|
|
408
433
|
try:
|
|
409
|
-
market : Dict[str, Any] = exchange.markets[position.ticker] if position.ticker in exchange.markets else None
|
|
434
|
+
market : Dict[str, Any] = exchange.markets[position.ticker] if position.ticker in exchange.markets else None
|
|
410
435
|
if not market:
|
|
411
|
-
raise ArgumentError(f"Market not found for {position.ticker} under {exchange.name}")
|
|
436
|
+
raise ArgumentError(f"Market not found for {position.ticker} under {exchange.name}")
|
|
412
437
|
|
|
413
|
-
min_amount = float(market['limits']['amount']['min']) if market['limits']['amount']['min'] else 0
|
|
438
|
+
min_amount = float(market['limits']['amount']['min']) if market['limits']['amount']['min'] else 0
|
|
414
439
|
multiplier = market['contractSize'] if 'contractSize' in market and market['contractSize'] else 1
|
|
415
440
|
position.multiplier = multiplier
|
|
416
441
|
|
|
417
|
-
|
|
442
|
+
order_amount_randomize_max_pct : float = param['order_amount_randomize_max_pct']
|
|
443
|
+
|
|
444
|
+
log(f"{position.ticker} min_amount: {min_amount}, multiplier: {multiplier}, order_amount_randomize_max_pct: {order_amount_randomize_max_pct}")
|
|
418
445
|
|
|
419
446
|
slices : List[Order] = position.to_slices()
|
|
420
447
|
|
|
@@ -428,7 +455,19 @@ async def execute_one_position(
|
|
|
428
455
|
slices[-1].amount += last_slice.amount
|
|
429
456
|
|
|
430
457
|
log(f"{position.ticker} Last slice residual smaller than min_amount. Amount is added to prev slice instead. last_slice_amount: {last_slice.amount/multiplier}, last_slice_rounded_amount: {last_slice_rounded_amount_in_base_ccy}")
|
|
431
|
-
|
|
458
|
+
|
|
459
|
+
@retry(num_attempts=3, pause_between_retries_ms=3000)
|
|
460
|
+
async def _fetch_order(
|
|
461
|
+
order_id : str,
|
|
462
|
+
ticker : str,
|
|
463
|
+
exchange : AnyExchange,
|
|
464
|
+
):
|
|
465
|
+
order_update = await exchange.fetch_order(order_id, ticker)
|
|
466
|
+
return order_update
|
|
467
|
+
|
|
468
|
+
randomized_order_amount : float = 0
|
|
469
|
+
last_randomized_order_amount : float = 0
|
|
470
|
+
apply_last_randomized_amount : bool = False # False: Apply new variance, True: Apply -1 * last_randomized_order_amount
|
|
432
471
|
i = 0
|
|
433
472
|
for slice in slices:
|
|
434
473
|
try:
|
|
@@ -436,9 +475,21 @@ async def execute_one_position(
|
|
|
436
475
|
|
|
437
476
|
dt_now : datetime = datetime.now()
|
|
438
477
|
|
|
439
|
-
|
|
478
|
+
if len(slices)>1:
|
|
479
|
+
if not apply_last_randomized_amount and i<len(slices):
|
|
480
|
+
randomized_order_amount = slice.amount * (order_amount_randomize_max_pct * random.uniform(-1, 1)) /100
|
|
481
|
+
last_randomized_order_amount = randomized_order_amount
|
|
482
|
+
|
|
483
|
+
else:
|
|
484
|
+
randomized_order_amount = -1 * last_randomized_order_amount # Apply the opposite of last slice's variance
|
|
485
|
+
last_randomized_order_amount = 0 # If # slices == 5, last slice don't apply random amount, so reset last_randomized_order_amount to zero.
|
|
486
|
+
|
|
487
|
+
slice_amount_in_base_ccy : float = slice.amount + randomized_order_amount
|
|
488
|
+
|
|
489
|
+
apply_last_randomized_amount = not apply_last_randomized_amount
|
|
490
|
+
|
|
440
491
|
rounded_slice_amount_in_base_ccy = slice_amount_in_base_ccy / multiplier # After divided by multiplier, rounded_slice_amount_in_base_ccy in number of contracts actually (Not in base ccy).
|
|
441
|
-
rounded_slice_amount_in_base_ccy = exchange.amount_to_precision(position.ticker, rounded_slice_amount_in_base_ccy)
|
|
492
|
+
rounded_slice_amount_in_base_ccy = exchange.amount_to_precision(position.ticker, rounded_slice_amount_in_base_ccy)
|
|
442
493
|
rounded_slice_amount_in_base_ccy = float(rounded_slice_amount_in_base_ccy) if rounded_slice_amount_in_base_ccy else 0
|
|
443
494
|
rounded_slice_amount_in_base_ccy = rounded_slice_amount_in_base_ccy if rounded_slice_amount_in_base_ccy>min_amount else min_amount
|
|
444
495
|
|
|
@@ -446,7 +497,7 @@ async def execute_one_position(
|
|
|
446
497
|
log(f"{position.ticker} Slice amount rounded to zero?! slice amount before rounding: {slice.amount}")
|
|
447
498
|
continue
|
|
448
499
|
|
|
449
|
-
orderbook = await exchange.fetch_order_book(symbol=position.ticker, limit=3)
|
|
500
|
+
orderbook = await exchange.fetch_order_book(symbol=position.ticker, limit=3)
|
|
450
501
|
if position.side=='buy':
|
|
451
502
|
asks = [ ask[0] for ask in orderbook['asks'] ]
|
|
452
503
|
best_asks = min(asks)
|
|
@@ -456,7 +507,7 @@ async def execute_one_position(
|
|
|
456
507
|
best_bid = max(bids)
|
|
457
508
|
limit_price : float = best_bid * (1 - position.leg_room_bps/10000)
|
|
458
509
|
|
|
459
|
-
rounded_limit_price : float = exchange.price_to_precision(position.ticker, limit_price)
|
|
510
|
+
rounded_limit_price : float = exchange.price_to_precision(position.ticker, limit_price)
|
|
460
511
|
rounded_limit_price = float(rounded_limit_price)
|
|
461
512
|
|
|
462
513
|
order_params = {
|
|
@@ -472,7 +523,7 @@ async def execute_one_position(
|
|
|
472
523
|
)
|
|
473
524
|
|
|
474
525
|
if not param['dry_run']:
|
|
475
|
-
executed_order = await exchange.create_order(
|
|
526
|
+
executed_order = await exchange.create_order(
|
|
476
527
|
symbol = position.ticker,
|
|
477
528
|
type = position.order_type,
|
|
478
529
|
amount = rounded_slice_amount_in_base_ccy,
|
|
@@ -499,7 +550,7 @@ async def execute_one_position(
|
|
|
499
550
|
|
|
500
551
|
else:
|
|
501
552
|
if not param['dry_run']:
|
|
502
|
-
executed_order = await exchange.create_order(
|
|
553
|
+
executed_order = await exchange.create_order(
|
|
503
554
|
symbol = position.ticker,
|
|
504
555
|
type = position.order_type,
|
|
505
556
|
amount = rounded_slice_amount_in_base_ccy,
|
|
@@ -596,11 +647,10 @@ async def execute_one_position(
|
|
|
596
647
|
elapsed_sec = time.time() - start_time
|
|
597
648
|
log(f"{position.ticker} waiting for order update ... elapsed_sec: {elapsed_sec}")
|
|
598
649
|
|
|
599
|
-
|
|
600
650
|
# Cancel hung limit order, resend as market
|
|
601
651
|
if order_status!='closed':
|
|
602
652
|
# If no update from websocket, do one last fetch via REST
|
|
603
|
-
order_update = await
|
|
653
|
+
order_update = await _fetch_order(order_id, position.ticker, exchange)
|
|
604
654
|
order_status = order_update['status']
|
|
605
655
|
filled_amount = order_update['filled']
|
|
606
656
|
remaining_amount = order_update['remaining']
|
|
@@ -610,15 +660,15 @@ async def execute_one_position(
|
|
|
610
660
|
|
|
611
661
|
if order_status!='closed':
|
|
612
662
|
log(f"Final order_update before cancel+resend: {json.dumps(order_update, indent=4)}", log_level=LogLevel.INFO)
|
|
613
|
-
await exchange.cancel_order(order_id, position.ticker)
|
|
663
|
+
await exchange.cancel_order(order_id, position.ticker)
|
|
614
664
|
position.get_execution(order_id)['status'] = 'canceled'
|
|
615
665
|
log(f"Canceled unfilled/partial filled order: {order_id}. Resending remaining_amount: {remaining_amount} as market order.", log_level=LogLevel.INFO)
|
|
616
666
|
|
|
617
|
-
rounded_slice_amount_in_base_ccy = exchange.amount_to_precision(position.ticker, remaining_amount)
|
|
667
|
+
rounded_slice_amount_in_base_ccy = exchange.amount_to_precision(position.ticker, remaining_amount)
|
|
618
668
|
rounded_slice_amount_in_base_ccy = float(rounded_slice_amount_in_base_ccy)
|
|
619
669
|
rounded_slice_amount_in_base_ccy = rounded_slice_amount_in_base_ccy if rounded_slice_amount_in_base_ccy>min_amount else min_amount
|
|
620
670
|
if rounded_slice_amount_in_base_ccy>0:
|
|
621
|
-
executed_resent_order = await exchange.create_order(
|
|
671
|
+
executed_resent_order = await exchange.create_order(
|
|
622
672
|
symbol=position.ticker,
|
|
623
673
|
type='market',
|
|
624
674
|
amount=remaining_amount,
|
|
@@ -654,7 +704,7 @@ async def execute_one_position(
|
|
|
654
704
|
|
|
655
705
|
if (not order_status or order_status!='closed'):
|
|
656
706
|
# If no update from websocket, do one last fetch via REST
|
|
657
|
-
order_update = await
|
|
707
|
+
order_update = await _fetch_order(order_id, position.ticker, exchange)
|
|
658
708
|
order_status = order_update['status']
|
|
659
709
|
filled_amount = order_update['filled']
|
|
660
710
|
remaining_amount = order_update['remaining']
|
|
@@ -691,13 +741,17 @@ async def execute_one_position(
|
|
|
691
741
|
for dispatched_slice in position.dispatched_slices:
|
|
692
742
|
log(f"{json.dumps(dispatched_slice.to_dict(), indent=4)}")
|
|
693
743
|
|
|
744
|
+
position_from_exchange = await exchange.fetch_position(position.ticker)
|
|
745
|
+
log(f"position update:")
|
|
746
|
+
log(f"{json.dumps(position_from_exchange, indent=4)}")
|
|
747
|
+
|
|
694
748
|
position.filled_amount = position.get_filled_amount()
|
|
695
749
|
position.average_cost = position.get_average_cost()
|
|
696
750
|
position.fees = position.get_fees()
|
|
697
751
|
|
|
698
|
-
balances = await exchange.fetch_balance()
|
|
752
|
+
balances = await exchange.fetch_balance()
|
|
699
753
|
if param['default_type']!='spot':
|
|
700
|
-
updated_position = await exchange.fetch_position(symbol=position.ticker)
|
|
754
|
+
updated_position = await exchange.fetch_position(symbol=position.ticker)
|
|
701
755
|
# After position closed, 'updated_position' can be an empty dict. hyperliquid for example.
|
|
702
756
|
amount = (updated_position['contracts'] if updated_position else 0) * position.multiplier # in base ccy
|
|
703
757
|
else:
|
|
@@ -725,7 +779,7 @@ async def execute_one_position(
|
|
|
725
779
|
err_msg = f"{position.ticker} Execution failed: {position_execution_err} {str(sys.exc_info()[0])} {str(sys.exc_info()[1])} {traceback.format_exc()}"
|
|
726
780
|
log(err_msg)
|
|
727
781
|
|
|
728
|
-
dispatch_notification(title=f"{param['current_filename']} {param['gateway_id']} {position.ticker} execute_one_position failed!!!", message=err_msg, footer=param['notification']['footer'], params=notification_params, log_level=LogLevel.ERROR, logger=logger)
|
|
782
|
+
dispatch_notification(title=f"{param['current_filename']} {param['gateway_id']} {position.ticker} execute_one_position failed!!!", message=err_msg, footer=param['notification']['footer'], params=notification_params, log_level=LogLevel.ERROR, logger=logger)
|
|
729
783
|
|
|
730
784
|
position.done = False
|
|
731
785
|
position.execution_err = err_msg
|
|
@@ -750,6 +804,7 @@ async def work(
|
|
|
750
804
|
|
|
751
805
|
asyncio.create_task(send_heartbeat(exchange))
|
|
752
806
|
|
|
807
|
+
loop_i : int = 0
|
|
753
808
|
while True:
|
|
754
809
|
try:
|
|
755
810
|
keys = redis_client.keys()
|
|
@@ -811,9 +866,14 @@ async def work(
|
|
|
811
866
|
log_level=LogLevel.ERROR
|
|
812
867
|
)
|
|
813
868
|
|
|
869
|
+
if loop_i%10==0:
|
|
870
|
+
balances = await exchange.fetch_balance()
|
|
871
|
+
log(f"{param['gateway_id']}: account balances {balances}")
|
|
872
|
+
|
|
814
873
|
except Exception as loop_error:
|
|
815
874
|
log(f"Error: {loop_error} {str(sys.exc_info()[0])} {str(sys.exc_info()[1])} {traceback.format_exc()}")
|
|
816
875
|
finally:
|
|
876
|
+
loop_i += 1
|
|
817
877
|
await asyncio.sleep(param['loop_freq_ms']/1000)
|
|
818
878
|
|
|
819
879
|
async def main():
|
|
@@ -862,13 +922,15 @@ async def main():
|
|
|
862
922
|
secret=secret,
|
|
863
923
|
passphrase=passphrase,
|
|
864
924
|
default_type=param['default_type'],
|
|
865
|
-
|
|
925
|
+
default_sub_type=param['default_sub_type'],
|
|
926
|
+
rate_limit_ms=param['rate_limit_ms'],
|
|
927
|
+
verbose=param['verbose']
|
|
866
928
|
)
|
|
867
929
|
if exchange:
|
|
868
930
|
# Once exchange instantiated, try fetch_balance to confirm connectivity and test credentials.
|
|
869
|
-
balances = await exchange.fetch_balance()
|
|
931
|
+
balances = await exchange.fetch_balance()
|
|
870
932
|
log(f"{param['gateway_id']}: account balances {balances}")
|
|
871
|
-
dispatch_notification(title=f"{param['current_filename']} {param['gateway_id']} started", message=balances, footer=param['notification']['footer'], params=notification_params, log_level=LogLevel.CRITICAL, logger=logger)
|
|
933
|
+
dispatch_notification(title=f"{param['current_filename']} {param['gateway_id']} started", message=balances['total'], footer=param['notification']['footer'], params=notification_params, log_level=LogLevel.CRITICAL, logger=logger)
|
|
872
934
|
|
|
873
935
|
await work(param=param, exchange=exchange, redis_client=redis_client, notification_params=notification_params)
|
|
874
936
|
|
|
@@ -118,6 +118,40 @@ class MarketDataUtilTests(unittest.TestCase):
|
|
|
118
118
|
assert set(pd_candles.columns) >= expected_columns, "Missing expected columns."
|
|
119
119
|
assert pd_candles['timestamp_ms'].notna().all(), "timestamp_ms column contains NaN values."
|
|
120
120
|
assert pd_candles['timestamp_ms'].is_monotonic_increasing, "Timestamps are not in ascending order."
|
|
121
|
+
|
|
122
|
+
def test_aggregate_candles(self):
|
|
123
|
+
end_date : datetime = datetime.today()
|
|
124
|
+
start_date : datetime = end_date + timedelta(hours=-8)
|
|
125
|
+
|
|
126
|
+
param = {
|
|
127
|
+
'apiKey' : None,
|
|
128
|
+
'secret' : None,
|
|
129
|
+
'password' : None,
|
|
130
|
+
'subaccount' : None,
|
|
131
|
+
'rateLimit' : 100, # In ms
|
|
132
|
+
'options' : {
|
|
133
|
+
'defaultType': 'swap' }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
exchange : Exchange = okx(param) # type: ignore
|
|
137
|
+
normalized_symbols = [ 'BTC/USDT:USDT' ]
|
|
138
|
+
pd_candles: Union[pd.DataFrame, None] = fetch_candles(
|
|
139
|
+
start_ts=start_date.timestamp(),
|
|
140
|
+
end_ts=end_date.timestamp(),
|
|
141
|
+
exchange=exchange,
|
|
142
|
+
normalized_symbols=normalized_symbols,
|
|
143
|
+
candle_size='15m' # <---- aggregate 1m into 15m candles
|
|
144
|
+
)[normalized_symbols[0]]
|
|
145
|
+
|
|
146
|
+
assert pd_candles is not None
|
|
147
|
+
pd_candles['timestamp_ms_gap'] = pd_candles['timestamp_ms'].diff()
|
|
148
|
+
timestamp_ms_gap_median = pd_candles['timestamp_ms_gap'].median()
|
|
149
|
+
NUM_MS_IN_1HR = 60*60*1000
|
|
150
|
+
expected_15m_gap_ms = NUM_MS_IN_1HR/4
|
|
151
|
+
assert(timestamp_ms_gap_median==expected_15m_gap_ms)
|
|
152
|
+
total_num_rows = pd_candles.shape[0]
|
|
153
|
+
num_rows_with_15min_gaps = pd_candles[pd_candles.timestamp_ms_gap!=timestamp_ms_gap_median].shape[0]
|
|
154
|
+
assert(num_rows_with_15min_gaps/total_num_rows <= 0.4) # Why not 100% match? minute bars may have gaps (Also depends on what ticker)
|
|
121
155
|
|
|
122
156
|
def test_fetch_candles_futubull(self):
|
|
123
157
|
# You need Futu OpenD running and you need entitlements
|
|
@@ -2,7 +2,7 @@ import unittest
|
|
|
2
2
|
from typing import List
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
5
|
-
from util.analytic_util import compute_candles_stats
|
|
5
|
+
from util.analytic_util import compute_candles_stats, lookup_fib_target
|
|
6
6
|
|
|
7
7
|
import pandas as pd
|
|
8
8
|
|
|
@@ -38,25 +38,27 @@ class AnalyticUtilTests(unittest.TestCase):
|
|
|
38
38
|
pd_candles=pd_candles,
|
|
39
39
|
boillenger_std_multiples=2,
|
|
40
40
|
sliding_window_how_many_candles=20,
|
|
41
|
-
pypy_compat=True
|
|
41
|
+
pypy_compat=True # Slopes calculation? Set pypy_compat to False
|
|
42
42
|
)
|
|
43
43
|
|
|
44
44
|
expected_columns : List[str] = [
|
|
45
45
|
'exchange', 'symbol', 'timestamp_ms',
|
|
46
46
|
'open', 'high', 'low', 'close', 'volume',
|
|
47
47
|
'datetime', 'datetime_utc', 'year', 'month', 'day', 'hour', 'minute', 'dayofweek',
|
|
48
|
-
'pct_chg_on_close', 'candle_height',
|
|
48
|
+
'pct_chg_on_close', 'candle_height', 'candle_body_height',
|
|
49
49
|
'week_of_month', 'apac_trading_hr', 'emea_trading_hr', 'amer_trading_hr',
|
|
50
|
-
'is_green', 'pct_change_close',
|
|
50
|
+
'is_green', 'candle_class', 'pct_change_close',
|
|
51
51
|
'sma_short_periods', 'sma_long_periods', 'ema_short_periods', 'ema_long_periods', 'ema_close',
|
|
52
52
|
'std', 'std_percent',
|
|
53
53
|
'vwap_short_periods', 'vwap_long_periods',
|
|
54
|
-
'candle_height_percent', 'candle_height_percent_rounded',
|
|
54
|
+
'candle_height_percent', 'candle_height_percent_rounded', 'candle_body_height_percent', 'candle_body_height_percent_rounded',
|
|
55
55
|
'log_return', 'interval_hist_vol', 'annualized_hist_vol',
|
|
56
56
|
'chop_against_ema',
|
|
57
57
|
'ema_volume_short_periods', 'ema_volume_long_periods',
|
|
58
58
|
'ema_cross', 'ema_cross_last', 'ema_bullish_cross_last_id', 'ema_bearish_cross_last_id',
|
|
59
59
|
'max_short_periods', 'max_long_periods', 'idmax_short_periods', 'idmax_long_periods', 'min_short_periods', 'min_long_periods', 'idmin_short_periods', 'idmin_long_periods',
|
|
60
|
+
'max_candle_body_height_percent_long_periods', 'idmax_candle_body_height_percent_long_periods',
|
|
61
|
+
'min_candle_body_height_percent_long_periods', 'idmin_candle_body_height_percent_long_periods',
|
|
60
62
|
'price_swing_short_periods', 'price_swing_long_periods',
|
|
61
63
|
'trend_from_highs_long_periods', 'trend_from_lows_long_periods', 'trend_from_highs_short_periods', 'trend_from_lows_short_periods',
|
|
62
64
|
'h_l', 'h_pc', 'l_pc', 'tr', 'atr', 'atr_avg_short_periods', 'atr_avg_long_periods',
|
|
@@ -65,12 +67,12 @@ class AnalyticUtilTests(unittest.TestCase):
|
|
|
65
67
|
'aggressive_up', 'aggressive_up_index', 'aggressive_up_candle_height', 'aggressive_up_candle_high', 'aggressive_up_candle_low', 'aggressive_down', 'aggressive_down_index', 'aggressive_down_candle_height', 'aggressive_down_candle_high', 'aggressive_down_candle_low',
|
|
66
68
|
'fvg_low', 'fvg_high', 'fvg_gap', 'fvg_mitigated',
|
|
67
69
|
'close_delta', 'close_delta_percent', 'up', 'down',
|
|
68
|
-
'rsi', 'ema_rsi', 'rsi_max', 'rsi_idmax', 'rsi_min', 'rsi_idmin', 'rsi_trend', 'rsi_trend_from_highs', 'rsi_trend_from_lows', 'rsi_divergence',
|
|
70
|
+
'rsi', 'rsi_bucket', 'ema_rsi', 'rsi_max', 'rsi_idmax', 'rsi_min', 'rsi_idmin', 'rsi_trend', 'rsi_trend_from_highs', 'rsi_trend_from_lows', 'rsi_divergence',
|
|
69
71
|
'typical_price',
|
|
70
|
-
'money_flow', 'money_flow_positive', 'money_flow_negative', 'positive_flow_sum', 'negative_flow_sum', 'money_flow_ratio', 'mfi',
|
|
71
|
-
'macd', 'signal', 'macd_minus_signal',
|
|
72
|
+
'money_flow', 'money_flow_positive', 'money_flow_negative', 'positive_flow_sum', 'negative_flow_sum', 'money_flow_ratio', 'mfi', 'mfi_bucket',
|
|
73
|
+
'macd', 'signal', 'macd_minus_signal', 'macd_cross', 'macd_bullish_cross_last_id', 'macd_bearish_cross_last_id', 'macd_cross_last',
|
|
72
74
|
'fib_0.618_short_periods', 'fib_0.618_long_periods',
|
|
73
|
-
'gap_close_vs_ema',
|
|
75
|
+
'gap_close_vs_ema', 'gap_close_vs_ema_percent',
|
|
74
76
|
'close_above_or_below_ema',
|
|
75
77
|
'close_vs_ema_inflection'
|
|
76
78
|
]
|
|
@@ -78,4 +80,29 @@ class AnalyticUtilTests(unittest.TestCase):
|
|
|
78
80
|
missing_columns = [ expected for expected in expected_columns if expected not in pd_candles.columns.to_list() ]
|
|
79
81
|
unexpected_columns = [ actual for actual in pd_candles.columns.to_list() if actual not in expected_columns ]
|
|
80
82
|
|
|
81
|
-
assert(pd_candles.columns.to_list()==expected_columns)
|
|
83
|
+
assert(pd_candles.columns.to_list()==expected_columns)
|
|
84
|
+
|
|
85
|
+
def test_lookup_fib_target(self):
|
|
86
|
+
data_dir = Path(__file__).parent.parent.parent.parent / "data"
|
|
87
|
+
csv_path = data_dir / "sample_btc_candles.csv"
|
|
88
|
+
pd_candles : pd.DataFrame = pd.read_csv(csv_path)
|
|
89
|
+
target_fib_level : float = 0.618
|
|
90
|
+
compute_candles_stats(
|
|
91
|
+
pd_candles=pd_candles,
|
|
92
|
+
boillenger_std_multiples=2,
|
|
93
|
+
sliding_window_how_many_candles=20,
|
|
94
|
+
target_fib_level=target_fib_level,
|
|
95
|
+
pypy_compat=True # Slopes calculation? Set pypy_compat to False
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
last_row = pd_candles.iloc[-1]
|
|
99
|
+
result = lookup_fib_target(
|
|
100
|
+
row=last_row,
|
|
101
|
+
pd_candles=pd_candles,
|
|
102
|
+
target_fib_level=target_fib_level
|
|
103
|
+
)
|
|
104
|
+
if result:
|
|
105
|
+
assert(result['short_periods']['min']<result['short_periods']['fib_target']<result['short_periods']['max'])
|
|
106
|
+
assert(result['long_periods']['min']<result['long_periods']['fib_target']<result['long_periods']['max'])
|
|
107
|
+
|
|
108
|
+
|