siglab-py 0.5.30__py3-none-any.whl → 0.6.16__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.

Files changed (34) hide show
  1. siglab_py/backtests/__init__.py +0 -0
  2. siglab_py/backtests/backtest_core.py +2371 -0
  3. siglab_py/backtests/coinflip_15m_crypto.py +432 -0
  4. siglab_py/backtests/fibonacci_d_mv_crypto.py +541 -0
  5. siglab_py/backtests/macdrsi_crosses_15m_tc_crypto.py +468 -0
  6. siglab_py/constants.py +5 -0
  7. siglab_py/exchanges/binance.py +38 -0
  8. siglab_py/exchanges/deribit.py +83 -0
  9. siglab_py/exchanges/futubull.py +11 -2
  10. siglab_py/market_data_providers/candles_provider.py +2 -2
  11. siglab_py/market_data_providers/candles_ta_provider.py +3 -3
  12. siglab_py/market_data_providers/futu_candles_ta_to_csv.py +6 -4
  13. siglab_py/market_data_providers/google_monitor.py +320 -0
  14. siglab_py/market_data_providers/orderbooks_provider.py +15 -12
  15. siglab_py/market_data_providers/tg_monitor.py +6 -2
  16. siglab_py/market_data_providers/{test_provider.py → trigger_provider.py} +9 -8
  17. siglab_py/ordergateway/encrypt_keys_util.py +1 -1
  18. siglab_py/ordergateway/gateway.py +97 -35
  19. siglab_py/tests/integration/market_data_util_tests.py +37 -1
  20. siglab_py/tests/unit/analytic_util_tests.py +37 -10
  21. siglab_py/tests/unit/simple_math_tests.py +252 -0
  22. siglab_py/tests/unit/trading_util_tests.py +0 -21
  23. siglab_py/util/analytic_util.py +195 -33
  24. siglab_py/util/datetime_util.py +39 -0
  25. siglab_py/util/market_data_util.py +184 -65
  26. siglab_py/util/notification_util.py +1 -1
  27. siglab_py/util/retry_util.py +6 -1
  28. siglab_py/util/simple_math.py +262 -0
  29. siglab_py/util/trading_util.py +0 -12
  30. {siglab_py-0.5.30.dist-info → siglab_py-0.6.16.dist-info}/METADATA +1 -1
  31. siglab_py-0.6.16.dist-info/RECORD +50 -0
  32. {siglab_py-0.5.30.dist-info → siglab_py-0.6.16.dist-info}/WHEEL +1 -1
  33. siglab_py-0.5.30.dist-info/RECORD +0 -39
  34. {siglab_py-0.5.30.dist-info → siglab_py-0.6.16.dist-info}/top_level.txt +0 -0
@@ -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 # type: ignore
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() # type: ignore
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() # type: ignore
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
- message = exchange.ping(client)
394
- await client.send(message)
395
- log('Heartbeat sent')
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 # type: ignore
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}") # type: ignore
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 # type: ignore
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
- log(f"{position.ticker} min_amount: {min_amount}, multiplier: {multiplier}")
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
- slice_amount_in_base_ccy : float = slice.amount
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) # type: ignore
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) # type: ignore
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) # type: ignore
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( # type: ignore
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( # type: ignore
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 exchange.fetch_order(order_id, position.ticker) # type: ignore
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) # type: ignore
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) # type: ignore
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( # type: ignore
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 exchange.fetch_order(order_id, position.ticker) # type: ignore
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() # type: ignore
752
+ balances = await exchange.fetch_balance()
699
753
  if param['default_type']!='spot':
700
- updated_position = await exchange.fetch_position(symbol=position.ticker) # type: ignore
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) # type: ignore
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
- rate_limit_ms=param['rate_limit_ms']
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() # type: ignore
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
 
@@ -1,6 +1,7 @@
1
1
  import unittest
2
2
  from datetime import datetime, timedelta
3
3
  from typing import Union
4
+ import logging
4
5
  from pathlib import Path
5
6
 
6
7
  from util.market_data_util import *
@@ -107,7 +108,8 @@ class MarketDataUtilTests(unittest.TestCase):
107
108
  end_ts=end_date.timestamp(),
108
109
  exchange=exchange,
109
110
  normalized_symbols=normalized_symbols,
110
- candle_size='1h'
111
+ candle_size='1h',
112
+ logger=logging.getLogger()
111
113
  )[normalized_symbols[0]]
112
114
 
113
115
  assert pd_candles is not None
@@ -118,6 +120,40 @@ class MarketDataUtilTests(unittest.TestCase):
118
120
  assert set(pd_candles.columns) >= expected_columns, "Missing expected columns."
119
121
  assert pd_candles['timestamp_ms'].notna().all(), "timestamp_ms column contains NaN values."
120
122
  assert pd_candles['timestamp_ms'].is_monotonic_increasing, "Timestamps are not in ascending order."
123
+
124
+ def test_aggregate_candles(self):
125
+ end_date : datetime = datetime.today()
126
+ start_date : datetime = end_date + timedelta(hours=-8)
127
+
128
+ param = {
129
+ 'apiKey' : None,
130
+ 'secret' : None,
131
+ 'password' : None,
132
+ 'subaccount' : None,
133
+ 'rateLimit' : 100, # In ms
134
+ 'options' : {
135
+ 'defaultType': 'swap' }
136
+ }
137
+
138
+ exchange : Exchange = okx(param) # type: ignore
139
+ normalized_symbols = [ 'BTC/USDT:USDT' ]
140
+ pd_candles: Union[pd.DataFrame, None] = fetch_candles(
141
+ start_ts=start_date.timestamp(),
142
+ end_ts=end_date.timestamp(),
143
+ exchange=exchange,
144
+ normalized_symbols=normalized_symbols,
145
+ candle_size='15m' # <---- aggregate 1m into 15m candles
146
+ )[normalized_symbols[0]]
147
+
148
+ assert pd_candles is not None
149
+ pd_candles['timestamp_ms_gap'] = pd_candles['timestamp_ms'].diff()
150
+ timestamp_ms_gap_median = pd_candles['timestamp_ms_gap'].median()
151
+ NUM_MS_IN_1HR = 60*60*1000
152
+ expected_15m_gap_ms = NUM_MS_IN_1HR/4
153
+ assert(timestamp_ms_gap_median==expected_15m_gap_ms)
154
+ total_num_rows = pd_candles.shape[0]
155
+ num_rows_with_15min_gaps = pd_candles[pd_candles.timestamp_ms_gap!=timestamp_ms_gap_median].shape[0]
156
+ 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
157
 
122
158
  def test_fetch_candles_futubull(self):
123
159
  # 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
+