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.
- {pyqqq-0.12.167 → pyqqq-0.12.169}/PKG-INFO +1 -1
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyproject.toml +1 -1
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/backtest/broker.py +109 -32
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/kis/domestic_stock.py +10 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/kis/simple.py +4 -4
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/tracker.py +114 -15
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/data/daily.py +3 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/data/minutes.py +2 -2
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/data/realtime.py +1 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/README.md +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/__init__.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/ai/__init__.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/ai/daily.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/ai/domestic.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/ai/market_schedule.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/ai/minute.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/backtest/__init__.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/backtest/environment.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/backtest/logger.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/backtest/positionprovider.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/backtest/strategy.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/backtest/utils.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/backtest/wallclock.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/__init__.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/ebest/__init__.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/ebest/domestic_stock.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/ebest/oauth.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/ebest/simple.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/ebest/tr_client.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/helper.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/kis/__init__.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/kis/oauth.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/kis/overseas_stock.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/kis/simple_overseas.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/kis/tr_client.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/brokerage/multiprocess_tracker.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/config.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/data/__init__.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/data/domestic.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/data/index.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/data/overseas.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/data/ticks.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/data/us_stocks.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/datatypes.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/executors/__init__.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/executors/hook.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/__init__.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/api_client.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/array.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/casting.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/compute.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/copycat.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/daily_tickers.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/display.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/kvstore.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/limiter.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/local_cache.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/logger.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/market_schedule.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/mock_api.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/position_classifier.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/retry.py +0 -0
- {pyqqq-0.12.167 → pyqqq-0.12.169}/pyqqq/utils/singleton.py +0 -0
|
@@ -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
|
-
|
|
474
|
-
|
|
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
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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.
|
|
570
|
-
dfs =
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
618
|
+
df = dfs[code]
|
|
619
|
+
if df.empty:
|
|
620
|
+
return df
|
|
577
621
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
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
|
-
|
|
583
|
-
|
|
584
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() <
|
|
687
|
+
if now.time() < open_time:
|
|
622
688
|
# 장 오픈 전은 전일 데이터 까지만 반환
|
|
623
689
|
df = _exclude_today(df)
|
|
624
|
-
elif now.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.
|
|
785
|
-
|
|
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: 넥스트레이드
|
|
324
|
+
# FIXME: 넥스트레이드 시장 (수능일 같은 경우 넥스트레이드는 시간 변동 없이 8시 그대로 오픈한다는 얘기가 있어서 확인 필요)
|
|
325
325
|
if data_exchange == DataExchange.NXT:
|
|
326
|
-
open_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
|
|
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.
|
|
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:
|
|
@@ -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
|
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|