pyqqq 0.12.166__py3-none-any.whl → 0.12.168__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.
- pyqqq/backtest/broker.py +16 -9
- pyqqq/brokerage/tracker.py +114 -15
- pyqqq/data/daily.py +25 -32
- pyqqq/data/domestic.py +33 -51
- pyqqq/data/minutes.py +32 -41
- pyqqq/datatypes.py +13 -1
- pyqqq/utils/compute.py +75 -17
- {pyqqq-0.12.166.dist-info → pyqqq-0.12.168.dist-info}/METADATA +1 -1
- {pyqqq-0.12.166.dist-info → pyqqq-0.12.168.dist-info}/RECORD +10 -10
- {pyqqq-0.12.166.dist-info → pyqqq-0.12.168.dist-info}/WHEEL +0 -0
pyqqq/backtest/broker.py
CHANGED
|
@@ -144,7 +144,7 @@ class BaseBroker(ABC):
|
|
|
144
144
|
raise NotImplementedError
|
|
145
145
|
|
|
146
146
|
@abstractmethod
|
|
147
|
-
def create_order(self, asset_code: str, side: OrderSide, quantity: int, order_type: OrderType = OrderType.MARKET, price: int | Decimal = 0) -> str:
|
|
147
|
+
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:
|
|
148
148
|
"""새로운 주문을 생성합니다.
|
|
149
149
|
|
|
150
150
|
Args:
|
|
@@ -153,6 +153,7 @@ class BaseBroker(ABC):
|
|
|
153
153
|
quantity (int): 주문 수량
|
|
154
154
|
order_type (OrderType, optional): 주문 타입 (기본값: MARKET)
|
|
155
155
|
price (int, optional): 지정가 주문 시 주문 가격 (기본값: 0)
|
|
156
|
+
exchange (OrderExchange, optional): 거래소 (기본값: KRX)
|
|
156
157
|
|
|
157
158
|
Returns:
|
|
158
159
|
str: 생성된 주문 번호
|
|
@@ -164,7 +165,7 @@ class BaseBroker(ABC):
|
|
|
164
165
|
raise NotImplementedError
|
|
165
166
|
|
|
166
167
|
@abstractmethod
|
|
167
|
-
def update_order(self, org_order_no: str, order_type: OrderType, price: int | Decimal, quantity: int = 0) -> str:
|
|
168
|
+
def update_order(self, org_order_no: str, order_type: OrderType, price: int | Decimal, quantity: int = 0, exchange: OrderExchange = OrderExchange.KRX) -> str:
|
|
168
169
|
"""기존 주문을 수정합니다.
|
|
169
170
|
|
|
170
171
|
Args:
|
|
@@ -288,21 +289,23 @@ class TradingBroker(BaseBroker):
|
|
|
288
289
|
def get_positions(self):
|
|
289
290
|
return self.trading_api.get_positions()
|
|
290
291
|
|
|
291
|
-
def create_order(self, asset_code: str, side: OrderSide, quantity: int, order_type: OrderType = OrderType.MARKET, price: int | Decimal = 0):
|
|
292
|
-
self.logger.debug(f"create_order: {asset_code} {side} {quantity} {order_type} {price}")
|
|
293
|
-
return self.trading_api.create_order(asset_code, side, quantity, order_type, price)
|
|
292
|
+
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
|
+
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=exchange)
|
|
294
295
|
|
|
295
|
-
def update_order(self, org_order_no: str, order_type: OrderType, price: int | Decimal, quantity: int = 0):
|
|
296
|
+
def update_order(self, org_order_no: str, order_type: OrderType, price: int | Decimal, quantity: int = 0, exchange: OrderExchange = OrderExchange.KRX):
|
|
296
297
|
if isinstance(self.trading_api, KISSimpleOverseasStock):
|
|
297
298
|
order = self._get_pending_order(org_order_no)
|
|
298
299
|
if order is not None:
|
|
299
300
|
self.logger.debug(f"update_order: ({order.asset_code}) {org_order_no} {order_type} {price} {quantity}")
|
|
301
|
+
# 해외주식의 경우 asset_code와 org_order_no를 사용하여 주문을 업데이트
|
|
302
|
+
# 또한 exchange 정보가 들어가지 않음
|
|
300
303
|
return self.trading_api.update_order(order.asset_code, org_order_no, order_type, price, quantity)
|
|
301
304
|
else:
|
|
302
305
|
raise ValueError(f"order not found: {org_order_no}")
|
|
303
306
|
else:
|
|
304
307
|
self.logger.debug(f"update_order: {org_order_no} {order_type} {price} {quantity}")
|
|
305
|
-
return self.trading_api.update_order(org_order_no, order_type, price, quantity)
|
|
308
|
+
return self.trading_api.update_order(org_order_no, order_type, price, quantity, exchange=exchange)
|
|
306
309
|
|
|
307
310
|
def cancel_order(self, order_no: str, quantity: int = 0):
|
|
308
311
|
if isinstance(self.trading_api, KISSimpleOverseasStock):
|
|
@@ -661,7 +664,8 @@ class MockBroker(BaseBroker):
|
|
|
661
664
|
positions = [p for p in self.positions if p.quantity > 0]
|
|
662
665
|
return positions
|
|
663
666
|
|
|
664
|
-
def create_order(self, asset_code: str, side: OrderSide, quantity: int, order_type: OrderType = OrderType.MARKET, price: int = 0):
|
|
667
|
+
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에 따라 다른 봉을 보는 처리가 추가로 되어야 함
|
|
665
669
|
price = self.get_price(asset_code) if order_type == OrderType.MARKET else price
|
|
666
670
|
self.logger.info(f"CREATE ORDER: {self._get_asset_name(asset_code)} side:{side} price:{price} quantity:{quantity} order_type:{order_type}")
|
|
667
671
|
order_no = str(self.next_order_no)
|
|
@@ -687,6 +691,7 @@ class MockBroker(BaseBroker):
|
|
|
687
691
|
pending_quantity=quantity,
|
|
688
692
|
order_type=order_type,
|
|
689
693
|
order_time=self.clock.now(),
|
|
694
|
+
exchange=exchange,
|
|
690
695
|
)
|
|
691
696
|
|
|
692
697
|
self.pending_orders.append(order)
|
|
@@ -694,7 +699,7 @@ class MockBroker(BaseBroker):
|
|
|
694
699
|
|
|
695
700
|
return order_no
|
|
696
701
|
|
|
697
|
-
def update_order(self, org_order_no: str, order_type: OrderType, price: int, quantity: int = 0):
|
|
702
|
+
def update_order(self, org_order_no: str, order_type: OrderType, price: int, quantity: int = 0, exchange: OrderExchange = OrderExchange.KRX):
|
|
698
703
|
order = next((order for order in self.pending_orders if order.order_no == org_order_no), None)
|
|
699
704
|
if order is None:
|
|
700
705
|
raise Exception(f"order not found: {org_order_no}")
|
|
@@ -727,6 +732,7 @@ class MockBroker(BaseBroker):
|
|
|
727
732
|
pending_quantity=quantity,
|
|
728
733
|
order_type=order_type,
|
|
729
734
|
order_time=self.clock.now(),
|
|
735
|
+
exchange=exchange,
|
|
730
736
|
)
|
|
731
737
|
|
|
732
738
|
order.pending_quantity -= quantity
|
|
@@ -788,6 +794,7 @@ class MockBroker(BaseBroker):
|
|
|
788
794
|
# 강제로 1분 차이를 만들어 줌
|
|
789
795
|
before_time = current_time - dtm.timedelta(seconds=60)
|
|
790
796
|
|
|
797
|
+
# TODO: exchange에 따라 다른 봉을 보는 처리가 추가로 되어야 함
|
|
791
798
|
df = self.get_minute_price(order.asset_code)
|
|
792
799
|
if df.empty:
|
|
793
800
|
self.logger.warning(f"ORDER CANCELED: {order.asset_code} minute data is empty")
|
pyqqq/brokerage/tracker.py
CHANGED
|
@@ -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:
|
pyqqq/data/daily.py
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
+
import datetime
|
|
1
2
|
from typing import Dict, List, Optional, Union
|
|
3
|
+
|
|
4
|
+
import pandas as pd
|
|
5
|
+
import pytz
|
|
6
|
+
|
|
7
|
+
import pyqqq.config as c
|
|
2
8
|
from pyqqq.datatypes import DataExchange
|
|
3
|
-
from pyqqq.utils.array import chunk
|
|
4
9
|
from pyqqq.utils.api_client import raise_for_status, send_request
|
|
10
|
+
from pyqqq.utils.array import chunk
|
|
5
11
|
from pyqqq.utils.local_cache import DiskCacheManager
|
|
6
12
|
from pyqqq.utils.logger import get_logger
|
|
7
|
-
import datetime as dtm
|
|
8
|
-
import pandas as pd
|
|
9
|
-
import pyqqq.config as c
|
|
10
|
-
import pytz
|
|
11
13
|
|
|
12
14
|
logger = get_logger(__name__)
|
|
13
15
|
dailyCache = DiskCacheManager("daily_cache")
|
|
@@ -15,9 +17,9 @@ dailyCache = DiskCacheManager("daily_cache")
|
|
|
15
17
|
|
|
16
18
|
@dailyCache.memoize()
|
|
17
19
|
def get_all_ohlcv_for_date(
|
|
18
|
-
date:
|
|
20
|
+
date: datetime.date,
|
|
19
21
|
adjusted: bool = True,
|
|
20
|
-
exchange: Union[
|
|
22
|
+
exchange: Union[str, DataExchange] = "KRX",
|
|
21
23
|
) -> pd.DataFrame:
|
|
22
24
|
"""
|
|
23
25
|
주어진 날짜에 대한 모든 주식의 OHLCV(Open, High, Low, Close, Volume) 데이터를 조회합니다.
|
|
@@ -29,9 +31,9 @@ def get_all_ohlcv_for_date(
|
|
|
29
31
|
NXT: 2025년 3월 4일 데이터 부터 조회 가능합니다.
|
|
30
32
|
|
|
31
33
|
Args:
|
|
32
|
-
date (
|
|
34
|
+
date (datetime.date): 조회할 날짜.
|
|
33
35
|
adjusted (bool): 수정주가 여부. 기본값은 True.
|
|
34
|
-
exchange (DataExchange): 거래소. 기본값은 KRX.
|
|
36
|
+
exchange (Union[str, DataExchange]): 거래소. 기본값은 KRX.
|
|
35
37
|
|
|
36
38
|
Returns:
|
|
37
39
|
pd.DataFrame: OHLCV 데이터를 포함하는 DataFrame. 'code' 컬럼은 DataFrame의 인덱스로 설정됩니다.
|
|
@@ -51,7 +53,7 @@ def get_all_ohlcv_for_date(
|
|
|
51
53
|
HTTPError: API 요청이 실패했을 때 발생.
|
|
52
54
|
|
|
53
55
|
Examples:
|
|
54
|
-
>>> ohlcv_data = get_all_ohlcv_for_date(
|
|
56
|
+
>>> ohlcv_data = get_all_ohlcv_for_date(datetime.date(2023, 5, 8))
|
|
55
57
|
>>> print(ohlcv_data)
|
|
56
58
|
open high low close volume value diff diff_rate
|
|
57
59
|
code
|
|
@@ -62,17 +64,17 @@ def get_all_ohlcv_for_date(
|
|
|
62
64
|
000075 54800 54900 54800 54800 177 9702400 -100 -0.18
|
|
63
65
|
...
|
|
64
66
|
"""
|
|
65
|
-
if isinstance(date,
|
|
67
|
+
if isinstance(date, datetime.datetime):
|
|
66
68
|
date = date.date()
|
|
67
69
|
|
|
68
|
-
exchange =
|
|
70
|
+
exchange = DataExchange.validate(exchange)
|
|
69
71
|
url = f"{c.PYQQQ_API_URL}/domestic-stock/ohlcv/daily/all/{date}"
|
|
70
72
|
r = send_request(
|
|
71
73
|
"GET",
|
|
72
74
|
url,
|
|
73
75
|
params={
|
|
74
76
|
"adjusted": "true" if adjusted else "false",
|
|
75
|
-
"current_date":
|
|
77
|
+
"current_date": datetime.date.today(),
|
|
76
78
|
"exchange": exchange.value,
|
|
77
79
|
},
|
|
78
80
|
)
|
|
@@ -96,11 +98,11 @@ def get_all_ohlcv_for_date(
|
|
|
96
98
|
|
|
97
99
|
def get_ohlcv_by_codes_for_period(
|
|
98
100
|
codes: List[str],
|
|
99
|
-
start_date:
|
|
100
|
-
end_date: Optional[
|
|
101
|
+
start_date: datetime.date,
|
|
102
|
+
end_date: Optional[datetime.date] = None,
|
|
101
103
|
adjusted: bool = True,
|
|
102
104
|
ascending: bool = False,
|
|
103
|
-
exchange: Union[
|
|
105
|
+
exchange: Union[str, DataExchange] = "KRX",
|
|
104
106
|
) -> Dict[str, pd.DataFrame]:
|
|
105
107
|
"""
|
|
106
108
|
지정된 코드 리스트와 기간에 대한 OHLCV 데이터를 조회합니다.
|
|
@@ -114,10 +116,11 @@ def get_ohlcv_by_codes_for_period(
|
|
|
114
116
|
|
|
115
117
|
Args:
|
|
116
118
|
codes (List[str]): 조회할 주식 코드들의 리스트.
|
|
117
|
-
start_date (
|
|
118
|
-
end_date (Optional[
|
|
119
|
+
start_date (datetime.date): 조회할 기간의 시작 날짜.
|
|
120
|
+
end_date (Optional[datetime.date]): 조회할 기간의 종료 날짜. 지정하지 않으면 최근 거래일 까지 조회됩니다.
|
|
119
121
|
adjusted (bool): 수정주가 여부. 기본값은 True.
|
|
120
122
|
ascending (bool): 날짜 오름차순 여부. 기본값은 False.
|
|
123
|
+
exchange (Union[str, DataExchange]): 거래소. 기본값은 KRX.
|
|
121
124
|
|
|
122
125
|
Returns:
|
|
123
126
|
dict: 주식 코드를 키로 하고, 해당 코드의 OHLCV 데이터를 포함하는 DataFrame을 값으로 하는 딕셔너리.
|
|
@@ -137,7 +140,7 @@ def get_ohlcv_by_codes_for_period(
|
|
|
137
140
|
HTTPError: API 요청이 실패했을 때 발생.
|
|
138
141
|
|
|
139
142
|
Examples:
|
|
140
|
-
>>> dfs = get_ohlcv_by_codes_for_period(['005930', '319640'],
|
|
143
|
+
>>> dfs = get_ohlcv_by_codes_for_period(['005930', '319640'], datetime.date(2024, 5, 7), datetime.date(2024, 5, 9))
|
|
141
144
|
>>> print(dfs)
|
|
142
145
|
{'319640': open high low close volume value diff diff_rate
|
|
143
146
|
date
|
|
@@ -150,7 +153,7 @@ def get_ohlcv_by_codes_for_period(
|
|
|
150
153
|
2024-05-08 80800 81400 80500 81300 12960682 1050108654400 0 0.00
|
|
151
154
|
2024-05-07 79600 81300 79400 81300 26238868 2112619288066 3700 4.77}
|
|
152
155
|
"""
|
|
153
|
-
exchange =
|
|
156
|
+
exchange = DataExchange.validate(exchange)
|
|
154
157
|
tz = pytz.timezone("Asia/Seoul")
|
|
155
158
|
chunks = chunk(codes, 20)
|
|
156
159
|
result = {}
|
|
@@ -161,7 +164,7 @@ def get_ohlcv_by_codes_for_period(
|
|
|
161
164
|
"codes": ",".join(asset_codes),
|
|
162
165
|
"start_date": start_date,
|
|
163
166
|
"adjusted": "true" if adjusted else "false",
|
|
164
|
-
"current_date":
|
|
167
|
+
"current_date": datetime.date.today(),
|
|
165
168
|
"exchange": exchange.value,
|
|
166
169
|
}
|
|
167
170
|
if end_date is not None:
|
|
@@ -180,7 +183,7 @@ def get_ohlcv_by_codes_for_period(
|
|
|
180
183
|
dt = row[0]
|
|
181
184
|
if dt[-1] == "Z":
|
|
182
185
|
dt = dt[:-1] + "+00:00"
|
|
183
|
-
dt =
|
|
186
|
+
dt = datetime.datetime.fromisoformat(dt).astimezone(tz).replace(tzinfo=None)
|
|
184
187
|
row[0] = dt
|
|
185
188
|
|
|
186
189
|
rows.reverse()
|
|
@@ -192,13 +195,3 @@ def get_ohlcv_by_codes_for_period(
|
|
|
192
195
|
result[code] = df
|
|
193
196
|
|
|
194
197
|
return result
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
def _validate_exchange(exchange: Union[str, DataExchange]) -> DataExchange:
|
|
198
|
-
if isinstance(exchange, str):
|
|
199
|
-
assert exchange in [e.value for e in DataExchange], "지원하지 않는 거래소 코드입니다."
|
|
200
|
-
exchange = DataExchange(exchange)
|
|
201
|
-
else:
|
|
202
|
-
assert exchange in DataExchange, "지원하지 않는 거래소 코드입니다."
|
|
203
|
-
|
|
204
|
-
return exchange
|
pyqqq/data/domestic.py
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
+
import datetime as dtm
|
|
2
|
+
from typing import List, Optional, Union
|
|
3
|
+
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
import pyqqq.config as c
|
|
8
|
+
from pyqqq.datatypes import DataExchange
|
|
1
9
|
from pyqqq.utils.api_client import raise_for_status, send_request
|
|
2
10
|
from pyqqq.utils.array import chunk
|
|
3
|
-
from pyqqq.utils.compute import quantize_adjusted_price
|
|
11
|
+
from pyqqq.utils.compute import get_krx_tick_size, quantize_adjusted_price
|
|
4
12
|
from pyqqq.utils.local_cache import DiskCacheManager
|
|
5
13
|
from pyqqq.utils.market_schedule import get_last_trading_day, get_market_schedule
|
|
6
|
-
from typing import List, Optional, Union
|
|
7
|
-
import datetime as dtm
|
|
8
|
-
import pandas as pd
|
|
9
|
-
import numpy as np
|
|
10
|
-
import pyqqq.config as c
|
|
11
14
|
|
|
12
15
|
domesticCache = DiskCacheManager("domestic_cache")
|
|
13
16
|
|
|
@@ -227,19 +230,27 @@ def _isoformat_to_readable(isodate: str) -> str:
|
|
|
227
230
|
|
|
228
231
|
|
|
229
232
|
@domesticCache.memoize()
|
|
230
|
-
def get_tickers(
|
|
233
|
+
def get_tickers(
|
|
234
|
+
date: Optional[dtm.date] = None,
|
|
235
|
+
market: Optional[str] = None,
|
|
236
|
+
adjusted: Optional[bool] = True,
|
|
237
|
+
exchange: Union[str, DataExchange] = "KRX",
|
|
238
|
+
):
|
|
231
239
|
"""
|
|
232
240
|
주어진 날짜와 시장에 따른 주식 종목 코드와 관련 정보를 조회합니다.
|
|
233
241
|
|
|
234
242
|
이 함수는 지정된 날짜(기본값은 오늘)와 선택적 시장('KOSPI', 'KOSDAQ')에 대한 주식 종목 코드와 추가 정보를 API를 통해 요청합니다.
|
|
235
243
|
반환된 정보는 pandas DataFrame 형태로 제공되며, 데이터가 없는 경우 빈 DataFrame을 반환합니다. DataFrame은 'code'를 인덱스로 사용합니다.
|
|
236
244
|
|
|
245
|
+
KRX 거래소에서는 거래정지 종목 등이 포함되어 있으나, NXT 거래소에서는 거래정지 종목이 제외되어 있습니다.
|
|
246
|
+
|
|
237
247
|
2018년 1월 1일 데이터 부터 조회 가능합니다. 수정주가는 소수점 첫째 자리에서 반올림합니다.
|
|
238
248
|
|
|
239
249
|
Args:
|
|
240
250
|
date (Optional[dtm.date]): 조회할 날짜. 기본값은 현재 날짜입니다.
|
|
241
|
-
market (Optional[str]): 조회할 시장. 'KOSPI' 또는 'KOSDAQ' 중 선택할 수 있습니다.
|
|
251
|
+
market (Optional[str]): 조회할 시장. 'KOSPI' 또는 'KOSDAQ' 중 선택할 수 있습니다. 기본값은 None 이며, 모든 시장을 조회합니다.
|
|
242
252
|
adjusted (Optional[bool]): 수정주가 여부. 기본값은 True.
|
|
253
|
+
exchange (Union[str, DataExchange]): 조회할 거래소. 'KRX' 또는 'NXT' 중 선택할 수 있습니다. 기본값은 'KRX' 입니다.
|
|
243
254
|
|
|
244
255
|
Returns:
|
|
245
256
|
pd.DataFrame: 주식 종목 코드와 관련 정보를 포함하는 DataFrame. 'code' 컬럼은 인덱스로 설정됩니다.
|
|
@@ -274,13 +285,16 @@ def get_tickers(date: Optional[dtm.date] = None, market: Optional[str] = None, a
|
|
|
274
285
|
"KOSDAQ",
|
|
275
286
|
], "market은 'KOSPI' 또는 'KOSDAQ'이어야 합니다."
|
|
276
287
|
|
|
288
|
+
# TODO: NXT 거래소 2025년 3월 4일 이전 요청 시 에러 발생
|
|
289
|
+
exchange = DataExchange.validate(exchange)
|
|
290
|
+
|
|
277
291
|
if date is None:
|
|
278
292
|
date = dtm.date.today()
|
|
279
293
|
schedule = get_market_schedule(date)
|
|
280
294
|
if schedule.full_day_closed:
|
|
281
295
|
date = get_last_trading_day(date)
|
|
282
296
|
|
|
283
|
-
return _get_tickers(date, market, adjusted)
|
|
297
|
+
return _get_tickers(date, market, adjusted, exchange)
|
|
284
298
|
|
|
285
299
|
|
|
286
300
|
def _get_tickers_check_not_expected_res(res):
|
|
@@ -291,7 +305,7 @@ def _get_tickers_check_not_expected_res(res):
|
|
|
291
305
|
|
|
292
306
|
|
|
293
307
|
@domesticCache.memoize(not_expected_res=_get_tickers_check_not_expected_res)
|
|
294
|
-
def _get_tickers(date: dtm.date, market: str = None, adjusted: bool = True):
|
|
308
|
+
def _get_tickers(date: dtm.date, market: str = None, adjusted: bool = True, exchange: DataExchange = DataExchange.KRX):
|
|
295
309
|
"""
|
|
296
310
|
get_tickers 함수의 실제 구현부.
|
|
297
311
|
기존 함수에선 date가 None이어도 정상적으로 돌아서 메모이제이션 하기 좋지않았음.
|
|
@@ -300,6 +314,7 @@ def _get_tickers(date: dtm.date, market: str = None, adjusted: bool = True):
|
|
|
300
314
|
params = {
|
|
301
315
|
"adjusted": "true" if adjusted else "false",
|
|
302
316
|
"current_date": dtm.date.today(),
|
|
317
|
+
"exchange": exchange.value,
|
|
303
318
|
}
|
|
304
319
|
if market:
|
|
305
320
|
params["market"] = market
|
|
@@ -333,50 +348,17 @@ def _get_tickers(date: dtm.date, market: str = None, adjusted: bool = True):
|
|
|
333
348
|
def calculate_price_limit(row):
|
|
334
349
|
price = row["reference_price"]
|
|
335
350
|
market = row["market"]
|
|
351
|
+
etf_etn = row["type"] == "ETF" or row["type"] == "ETN"
|
|
352
|
+
|
|
353
|
+
tick_size = get_krx_tick_size(price, etf_etn, market, date)
|
|
336
354
|
|
|
337
|
-
# 상한가 계산 (1.3배)
|
|
355
|
+
# 상한가 계산 (1.3배) 후 호가단위로 절삭 (내림)
|
|
338
356
|
upper = price * 1.3
|
|
339
|
-
|
|
340
|
-
lower = price * 0.7
|
|
357
|
+
upper = int(upper // tick_size * tick_size)
|
|
341
358
|
|
|
342
|
-
#
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
if price < 1000:
|
|
346
|
-
return 1
|
|
347
|
-
elif price < 5000:
|
|
348
|
-
return 5
|
|
349
|
-
elif price < 10000:
|
|
350
|
-
return 10
|
|
351
|
-
elif price < 50000:
|
|
352
|
-
return 50
|
|
353
|
-
elif price < 100000:
|
|
354
|
-
return 100
|
|
355
|
-
elif price < 500000:
|
|
356
|
-
return 500
|
|
357
|
-
else:
|
|
358
|
-
return 1000
|
|
359
|
-
elif market == "KOSDAQ":
|
|
360
|
-
if price < 1000:
|
|
361
|
-
return 1
|
|
362
|
-
elif price < 5000:
|
|
363
|
-
return 5
|
|
364
|
-
elif price < 10000:
|
|
365
|
-
return 10
|
|
366
|
-
elif price < 50000:
|
|
367
|
-
return 50
|
|
368
|
-
else:
|
|
369
|
-
return 100
|
|
370
|
-
return 1 # 기본값
|
|
371
|
-
|
|
372
|
-
# 호가단위로 절삭
|
|
373
|
-
upper_tick = get_tick_size(upper, market)
|
|
374
|
-
lower_tick = get_tick_size(lower, market)
|
|
375
|
-
|
|
376
|
-
# 호가단위로 절삭 (내림)
|
|
377
|
-
upper = int(upper // upper_tick * upper_tick)
|
|
378
|
-
# 호가단위로 절삭 (올림)
|
|
379
|
-
lower = int((lower + lower_tick - 0.01) // lower_tick * lower_tick)
|
|
359
|
+
# 하한가 계산 (0.7배) 후 호가단위로 절삭 (올림)
|
|
360
|
+
lower = price * 0.7
|
|
361
|
+
lower = int((lower + tick_size - 0.01) // tick_size * tick_size)
|
|
380
362
|
|
|
381
363
|
return pd.Series([upper, lower])
|
|
382
364
|
|
pyqqq/data/minutes.py
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
+
import datetime
|
|
1
2
|
from typing import Dict, Union
|
|
2
|
-
|
|
3
|
-
from pyqqq.utils.api_client import raise_for_status, send_request
|
|
4
|
-
from pyqqq.utils.local_cache import DiskCacheManager
|
|
5
|
-
from pyqqq.utils.logger import get_logger
|
|
6
|
-
import datetime as dtm
|
|
3
|
+
|
|
7
4
|
import numpy as np
|
|
8
5
|
import pandas as pd
|
|
9
|
-
import pyqqq.config as c
|
|
10
6
|
import pytz
|
|
11
7
|
|
|
8
|
+
import pyqqq.config as c
|
|
9
|
+
from pyqqq.datatypes import DataExchange
|
|
10
|
+
from pyqqq.utils.api_client import raise_for_status, send_request
|
|
11
|
+
from pyqqq.utils.local_cache import DiskCacheManager
|
|
12
|
+
from pyqqq.utils.logger import get_logger
|
|
12
13
|
|
|
13
14
|
logger = get_logger(__name__)
|
|
14
15
|
minuteCache = DiskCacheManager("minute_cache")
|
|
@@ -16,10 +17,10 @@ minuteCache = DiskCacheManager("minute_cache")
|
|
|
16
17
|
|
|
17
18
|
@minuteCache.memoize()
|
|
18
19
|
def get_all_minute_data(
|
|
19
|
-
time:
|
|
20
|
+
time: datetime.datetime,
|
|
20
21
|
source: str = "ebest",
|
|
21
22
|
adjusted: bool = True,
|
|
22
|
-
exchange: Union[
|
|
23
|
+
exchange: Union[str, DataExchange] = "KRX",
|
|
23
24
|
) -> pd.DataFrame:
|
|
24
25
|
"""
|
|
25
26
|
모든 종목의 분봉 데이터를 반환합니다.
|
|
@@ -29,10 +30,10 @@ def get_all_minute_data(
|
|
|
29
30
|
NXT 거래소 데이터의 조회 가능 시작일은 데이터 소스에 따라 다릅니다. kis는 2025년 3월 4일부터, ebest는 2025년 5월 12일부터 데이터를 조회할 수 있습니다.
|
|
30
31
|
|
|
31
32
|
Args:
|
|
32
|
-
time (
|
|
33
|
+
time (datetime.datetime): 조회할 시간
|
|
33
34
|
source (str): 데이터를 검색할 API. 'ebest' 또는 'kis'를 지정할 수 있습니다. 기본값은 'ebest'입니다.
|
|
34
35
|
adjusted (bool): 수정주가 여부. 기본값은 True.
|
|
35
|
-
exchange (Union[
|
|
36
|
+
exchange (Union[str, DataExchange]): 거래소. 기본값은 KRX.
|
|
36
37
|
|
|
37
38
|
Returns:
|
|
38
39
|
pd.DataFrame: 모든 종목의 분봉 데이터가 포함된 pandas DataFrame.
|
|
@@ -63,7 +64,7 @@ def get_all_minute_data(
|
|
|
63
64
|
- msvolumetm (int): 시간별매수체결량
|
|
64
65
|
|
|
65
66
|
Examples:
|
|
66
|
-
>>> df = get_all_minute_data(
|
|
67
|
+
>>> df = get_all_minute_data(datetime.datetime(2024, 5, 2, 15, 30))
|
|
67
68
|
>>> print(df)
|
|
68
69
|
time open high low ... totofferrem totbidrem mdvolumetm msvolumetm
|
|
69
70
|
code ...
|
|
@@ -76,13 +77,13 @@ def get_all_minute_data(
|
|
|
76
77
|
[5 rows x 23 columns]
|
|
77
78
|
"""
|
|
78
79
|
tz = pytz.timezone("Asia/Seoul")
|
|
79
|
-
exchange =
|
|
80
|
+
exchange = DataExchange.validate(exchange)
|
|
80
81
|
|
|
81
82
|
url = f"{c.PYQQQ_API_URL}/domestic-stock/ohlcv/minutes/all/{time.date()}/{time.strftime('%H%M')}"
|
|
82
83
|
params = {
|
|
83
84
|
"brokerage": source,
|
|
84
85
|
"adjusted": "true" if adjusted else "false",
|
|
85
|
-
"current_date":
|
|
86
|
+
"current_date": datetime.date.today(),
|
|
86
87
|
"exchange": exchange.value,
|
|
87
88
|
}
|
|
88
89
|
|
|
@@ -94,7 +95,7 @@ def get_all_minute_data(
|
|
|
94
95
|
rows = r.json()
|
|
95
96
|
for data in rows:
|
|
96
97
|
time = data["time"].replace("Z", "+00:00")
|
|
97
|
-
time =
|
|
98
|
+
time = datetime.datetime.fromisoformat(time).astimezone(tz).replace(tzinfo=None)
|
|
98
99
|
data["time"] = time
|
|
99
100
|
|
|
100
101
|
df = pd.DataFrame(rows)
|
|
@@ -126,13 +127,13 @@ def get_all_minute_data(
|
|
|
126
127
|
|
|
127
128
|
@minuteCache.memoize()
|
|
128
129
|
def get_all_day_data(
|
|
129
|
-
date:
|
|
130
|
+
date: datetime.date,
|
|
130
131
|
codes: list[str] | str,
|
|
131
|
-
period:
|
|
132
|
+
period: datetime.timedelta = datetime.timedelta(minutes=1),
|
|
132
133
|
source: str = "ebest",
|
|
133
134
|
adjusted: bool = True,
|
|
134
135
|
ascending: bool = True,
|
|
135
|
-
exchange: Union[
|
|
136
|
+
exchange: Union[str, DataExchange] = "KRX",
|
|
136
137
|
) -> dict[str, pd.DataFrame] | pd.DataFrame:
|
|
137
138
|
"""
|
|
138
139
|
지정된 날짜에 대해 하나 이상의 주식 코드에 대한 전체 분별 OHLCV(시가, 고가, 저가, 종가, 거래량) 데이터를 검색하여 반환합니다.
|
|
@@ -142,13 +143,13 @@ def get_all_day_data(
|
|
|
142
143
|
NXT 거래소 데이터의 조회 가능 시작일은 데이터 소스에 따라 다릅니다. kis는 2025년 3월 4일부터, ebest는 2025년 5월 12일부터 데이터를 조회할 수 있습니다.
|
|
143
144
|
|
|
144
145
|
Args:
|
|
145
|
-
date (
|
|
146
|
+
date (datetime.date): 데이터를 검색할 날짜.
|
|
146
147
|
codes (list[str]): 조회할 주식 코드들의 리스트. 최대 20개까지 지정할 수 있습니다.
|
|
147
|
-
period (
|
|
148
|
+
period (datetime.timedelta, optional): 반환된 데이터의 시간 간격. 기본값은 1분입니다. 30초 이상의 값을 30초간격으로 지정할 수 있습니다.
|
|
148
149
|
source (str, optional): 데이터를 검색할 API. 'ebest' 또는 'kis'를 지정할 수 있습니다. 기본값은 'ebest'입니다.
|
|
149
150
|
adjusted (bool): 수정주가 여부. 기본값은 True.
|
|
150
151
|
ascending (bool): 오름차순 여부. 기본값은 True.
|
|
151
|
-
exchange (Union[
|
|
152
|
+
exchange (Union[str, DataExchange]): 거래소. 기본값은 KRX.
|
|
152
153
|
|
|
153
154
|
Returns:
|
|
154
155
|
dict[str, pd.DataFrame]: 주식 코드를 키로 하고, 해당 주식의 일일 OHLCV 데이터가 포함된 pandas DataFrame을 값으로 하는 딕셔너리.
|
|
@@ -183,7 +184,7 @@ def get_all_day_data(
|
|
|
183
184
|
requests.exceptions.RequestException: PYQQQ API로부터 데이터를 검색하는 과정에서 오류가 발생한 경우.
|
|
184
185
|
|
|
185
186
|
Examples:
|
|
186
|
-
>>> result = get_all_day_data(
|
|
187
|
+
>>> result = get_all_day_data(datetime.date(2024, 4, 26), ["005930", "319640"], datetime.timedelta(minutes=1))
|
|
187
188
|
>>> print(result["069500"])
|
|
188
189
|
open high low close volume sign change diff \
|
|
189
190
|
time
|
|
@@ -193,7 +194,7 @@ def get_all_day_data(
|
|
|
193
194
|
2024-04-26 09:03:00 77500 77500 77200 77500 3033307 2 1200 1.57
|
|
194
195
|
2024-04-26 09:04:00 77400 77600 77400 77500 3268502 2 1200 1.57
|
|
195
196
|
"""
|
|
196
|
-
assert isinstance(date,
|
|
197
|
+
assert isinstance(date, datetime.date), "date must be a datetime.date object"
|
|
197
198
|
assert isinstance(codes, list) or isinstance(codes, str), "codes must be a list of strings or single code"
|
|
198
199
|
|
|
199
200
|
if isinstance(codes, list):
|
|
@@ -202,13 +203,13 @@ def get_all_day_data(
|
|
|
202
203
|
assert len(codes) <= 20, "codes must not exceed 20"
|
|
203
204
|
|
|
204
205
|
if period is not None:
|
|
205
|
-
assert period >=
|
|
206
|
+
assert period >= datetime.timedelta(seconds=30), "period must be at least 30 seconds"
|
|
206
207
|
assert period.total_seconds() % 30 == 0, "period must be a multiple of 30 seconds"
|
|
207
208
|
|
|
208
209
|
tz = pytz.timezone("Asia/Seoul")
|
|
209
210
|
target_codes = codes if isinstance(codes, list) else [codes]
|
|
210
211
|
|
|
211
|
-
exchange =
|
|
212
|
+
exchange = DataExchange.validate(exchange)
|
|
212
213
|
if exchange == DataExchange.NXT or source == "kis":
|
|
213
214
|
url = f"{c.PYQQQ_API_URL}/domestic-stock/ohlcv/minutes/{date}"
|
|
214
215
|
else:
|
|
@@ -221,7 +222,7 @@ def get_all_day_data(
|
|
|
221
222
|
"codes": ",".join(target_codes) if target_codes else None,
|
|
222
223
|
"brokerage": source,
|
|
223
224
|
"adjusted": "true" if adjusted else "false",
|
|
224
|
-
"current_date":
|
|
225
|
+
"current_date": datetime.date.today(),
|
|
225
226
|
"exchange": exchange.value,
|
|
226
227
|
},
|
|
227
228
|
)
|
|
@@ -246,7 +247,7 @@ def get_all_day_data(
|
|
|
246
247
|
rows = multirows[code]
|
|
247
248
|
for row in rows:
|
|
248
249
|
time = row[time_index].replace("Z", "+00:00")
|
|
249
|
-
time =
|
|
250
|
+
time = datetime.datetime.fromisoformat(time).astimezone(tz).replace(tzinfo=None)
|
|
250
251
|
row[time_index] = time
|
|
251
252
|
|
|
252
253
|
rows.reverse()
|
|
@@ -270,7 +271,7 @@ def get_all_day_data(
|
|
|
270
271
|
|
|
271
272
|
def resample_ebest_data(df, period):
|
|
272
273
|
if period is not None and period.total_seconds() != 30:
|
|
273
|
-
df["time"] = df["time"] -
|
|
274
|
+
df["time"] = df["time"] - datetime.timedelta(seconds=30)
|
|
274
275
|
df.set_index("time", inplace=True)
|
|
275
276
|
|
|
276
277
|
minutes = period.total_seconds() / 60
|
|
@@ -368,13 +369,13 @@ def resample_kis_data(df, period):
|
|
|
368
369
|
return df
|
|
369
370
|
|
|
370
371
|
|
|
371
|
-
def get_orderbook(code: str, time:
|
|
372
|
+
def get_orderbook(code: str, time: datetime.datetime) -> Dict:
|
|
372
373
|
"""
|
|
373
374
|
주식 종목의 주문 호가 정보를 반환합니다.
|
|
374
375
|
|
|
375
376
|
Args:
|
|
376
377
|
code (str): 종목 코드
|
|
377
|
-
time (
|
|
378
|
+
time (datetime.datetime): 조회할 시간
|
|
378
379
|
|
|
379
380
|
Returns:
|
|
380
381
|
dict: 호가 정보가 포함된 사전.
|
|
@@ -384,7 +385,7 @@ def get_orderbook(code: str, time: dtm.datetime) -> Dict:
|
|
|
384
385
|
- ask_volume (int): 1차 매도 호가 잔량.
|
|
385
386
|
- bid_price (int): 1차 매수 호가 가격.
|
|
386
387
|
- bid_volume (int): 1차 매수 호가 잔량.
|
|
387
|
-
- time (
|
|
388
|
+
- time (datetime.datetime): 현지 기준 호가 정보 조회 시간.
|
|
388
389
|
- bids (list): 매수 호가 목록 (각 항목은 price와 volume을 포함하는 dict).
|
|
389
390
|
- asks (list): 매도 호가 목록 (각 항목은 price과 volume을 포함하는 dict).
|
|
390
391
|
"""
|
|
@@ -398,16 +399,6 @@ def get_orderbook(code: str, time: dtm.datetime) -> Dict:
|
|
|
398
399
|
|
|
399
400
|
data = r.json()
|
|
400
401
|
data.pop("code")
|
|
401
|
-
data["time"] =
|
|
402
|
+
data["time"] = datetime.datetime.fromisoformat(data["time"]).astimezone(pytz.timezone("Asia/Seoul")).replace(tzinfo=None)
|
|
402
403
|
|
|
403
404
|
return data
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
def _validate_exchange(exchange: Union[str, DataExchange]) -> DataExchange:
|
|
407
|
-
if isinstance(exchange, str):
|
|
408
|
-
assert exchange in [e.value for e in DataExchange], "지원하지 않는 거래소 코드입니다."
|
|
409
|
-
exchange = DataExchange(exchange)
|
|
410
|
-
else:
|
|
411
|
-
assert exchange in DataExchange, "지원하지 않는 거래소 코드입니다."
|
|
412
|
-
|
|
413
|
-
return exchange
|
pyqqq/datatypes.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from dataclasses import dataclass
|
|
2
2
|
from enum import Enum
|
|
3
3
|
from decimal import Decimal
|
|
4
|
-
from typing import Optional
|
|
4
|
+
from typing import Optional, Union
|
|
5
5
|
import datetime
|
|
6
6
|
|
|
7
7
|
|
|
@@ -20,6 +20,18 @@ class DataExchange(Enum):
|
|
|
20
20
|
NXT = "NXT"
|
|
21
21
|
""" 넥스트레이드 """
|
|
22
22
|
|
|
23
|
+
@classmethod
|
|
24
|
+
def validate(cls, exchange: Union[str, "DataExchange"]) -> "DataExchange":
|
|
25
|
+
if isinstance(exchange, cls):
|
|
26
|
+
return exchange
|
|
27
|
+
if isinstance(exchange, str):
|
|
28
|
+
try:
|
|
29
|
+
return cls(exchange)
|
|
30
|
+
except ValueError:
|
|
31
|
+
raise ValueError(f"지원하지 않는 거래소 코드입니다: {exchange}")
|
|
32
|
+
|
|
33
|
+
raise TypeError(f"exchange는 str 또는 DataExchange 타입이어야 합니다. 현재 타입: {type(exchange)}")
|
|
34
|
+
|
|
23
35
|
|
|
24
36
|
class OrderExchange(Enum):
|
|
25
37
|
KRX = 1
|
pyqqq/utils/compute.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
|
|
1
|
+
import datetime
|
|
2
|
+
from decimal import ROUND_CEILING, ROUND_FLOOR, ROUND_HALF_UP, Decimal
|
|
2
3
|
from typing import Union
|
|
3
4
|
|
|
4
5
|
|
|
@@ -45,19 +46,30 @@ def quantize_krx_price(price: Union[Decimal, int, float], etf_etn: bool, roundin
|
|
|
45
46
|
return int((price / tick_size).quantize(Decimal("1"), rounding=constant_rounding) * tick_size)
|
|
46
47
|
|
|
47
48
|
|
|
48
|
-
def get_krx_tick_size(
|
|
49
|
+
def get_krx_tick_size(
|
|
50
|
+
price: float,
|
|
51
|
+
etf_etn: bool,
|
|
52
|
+
market: str = "KOSPI",
|
|
53
|
+
date: datetime.date = None,
|
|
54
|
+
) -> int:
|
|
49
55
|
"""
|
|
50
|
-
주어진 가격과 금융 상품 유형에 따라 적절한
|
|
56
|
+
주어진 가격과 금융 상품 유형에 따라 적절한 호가가격단위를 반환합니다.
|
|
51
57
|
|
|
52
|
-
한국거래소(KRX)의
|
|
53
|
-
입력된 price가 각 가격대의 최소값 미만일 경우 해당하는
|
|
58
|
+
한국거래소(KRX)의 호가가격단위 규칙에 따라, 특정 가격대의 주식 또는 ETF/ETN의 최소 가격 변동 단위(호가가격단위)를 결정합니다.
|
|
59
|
+
입력된 price가 각 가격대의 최소값 미만일 경우 해당하는 호가가격단위를 반환하며, 모든 조건에 부합하지 않는 경우 최대 가격을 반환합니다.
|
|
60
|
+
|
|
61
|
+
날짜별 규칙 변경사항:
|
|
62
|
+
- ETF/ETN: 2023-12-11 이전 5원, 이후 2000원 미만 1원/이상 5원
|
|
63
|
+
- 일반 주식: 2023-01-25 이전 market별 규칙, 이후 현재 통합 규칙
|
|
54
64
|
|
|
55
65
|
Args:
|
|
56
66
|
price (float): 상품의 가격.
|
|
57
67
|
etf_etn (bool): 상품이 ETF 또는 ETN인 경우 True, 아니면 False.
|
|
68
|
+
market (str): 상품의 시장. 기본값은 "KOSPI".
|
|
69
|
+
date (datetime.date): 상품의 날짜. 기본값은 None.
|
|
58
70
|
|
|
59
71
|
Returns:
|
|
60
|
-
int: 결정된
|
|
72
|
+
int: 결정된 호가가격단위.
|
|
61
73
|
|
|
62
74
|
Raises:
|
|
63
75
|
AssertionError: price가 0 이하일 경우 오류를 발생시킵니다.
|
|
@@ -73,22 +85,68 @@ def get_krx_tick_size(price: float, etf_etn: bool) -> int:
|
|
|
73
85
|
|
|
74
86
|
assert price > 0, "price should be greater than 0"
|
|
75
87
|
|
|
88
|
+
# 날짜가 None인 경우 현재 날짜를 사용
|
|
89
|
+
if date is None:
|
|
90
|
+
date = datetime.date.today()
|
|
91
|
+
|
|
76
92
|
conds = []
|
|
77
93
|
max_value = 0
|
|
78
94
|
|
|
79
95
|
if etf_etn:
|
|
80
|
-
|
|
81
|
-
|
|
96
|
+
# 2023년 12월 11일 이전에는 ETF/ETN 상품에 대해 가격에 상관없이 5원
|
|
97
|
+
etf_rule_change_date = datetime.date(2023, 12, 11)
|
|
98
|
+
if date < etf_rule_change_date:
|
|
99
|
+
conds = [] # 조건 없이 max_value로 처리
|
|
100
|
+
max_value = 5
|
|
101
|
+
else:
|
|
102
|
+
conds = [(2000, 1)]
|
|
103
|
+
max_value = 5
|
|
82
104
|
else:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
105
|
+
# 일반 주식: 2023년 1월 25일 이전에는 market에 따른 규칙 적용
|
|
106
|
+
stock_rule_change_date = datetime.date(2023, 1, 25)
|
|
107
|
+
|
|
108
|
+
if date < stock_rule_change_date:
|
|
109
|
+
# 2023년 1월 25일 이전: market에 따른 규칙
|
|
110
|
+
if market == "KOSPI":
|
|
111
|
+
conds = [
|
|
112
|
+
(1000, 1),
|
|
113
|
+
(5000, 5),
|
|
114
|
+
(10000, 10),
|
|
115
|
+
(50000, 50),
|
|
116
|
+
(100000, 100),
|
|
117
|
+
(500000, 500),
|
|
118
|
+
]
|
|
119
|
+
max_value = 1000
|
|
120
|
+
elif market == "KOSDAQ":
|
|
121
|
+
conds = [
|
|
122
|
+
(1000, 1),
|
|
123
|
+
(5000, 5),
|
|
124
|
+
(10000, 10),
|
|
125
|
+
(50000, 50),
|
|
126
|
+
]
|
|
127
|
+
max_value = 100
|
|
128
|
+
else:
|
|
129
|
+
# 기본값 (KOSPI 규칙)
|
|
130
|
+
conds = [
|
|
131
|
+
(1000, 1),
|
|
132
|
+
(5000, 5),
|
|
133
|
+
(10000, 10),
|
|
134
|
+
(50000, 50),
|
|
135
|
+
(100000, 100),
|
|
136
|
+
(500000, 500),
|
|
137
|
+
]
|
|
138
|
+
max_value = 1000
|
|
139
|
+
else:
|
|
140
|
+
# 2023년 1월 25일 이후: 현재 규칙
|
|
141
|
+
conds = [
|
|
142
|
+
(2000, 1),
|
|
143
|
+
(5000, 5),
|
|
144
|
+
(20000, 10),
|
|
145
|
+
(50000, 50),
|
|
146
|
+
(200000, 100),
|
|
147
|
+
(500000, 500),
|
|
148
|
+
]
|
|
149
|
+
max_value = 1000
|
|
92
150
|
|
|
93
151
|
for min_price, size in conds:
|
|
94
152
|
if price < min_price:
|
|
@@ -5,7 +5,7 @@ pyqqq/ai/domestic.py,sha256=FiJNInRlhcnxG7Jxmz2hDvaLhS8_jn-JFpQMze8Ch9s,1888
|
|
|
5
5
|
pyqqq/ai/market_schedule.py,sha256=8HiivwC-xI2EKr8lXS_g4mTj2LYpCQ2QfZsJmIq61O0,818
|
|
6
6
|
pyqqq/ai/minute.py,sha256=C0sTVkBY4-Vuj8Q9VZ7d9kZYAv963FUX4k3vIvhetng,1754
|
|
7
7
|
pyqqq/backtest/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
-
pyqqq/backtest/broker.py,sha256=
|
|
8
|
+
pyqqq/backtest/broker.py,sha256=9paaihryHhdtPawvmxvMcxHdIX-aXB3fOgDz2t90YgQ,58490
|
|
9
9
|
pyqqq/backtest/environment.py,sha256=Vb9h-rh_gS--1Ku99tD36XtH0bVgYsPHqKxP1lT0XEQ,8890
|
|
10
10
|
pyqqq/backtest/logger.py,sha256=BmoEMjUU76z8rZtMCYCwbspD3AVaHJrdbbT1EAFgrAE,3294
|
|
11
11
|
pyqqq/backtest/positionprovider.py,sha256=wrR7Bntg28Q5_vGQV6XNzxe-SYoO9_GLcV9gDVEDAN4,4164
|
|
@@ -27,25 +27,25 @@ pyqqq/brokerage/kis/simple.py,sha256=qX0LpvrC8vsu2PIW0v9fc25hzNQxPjJ-OY53rZr8q2I
|
|
|
27
27
|
pyqqq/brokerage/kis/simple_overseas.py,sha256=1DuQBuJosg0mJQV7Ey2N3UOY8F3uOhzPDay4ncothuc,50360
|
|
28
28
|
pyqqq/brokerage/kis/tr_client.py,sha256=9fTok0d8FmfXw4YxZSdn6T8UTHIG2aN1yMSkiMJUB3c,5530
|
|
29
29
|
pyqqq/brokerage/multiprocess_tracker.py,sha256=Xx0hSpRZYITBGWjxclOEtNZdHV5agX94s34q1A8EE-Y,7283
|
|
30
|
-
pyqqq/brokerage/tracker.py,sha256=
|
|
30
|
+
pyqqq/brokerage/tracker.py,sha256=z_bZ3O-G0oaEE7OBJawd2GbQwvPh-lKOLdWXOV1m1mw,23981
|
|
31
31
|
pyqqq/config.py,sha256=55Vqc_pGkdbrBdCV1aLgoH_n5IFxmMC59sbPHId3LoI,498
|
|
32
32
|
pyqqq/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
33
|
-
pyqqq/data/daily.py,sha256=
|
|
34
|
-
pyqqq/data/domestic.py,sha256=
|
|
33
|
+
pyqqq/data/daily.py,sha256=hLrVf5COqrZNXXuzp_CDDsBAHDl-I6-82ySkelkMQPU,7973
|
|
34
|
+
pyqqq/data/domestic.py,sha256=2FOYxDGw2W7DGwY61p3uGFb4IWqWUKiNdR3RlewjkCU,30352
|
|
35
35
|
pyqqq/data/index.py,sha256=d5b-8a7IXu7yNJWt1tIe1Mj83NW0ZnQq8nsj2Sl3Gx8,6988
|
|
36
|
-
pyqqq/data/minutes.py,sha256=
|
|
36
|
+
pyqqq/data/minutes.py,sha256=mVgaeku-de5Vx-U-majIW3K5FpLYHE1ewPabK7UT9uM,14609
|
|
37
37
|
pyqqq/data/overseas.py,sha256=yx7tCZHW8AvjIbtrP4dqIeC6wseRSzbg5ag3dm6H0LY,1234
|
|
38
38
|
pyqqq/data/realtime.py,sha256=W2UJ1cU_h4Bc4XrqIs96n6tkrZSAVpIZ-RrnIfU2LfI,14888
|
|
39
39
|
pyqqq/data/ticks.py,sha256=DXioiKBsGTzwXyvEH0lpm8t5g-1nHIOLKMXoSrE1Rko,4127
|
|
40
40
|
pyqqq/data/us_stocks.py,sha256=jXR9dQEVigrwTLEpX1aX1_AQvOlBopW265gwx8Nq8OA,12959
|
|
41
|
-
pyqqq/datatypes.py,sha256=
|
|
41
|
+
pyqqq/datatypes.py,sha256=KnanWzat6w5w0vNvKHsWt9VOBwf9gh0njdt36PJBgXc,8370
|
|
42
42
|
pyqqq/executors/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
43
43
|
pyqqq/executors/hook.py,sha256=xV9SVUpUwGm8AgEuz8aD7U4ema47lRoKn4KFthuLJwQ,36985
|
|
44
44
|
pyqqq/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
45
45
|
pyqqq/utils/api_client.py,sha256=WLcvZ9sZp2b9YrTauZt2VkcikfByOhHxPHCvGXmgEzw,585
|
|
46
46
|
pyqqq/utils/array.py,sha256=8E8JW-P1GWzluiqIDuKdUEALZ30AkKRtfxgdrWmu__Q,1747
|
|
47
47
|
pyqqq/utils/casting.py,sha256=nCHnJQ_F88R22xfnBg58fiJXwHYYnsnk3qSDw_rVIY8,135
|
|
48
|
-
pyqqq/utils/compute.py,sha256=
|
|
48
|
+
pyqqq/utils/compute.py,sha256=nM5WUZ7aWnsrcHnKEQT-Omv56lrZJO6LfmDH6tnrg1M,5645
|
|
49
49
|
pyqqq/utils/copycat.py,sha256=1cMuQKteOuzBbH3aAdsDCH7ZTxTyM6OyJ5Wii7gmBpk,10837
|
|
50
50
|
pyqqq/utils/daily_tickers.py,sha256=_zK-U1jJgQlmXARVPA4Wnmtd_mkxeZAp4-Dg_xMLEOs,3474
|
|
51
51
|
pyqqq/utils/display.py,sha256=kFoXw52ODDgbR-ufAKRJdY5NEA7UTikrosZRukEIWFc,1177
|
|
@@ -58,6 +58,6 @@ pyqqq/utils/mock_api.py,sha256=7EsaVQ9mOVZQAqtQW24isPnk9QTbJII7x3guhFyEMAE,10569
|
|
|
58
58
|
pyqqq/utils/position_classifier.py,sha256=EaomByAWM2lVuYow5OFdJNrN64Fpukhj-lhFkjYpjeo,14908
|
|
59
59
|
pyqqq/utils/retry.py,sha256=4mw9MQvgSBC8bTLvDauaCEI5N9tL8upHCk8rSfaVRG8,2066
|
|
60
60
|
pyqqq/utils/singleton.py,sha256=m6NZ8fwVDpI6U-gUUihMPgVK_NkDh-Z1NSAtjisrpjY,810
|
|
61
|
-
pyqqq-0.12.
|
|
62
|
-
pyqqq-0.12.
|
|
63
|
-
pyqqq-0.12.
|
|
61
|
+
pyqqq-0.12.168.dist-info/METADATA,sha256=NAaKZOU8if4_y3Kv3mEQTl-HV1ZtnSlKB7Oq_jaev9Y,1664
|
|
62
|
+
pyqqq-0.12.168.dist-info/WHEEL,sha256=sP946D7jFCHeNz5Iq4fL4Lu-PrWrFsgfLXbbkciIZwg,88
|
|
63
|
+
pyqqq-0.12.168.dist-info/RECORD,,
|
|
File without changes
|