pyqqq 0.12.167__tar.gz → 0.12.169__tar.gz

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.
Files changed (63) hide show
  1. {pyqqq-0.12.167 → pyqqq-0.12.169}/PKG-INFO +1 -1
  2. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyproject.toml +1 -1
  3. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/backtest/broker.py +109 -32
  4. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/kis/domestic_stock.py +10 -0
  5. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/kis/simple.py +4 -4
  6. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/tracker.py +114 -15
  7. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/data/daily.py +3 -0
  8. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/data/minutes.py +2 -2
  9. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/data/realtime.py +1 -0
  10. {pyqqq-0.12.167 → pyqqq-0.12.169}/README.md +0 -0
  11. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/__init__.py +0 -0
  12. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/ai/__init__.py +0 -0
  13. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/ai/daily.py +0 -0
  14. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/ai/domestic.py +0 -0
  15. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/ai/market_schedule.py +0 -0
  16. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/ai/minute.py +0 -0
  17. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/backtest/__init__.py +0 -0
  18. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/backtest/environment.py +0 -0
  19. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/backtest/logger.py +0 -0
  20. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/backtest/positionprovider.py +0 -0
  21. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/backtest/strategy.py +0 -0
  22. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/backtest/utils.py +0 -0
  23. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/backtest/wallclock.py +0 -0
  24. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/__init__.py +0 -0
  25. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/ebest/__init__.py +0 -0
  26. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/ebest/domestic_stock.py +0 -0
  27. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/ebest/oauth.py +0 -0
  28. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/ebest/simple.py +0 -0
  29. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/ebest/tr_client.py +0 -0
  30. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/helper.py +0 -0
  31. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/kis/__init__.py +0 -0
  32. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/kis/oauth.py +0 -0
  33. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/kis/overseas_stock.py +0 -0
  34. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/kis/simple_overseas.py +0 -0
  35. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/kis/tr_client.py +0 -0
  36. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/multiprocess_tracker.py +0 -0
  37. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/config.py +0 -0
  38. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/data/__init__.py +0 -0
  39. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/data/domestic.py +0 -0
  40. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/data/index.py +0 -0
  41. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/data/overseas.py +0 -0
  42. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/data/ticks.py +0 -0
  43. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/data/us_stocks.py +0 -0
  44. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/datatypes.py +0 -0
  45. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/executors/__init__.py +0 -0
  46. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/executors/hook.py +0 -0
  47. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/__init__.py +0 -0
  48. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/api_client.py +0 -0
  49. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/array.py +0 -0
  50. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/casting.py +0 -0
  51. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/compute.py +0 -0
  52. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/copycat.py +0 -0
  53. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/daily_tickers.py +0 -0
  54. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/display.py +0 -0
  55. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/kvstore.py +0 -0
  56. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/limiter.py +0 -0
  57. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/local_cache.py +0 -0
  58. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/logger.py +0 -0
  59. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/market_schedule.py +0 -0
  60. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/mock_api.py +0 -0
  61. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/position_classifier.py +0 -0
  62. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/retry.py +0 -0
  63. {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/singleton.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: pyqqq
3
- Version: 0.12.167
3
+ Version: 0.12.169
4
4
  Summary: Package for quantitative strategy development on the PyQQQ platform
5
5
  License: MIT
6
6
  Author: PyQQQ team
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "pyqqq"
3
- version = "0.12.167"
3
+ version = "0.12.169"
4
4
  description = "Package for quantitative strategy development on the PyQQQ platform"
5
5
  authors = ["PyQQQ team <pyqqq.cs@gmail.com>"]
6
6
  readme = "README.md"
@@ -1,5 +1,6 @@
1
1
  import datetime as dtm
2
2
  import os
3
+ import fcntl
3
4
  from abc import ABC, abstractmethod
4
5
  from dataclasses import asdict
5
6
  from decimal import Decimal
@@ -291,7 +292,7 @@ class TradingBroker(BaseBroker):
291
292
 
292
293
  def create_order(self, asset_code: str, side: OrderSide, quantity: int, order_type: OrderType = OrderType.MARKET, price: int | Decimal = 0, exchange: OrderExchange = OrderExchange.KRX) -> str:
293
294
  self.logger.debug(f"create_order: {asset_code} {side} {quantity} {order_type} {price} {exchange}")
294
- return self.trading_api.create_order(asset_code, side, quantity, order_type, price, exchange)
295
+ return self.trading_api.create_order(asset_code, side, quantity, order_type, price, exchange=exchange)
295
296
 
296
297
  def update_order(self, org_order_no: str, order_type: OrderType, price: int | Decimal, quantity: int = 0, exchange: OrderExchange = OrderExchange.KRX):
297
298
  if isinstance(self.trading_api, KISSimpleOverseasStock):
@@ -305,7 +306,7 @@ class TradingBroker(BaseBroker):
305
306
  raise ValueError(f"order not found: {org_order_no}")
306
307
  else:
307
308
  self.logger.debug(f"update_order: {org_order_no} {order_type} {price} {quantity}")
308
- return self.trading_api.update_order(org_order_no, order_type, price, quantity, exchange)
309
+ return self.trading_api.update_order(org_order_no, order_type, price, quantity, exchange=exchange)
309
310
 
310
311
  def cancel_order(self, order_no: str, quantity: int = 0):
311
312
  if isinstance(self.trading_api, KISSimpleOverseasStock):
@@ -383,6 +384,7 @@ class MockBroker(BaseBroker):
383
384
  time_unit="minutes",
384
385
  current_data_handling: Literal["include", "virtual", "exclude"] = "virtual",
385
386
  local_cache_path: str = LOCAL_CACHE_PATH,
387
+ market_nxt_on: bool = False,
386
388
  ):
387
389
  """MockBroker 클래스의 초기화 메서드입니다.
388
390
 
@@ -398,11 +400,14 @@ class MockBroker(BaseBroker):
398
400
  - "virtual": 직전(1분전)까지의 데이터 + 현재(분)의 시가로 통일한 가상 데이터 반환 (기본값)
399
401
  - "exclude": 직전(1분전)까지의 데이터만 반환
400
402
  local_cache_path (str, optional): 데이터 로컬 캐시 경로. Defaults to "./data".
403
+ market_nxt_on (bool, optional): 넥스트레이드 시장 활성화 여부. Defaults to False.
401
404
  """
402
405
  self.logger = get_logger("MockBroker", clock)
403
406
  self.clock = clock
404
407
  self.clock.on_time_change = self.on_time_change
405
408
  self.market = market
409
+ self.market_nxt_on = market_nxt_on
410
+ self.logger.info(f"init market_nxt_on={self.market_nxt_on}")
406
411
 
407
412
  self.next_order_no = 1000
408
413
  self.cash = 1_000_000_000
@@ -470,8 +475,12 @@ class MockBroker(BaseBroker):
470
475
  def typed_result(value) -> Decimal:
471
476
  return Decimal(value).quantize(Decimal("0.0000")) if self.market == "us_stock" else Decimal(int(value))
472
477
 
473
- schedule = get_market_schedule(today, exchange=self._get_exchange_code())
474
- if now.time() < schedule.open_time:
478
+ market_schedule = get_market_schedule(today, exchange=self._get_exchange_code())
479
+ open_time = market_schedule.open_time
480
+ if self.market_nxt_on:
481
+ open_time = dtm.time(8, 0, 0)
482
+
483
+ if now.time() < open_time:
475
484
  last_trading_day = get_last_trading_day(today, exchange=self._get_exchange_code())
476
485
  df = self.get_daily_price(code, last_trading_day, last_trading_day) # 전일 종가는 일봉, 분봉 구분이 필요 없다.
477
486
  return typed_result(df["close"].iloc[-1])
@@ -496,7 +505,8 @@ class MockBroker(BaseBroker):
496
505
 
497
506
  def get_minute_price(self, code: str) -> pd.DataFrame:
498
507
  """
499
- self.clock 기준 오늘 정규장 시작부터 현재 시각 (이전) 까지의 분봉 데이터를 조회합니다.
508
+ self.clock 기준 당일의 정규장 시작부터 현재 시각 (이전) 까지의 분봉 데이터를 조회합니다.
509
+ 단, market_nxt_on 이면, NXT 가능 종목의 경우 프리마켓, 정규마켓, 애프터마켓의 분봉을 모두 조회합니다.
500
510
 
501
511
  Args:
502
512
  code (str): 종목 코드
@@ -509,13 +519,21 @@ class MockBroker(BaseBroker):
509
519
  if df.empty:
510
520
  return df
511
521
 
512
- # 정규장 정보로만 거르기
522
+ # 정규장 정보로만 거르기 ==================================
513
523
  market_schedule = get_market_schedule(today, "NYSE" if self.market == "us_stock" else "KRX")
514
524
  open_time = dtm.datetime.combine(today, market_schedule.open_time)
515
525
  close_time = dtm.datetime.combine(today, market_schedule.close_time)
526
+
527
+ # TODO: us_stock 에 대해서는 따로 적용해야 함.
528
+ is_nxt = df.index[0] == dtm.datetime.combine(today, dtm.time(8, 0, 0))
529
+ if self.market_nxt_on and is_nxt:
530
+ open_time = dtm.datetime.combine(today, dtm.time(8, 0, 0))
531
+ close_time = dtm.datetime.combine(today, dtm.time(20, 0, 0))
532
+
516
533
  if df.index.tz is not None:
517
534
  df.index = df.index.tz_localize(None)
518
535
  df = df[(df.index >= open_time) & (df.index <= close_time)]
536
+ # ========================================================
519
537
 
520
538
  # 미래 정보 없애기
521
539
  now = pd.Timestamp(self.clock.now())
@@ -558,34 +576,71 @@ class MockBroker(BaseBroker):
558
576
 
559
577
  @lru_cache(maxsize=40)
560
578
  def _get_minute_price_with_cache(self, date, code) -> pd.DataFrame:
561
- file_path = f"{self.local_cache_path}/minutes/{date}/{code}.csv" if self.local_cache_path else None
579
+ """
580
+ market_nxt_on 이면, NXT 프리마켓, 정규마켓, 애프터마켓의 분봉이 모두 반환된다.
581
+ """
582
+ folder_name = "nxt_minutes" if self.market_nxt_on else "minutes"
583
+ file_path = f"{self.local_cache_path}/{folder_name}/{date}/{code}.csv" if self.local_cache_path else None
562
584
  if file_path and os.path.exists(file_path):
563
- df = pd.read_csv(file_path)
564
- df["time"] = pd.to_datetime(df["time"])
565
- df.set_index("time", inplace=True)
566
- return df
585
+ try:
586
+ with open(file_path, 'r') as f:
587
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
588
+ try:
589
+ df = pd.read_csv(f)
590
+ df['time'] = pd.to_datetime(df['time'])
591
+ df.set_index('time', inplace=True)
592
+ finally:
593
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
594
+ except Exception as e:
595
+ self.logger.exception(e)
596
+ if os.path.exists(file_path):
597
+ os.remove(file_path)
598
+ df = self._fetch_minute_price(date, code)
599
+ else:
600
+ df = self._fetch_minute_price(date, code)
601
+
602
+ return df
567
603
 
604
+ def _fetch_minute_price(self, date, code) -> pd.DataFrame:
605
+ folder_name = "nxt_minutes" if self.market_nxt_on else "minutes"
606
+ file_path = f"{self.local_cache_path}/{folder_name}/{date}/{code}.csv" if self.local_cache_path else None
607
+
608
+ if self.market == "us_stock":
609
+ dfs = get_us_minute_data(date, [code])
568
610
  else:
569
- if self.market == "us_stock":
570
- dfs = get_us_minute_data(date, [code])
611
+ if self.market_nxt_on:
612
+ dfs = get_kr_minute_data(date, [code], dtm.timedelta(minutes=1), source="kis", adjusted=True, exchange=DataExchange.NXT)
613
+ if dfs[code].empty: # NXT 에 해당 종목이 없는 경우 KRX 의 결과값을 반환한다. 하지만 로컬 캐시 폴더는 NXT 기준으로 저장한다.
614
+ dfs = get_kr_minute_data(date, [code], dtm.timedelta(minutes=1), source="kis", adjusted=True, exchange=DataExchange.KRX)
571
615
  else:
572
- dfs = get_kr_minute_data(date, [code], dtm.timedelta(minutes=1), source="kis", adjusted=True)
616
+ dfs = get_kr_minute_data(date, [code], dtm.timedelta(minutes=1), source="kis", adjusted=True, exchange=DataExchange.KRX)
573
617
 
574
- df = dfs[code]
575
- if df.empty:
576
- return df
618
+ df = dfs[code]
619
+ if df.empty:
620
+ return df
577
621
 
578
- if self.local_cache_path:
579
- if not os.path.exists(f"{self.local_cache_path}/minutes/{date}"):
580
- os.makedirs(f"{self.local_cache_path}/minutes/{date}")
622
+ if self.local_cache_path:
623
+ if not os.path.exists(f"{self.local_cache_path}/{folder_name}/{date}"):
624
+ os.makedirs(f"{self.local_cache_path}/{folder_name}/{date}")
581
625
 
582
- df.reset_index(inplace=True)
583
- df.to_csv(file_path, index=False)
584
- df.set_index("time", inplace=True)
626
+ df.reset_index(inplace=True)
627
+ with open(file_path, 'w') as f:
628
+ fcntl.flock(f.fileno(), fcntl.LOCK_EX)
629
+ try:
630
+ df.to_csv(f, index=False)
631
+ except Exception as e:
632
+ self.logger.exception(e)
633
+ finally:
634
+ fcntl.flock(f.fileno(), fcntl.LOCK_UN)
635
+ df.set_index("time", inplace=True)
585
636
 
586
- return df
637
+ return df
587
638
 
588
639
  def get_daily_price(self, code: str, from_date: Optional[dtm.date] = None, end_date: Optional[dtm.date] = None):
640
+ """
641
+ 지정된 기간 동안의 정규장 시작부터 현재 시각 (이전) 까지의 일봉 데이터를 조회합니다.
642
+ 단, market_nxt_on 이면, NXT 가능 종목의 경우 프리마켓, 정규마켓, 애프터마켓의 일봉을 모두 조회합니다.
643
+ """
589
644
  today = self.clock.today()
590
645
 
591
646
  def _exclude_today(df: pd.DataFrame) -> pd.DataFrame:
@@ -604,7 +659,12 @@ class MockBroker(BaseBroker):
604
659
  if self.market == "us_stock":
605
660
  dfs = get_us_daily_data([code], from_date, end_date, adjusted=True, ascending=True)
606
661
  else:
607
- dfs = get_kr_daily_data([code], from_date, end_date, adjusted=True, ascending=True)
662
+ if self.market_nxt_on:
663
+ dfs = get_kr_daily_data([code], from_date, end_date, adjusted=True, ascending=True, exchange=DataExchange.NXT)
664
+ if code not in dfs or dfs[code].empty:
665
+ dfs = get_kr_daily_data([code], from_date, end_date, adjusted=True, ascending=True, exchange=DataExchange.KRX)
666
+ else:
667
+ dfs = get_kr_daily_data([code], from_date, end_date, adjusted=True, ascending=True, exchange=DataExchange.KRX)
608
668
 
609
669
  df = dfs[code]
610
670
 
@@ -612,16 +672,22 @@ class MockBroker(BaseBroker):
612
672
  # 마지막 데이터가 오늘인 경우 장중 여부와 설정에따라 구분되어 처리될 필요가 있다.
613
673
  now = self.clock.now()
614
674
  schedule = get_market_schedule(today, exchange=self._get_exchange_code())
675
+ open_time = schedule.open_time
676
+ close_time = schedule.close_time
677
+ if self.market_nxt_on:
678
+ open_time = dtm.time(8, 0, 0)
679
+ close_time = dtm.time(20, 0, 0)
680
+
615
681
  if schedule.full_day_closed:
616
682
  pass # end_date 가 폐장일이면 해당 날짜의 데이터는 이미 없음
617
683
  else:
618
684
  if self.current_data_handling == "include":
619
685
  pass
620
686
  elif self.current_data_handling == "virtual":
621
- if now.time() < schedule.open_time:
687
+ if now.time() < open_time:
622
688
  # 장 오픈 전은 전일 데이터 까지만 반환
623
689
  df = _exclude_today(df)
624
- elif now.time() <= schedule.close_time:
690
+ elif now.time() <= close_time:
625
691
  # 장중에는 분봉을 적절히 활용
626
692
  minute_df = self.get_minute_price(code)
627
693
  if minute_df.empty:
@@ -665,9 +731,8 @@ class MockBroker(BaseBroker):
665
731
  return positions
666
732
 
667
733
  def create_order(self, asset_code: str, side: OrderSide, quantity: int, order_type: OrderType = OrderType.MARKET, price: int = 0, exchange: OrderExchange = OrderExchange.KRX) -> str:
668
- # TODO: exchange에 따라 다른 봉을 보는 처리가 추가로 되어야 함
669
734
  price = self.get_price(asset_code) if order_type == OrderType.MARKET else price
670
- self.logger.info(f"CREATE ORDER: {self._get_asset_name(asset_code)} side:{side} price:{price} quantity:{quantity} order_type:{order_type}")
735
+ self.logger.info(f"CREATE ORDER: {self._get_asset_name(asset_code)} side:{side} price:{price} quantity:{quantity} order_type:{order_type} exchange:{exchange.value}")
671
736
  order_no = str(self.next_order_no)
672
737
  self.next_order_no += 1
673
738
 
@@ -775,14 +840,27 @@ class MockBroker(BaseBroker):
775
840
  close_time = dtm.datetime.combine(today, market_schedule.close_time)
776
841
  preclose_auction_start_time = close_time - dtm.timedelta(minutes=10)
777
842
 
843
+ nxt_pre_open_time = dtm.datetime.combine(today, dtm.time(8, 0, 0))
844
+ nxt_pre_close_time = dtm.datetime.combine(today, dtm.time(8, 50, 0))
845
+ nxt_main_open_time = dtm.datetime.combine(today, dtm.time(9, 0, 30))
846
+ nxt_main_close_time = dtm.datetime.combine(today, dtm.time(15, 20, 0))
847
+ nxt_after_open_time = dtm.datetime.combine(today, dtm.time(15, 40, 0))
848
+ nxt_after_close_time = dtm.datetime.combine(today, dtm.time(20, 0, 0))
849
+
778
850
  just_closed = current_time.replace(second=0, microsecond=0) == close_time.replace(second=0, microsecond=0)
779
851
  if preclose_auction_start_time - dtm.timedelta(minutes=1) <= current_time <= preclose_auction_start_time + dtm.timedelta(minutes=1):
780
852
  # 로그 출력 수 조정
781
853
  self.logger.debug(f"just_closed: {just_closed} {self.pending_orders}")
782
854
 
783
855
  if market_schedule.full_day_closed or current_time < open_time or (current_time > close_time and not just_closed):
784
- self.logger.warning(f"on_time_change: {before_time} ~ {current_time} market is closed")
785
- return
856
+ if self.market_nxt_on and not market_schedule.full_day_closed:
857
+ # market_nxt_on 인 경우 메인 마켓은 KRX 그대로 사용하고 프리마켓과 애프터마켓만 추가한다.
858
+ if current_time < nxt_pre_open_time or nxt_pre_close_time <= current_time < open_time or close_time < current_time < nxt_after_open_time or nxt_after_close_time <= current_time:
859
+ self.logger.warning(f"on_time_change(nxt_on): {before_time} ~ {current_time} market is closed")
860
+ return
861
+ else:
862
+ self.logger.warning(f"on_time_change: {before_time} ~ {current_time} market is closed")
863
+ return
786
864
 
787
865
  for order in list(self.pending_orders):
788
866
  if self.time_unit == "minutes":
@@ -794,7 +872,6 @@ class MockBroker(BaseBroker):
794
872
  # 강제로 1분 차이를 만들어 줌
795
873
  before_time = current_time - dtm.timedelta(seconds=60)
796
874
 
797
- # TODO: exchange에 따라 다른 봉을 보는 처리가 추가로 되어야 함
798
875
  df = self.get_minute_price(order.asset_code)
799
876
  if df.empty:
800
877
  self.logger.warning(f"ORDER CANCELED: {order.asset_code} minute data is empty")
@@ -2646,6 +2646,16 @@ class KISDomesticStock:
2646
2646
  - ocr_no: OCR번호
2647
2647
  - crfd_item_yn: 크라우드펀딩종목여부
2648
2648
  - elec_scty_yn: 전자증권여부
2649
+ - issu_istt_cd: 발행기관코드
2650
+ - etf_chas_erng_rt_dbnb: ETF추적수익율배수
2651
+ - etf_etn_ivst_heed_item_yn: ETF/ETN투자유의종목여부
2652
+ - stln_int_rt_dvsn_cd: 대주이자율구분코드
2653
+ - frnr_psnl_lmt_rt: 외국인개인한도비율
2654
+ - lstg_rqsr_issu_istt_cd: 상장신청인발행기관코드
2655
+ - lstg_rqsr_item_cd: 상장신청인종목코드
2656
+ - trst_istt_issu_istt_cd: 신탁기관발행기관코드
2657
+ - cptt_trad_tr_psbl_yn: NXT 거래종목여부
2658
+ - nxt_tr_stop_yn: NXT 거래정지여부
2649
2659
 
2650
2660
  Raise:
2651
2661
  ValueError: API 에러 발생시
@@ -278,10 +278,10 @@ class KISSimpleDomesticStock:
278
278
 
279
279
  Args:
280
280
  asset_code(str): 종목코드
281
- data_exchange(DataExchange): 데이터 거래소
281
+ data_exchange(DataExchange): 데이터 거래소 (cf. NXT의 경우 해당되지 않는 종목은 Empty DataFrame이 아니고 모든 값이 0인 DataFrame이 반환됩니다.)
282
282
 
283
283
  Returns:
284
- pd.DataFrame: 분봉 데이터
284
+ pd.DataFrame: 분봉 데이터 (시간의 역순)
285
285
  """
286
286
 
287
287
  request_datetime = dtm.datetime.now()
@@ -321,9 +321,9 @@ class KISSimpleDomesticStock:
321
321
 
322
322
  open_time = schedule.open_time
323
323
 
324
- # FIXME: 넥스트레이드 시장은 1시간 데이터를 조회해야
324
+ # FIXME: 넥스트레이드 시장 (수능일 같은 경우 넥스트레이드는 시간 변동 없이 8시 그대로 오픈한다는 얘기가 있어서 확인 필요)
325
325
  if data_exchange == DataExchange.NXT:
326
- open_time = (dtm.datetime.combine(request_datetime.date(), schedule.open_time) - dtm.timedelta(hours=1)).time()
326
+ open_time = dtm.time(8, 0, 0)
327
327
 
328
328
  if request_time.time() < open_time:
329
329
  break
@@ -8,6 +8,7 @@ from pyqqq.utils.api_client import raise_for_status, send_request
8
8
  from pyqqq.utils.array import find
9
9
  from pyqqq.utils.logger import get_logger
10
10
  from pyqqq.utils.market_schedule import get_market_schedule, get_last_trading_day
11
+ from pyqqq.utils.singleton import singleton
11
12
  from typing import Dict, Optional, List
12
13
  import asyncio
13
14
  import os
@@ -15,6 +16,111 @@ import pyqqq.config as c
15
16
  import datetime as dtm
16
17
 
17
18
 
19
+ @singleton
20
+ class TrackerSocket:
21
+ """
22
+ 거래 내역 추적을 위한 WebSocket 소켓 클래스입니다.
23
+
24
+ Args:
25
+ simple_api (EBestSimpleDomesticStock | KISSimpleDomesticStock): 간편 거래 API 객체
26
+ """
27
+
28
+ def __init__(self, simple_api: EBestSimpleDomesticStock | KISSimpleDomesticStock):
29
+ self.simple_api = simple_api
30
+ self.task: asyncio.Task = None
31
+ self.stop_event = asyncio.Event()
32
+ self.logger = get_logger(__name__ + ".TrackerSocket")
33
+ self.trading_tracker_counter = 1
34
+ self.event_callbacks: Dict[callable] = {}
35
+
36
+ async def start(self):
37
+ """
38
+ TradingSocket에서 거래내역 추적을 시작합니다.
39
+ """
40
+ if self.task is not None and not self.task.done():
41
+ self.logger.info("TrackerSocket already started!")
42
+ return
43
+
44
+ self.task = asyncio.create_task(self._listen_order_event())
45
+ self.logger.info("TrackerSocket started!")
46
+
47
+ async def _listen_order_event(self):
48
+ """
49
+ 주문 이벤트를 수신하고 처리하는 비동기 메서드입니다.
50
+ """
51
+ self.logger.info("Listening for order events...")
52
+ try:
53
+ async for event in self.simple_api.listen_order_event(self.stop_event):
54
+ self._relay_order_event(event)
55
+ except asyncio.CancelledError:
56
+ self.logger.info("Order event listening cancelled.")
57
+ except Exception as e:
58
+ self.logger.exception(f"Error while listening to order events: {e}")
59
+
60
+ def _relay_order_event(self, event: OrderEvent):
61
+ """
62
+ 주문 이벤트를 등록된 콜백 함수로 전달합니다.
63
+
64
+ Args:
65
+ event (OrderEvent): 주문 이벤트 객체
66
+ """
67
+ self.logger.debug(f"Relay order event: {event}")
68
+
69
+ for callback in self.event_callbacks.values():
70
+ try:
71
+ callback(event)
72
+ except Exception as e:
73
+ self.logger.exception(f"Error in callback {callback}: {e}")
74
+
75
+ def add_tracker(self, callback: callable):
76
+ """
77
+ TradingTracker를 추가합니다.
78
+
79
+ Args:
80
+ callback (callable): 거래 내역 추적 이벤트를 처리할 콜백 함수
81
+ """
82
+ ret = self.trading_tracker_counter
83
+ self.event_callbacks[self.trading_tracker_counter] = callback
84
+ self.trading_tracker_counter += 1
85
+
86
+ self.logger.info(f"Tracker added: {ret}")
87
+ return ret
88
+
89
+ async def remove_tracker(self, tracker_number: int):
90
+ """
91
+ TradingTracker를 제거합니다.
92
+ Args:
93
+ tracker_number (int): 제거할 트래커 번호
94
+ """
95
+ if tracker_number in self.event_callbacks:
96
+ del self.event_callbacks[tracker_number]
97
+ self.logger.info(f"Tracker removed: {tracker_number}")
98
+ else:
99
+ self.logger.warning(f"Tracker {tracker_number} not found!")
100
+
101
+ if len(self.event_callbacks) == 0:
102
+ self.logger.info("No more trackers, stopping TrackerSocket.")
103
+ await self.stop()
104
+
105
+ async def stop(self):
106
+ """
107
+ 거래 내역 추적을 중지합니다.
108
+ """
109
+ if self.task is None or self.task.done():
110
+ self.logger.info("TrackerSocket already stopped!")
111
+ return
112
+
113
+ self.stop_event.set()
114
+ self.task.cancel()
115
+ try:
116
+ await self.task
117
+ except asyncio.CancelledError:
118
+ pass
119
+ self.logger.info("TrackerSocket stopped!")
120
+
121
+ self.task = asyncio.create_task(self._monitor_schedule())
122
+
123
+
18
124
  class TradingTracker:
19
125
  """
20
126
  거래 내역 추적을 위한 클래스입니다
@@ -69,7 +175,6 @@ class TradingTracker:
69
175
  """ 백그라운드로 실행되는 거래 이벤트 모니터링 Task """
70
176
 
71
177
  self.simple_api = simple_api
72
- self.stop_event = asyncio.Event()
73
178
  self.account_no = None
74
179
  self.fee_rate = fee_rate # 증권사 수수료율
75
180
  self.tax_rate = Decimal("0.0018") # KOSPI, KOSDAQ 매도시 거래세율 0.18%
@@ -77,6 +182,8 @@ class TradingTracker:
77
182
  self.ticker_date: dtm.datetime = None # 종목 정보 갱신 시간
78
183
  self.save_trading_history = False # 거래 내역 저장 여부
79
184
  self.last_t_day_tickers: Dict[str, Dict] = {}
185
+ self.tracker_socket = TrackerSocket(simple_api)
186
+ self.tracker_number = None
80
187
 
81
188
  self.started = False
82
189
  self.callback_id = 0
@@ -94,7 +201,7 @@ class TradingTracker:
94
201
  elif isinstance(self.simple_api, KISSimpleDomesticStock):
95
202
  self.account_no = self.simple_api.account_no + self.simple_api.account_product_code
96
203
 
97
- self.logger.info(f"Trading tarcker started! Account No: {self.account_no} / save history: {self.save_trading_history}")
204
+ self.logger.info(f"Trading tracker started! Account No: {self.account_no} / save history: {self.save_trading_history}")
98
205
 
99
206
  self._fetch_tickers()
100
207
  self._sync_positions_and_pending_orders()
@@ -104,8 +211,10 @@ class TradingTracker:
104
211
  for o in self.pending_orders:
105
212
  self.logger.info(f"- {o.order_no}({o.org_order_no})\t{o.side}\t{o.asset_code}\t{o.filled_quantity}/{o.quantity}\t{o.is_pending}")
106
213
 
214
+ self.tracker_number = self.tracker_socket.add_tracker(self._handle_order_event)
215
+
107
216
  self.tasks = [
108
- asyncio.create_task(self._monitor_trading()),
217
+ asyncio.create_task(self.tracker_socket.start()),
109
218
  asyncio.create_task(self._monitor_schedule()),
110
219
  ]
111
220
  self.started = True
@@ -142,25 +251,15 @@ class TradingTracker:
142
251
  """
143
252
  거래 내역 추적을 중지합니다
144
253
  """
145
- self.stop_event.set()
146
254
  for t in self.tasks:
147
255
  t.cancel()
148
-
149
256
  await asyncio.gather(*self.tasks)
257
+ await self.tracker_socket.remove_tracker(self.tracker_number)
150
258
  self.started = False
151
259
 
152
- async def _monitor_trading(self):
153
- try:
154
- async for event in self.simple_api.listen_order_event(self.stop_event):
155
- self._handle_order_event(event)
156
- except asyncio.CancelledError:
157
- return
158
- except Exception as e:
159
- self.logger.exception(f"Error on handling order event: {e}")
160
-
161
260
  async def _monitor_schedule(self):
162
261
  """거래 시간대별 작업을 위한 스케줄을 모니터링합니다"""
163
- while not self.stop_event.is_set():
262
+ while not self.tracker_socket.stop_event.is_set():
164
263
  market_schedule = get_market_schedule(dtm.date.today())
165
264
 
166
265
  if not market_schedule.full_day_closed:
@@ -177,6 +177,9 @@ def get_ohlcv_by_codes_for_period(
177
177
  cols = data["cols"]
178
178
  dataset = data["rows"]
179
179
 
180
+ if not dataset:
181
+ continue
182
+
180
183
  for code in dataset.keys():
181
184
  rows = dataset[code]
182
185
  for row in rows:
@@ -149,11 +149,11 @@ def get_all_day_data(
149
149
  source (str, optional): 데이터를 검색할 API. 'ebest' 또는 'kis'를 지정할 수 있습니다. 기본값은 'ebest'입니다.
150
150
  adjusted (bool): 수정주가 여부. 기본값은 True.
151
151
  ascending (bool): 오름차순 여부. 기본값은 True.
152
- exchange (Union[str, DataExchange]): 거래소. 기본값은 KRX.
152
+ exchange (Union[str, DataExchange]): 거래소. 기본값은 KRX. (cf. NXT의 경우 해당되지 않는 종목은 Empty DataFrame이 반환됩니다.)
153
153
 
154
154
  Returns:
155
155
  dict[str, pd.DataFrame]: 주식 코드를 키로 하고, 해당 주식의 일일 OHLCV 데이터가 포함된 pandas DataFrame을 값으로 하는 딕셔너리.
156
- 각 DataFrame에는 변환된 'time' 열이 포함되어 있으며, 이는 조회된 데이터의 시간을 나타냅니다. 'time' 열은 DataFrame의 인덱스로 설정됩니다.
156
+ 각 DataFrame에는 변환된 'time' 열이 포함되어 있으며, 이는 조회된 데이터의 시간을 나타냅니다. 'time' 열은 DataFrame의 인덱스로 설정되고 오름차순으로 정렬됩니다.
157
157
 
158
158
  DataFrame의 열은 다음과 같습니다:
159
159
 
@@ -95,6 +95,7 @@ def get_all_last_orderbooks():
95
95
 
96
96
  return result
97
97
 
98
+
98
99
  @singleton
99
100
  class TickEventListener:
100
101
  """
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes