diff --git a/pyproject.toml b/pyproject.toml index 40a57d6..6b510f2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,4 +4,15 @@ version = "0.1.0" description = "Add your description here" readme = "README.md" requires-python = ">=3.14" -dependencies = [] +dependencies = [ + "requests", + "python-dotenv", +] + +[project.scripts] +toss-quant = "toss_quant.main:main" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + diff --git a/src/toss_quant/__init__.py b/src/toss_quant/__init__.py new file mode 100644 index 0000000..5155812 --- /dev/null +++ b/src/toss_quant/__init__.py @@ -0,0 +1 @@ +# toss_quant package diff --git a/main.py b/src/toss_quant/main.py similarity index 95% rename from main.py rename to src/toss_quant/main.py index 6d3e621..9787d15 100644 --- a/main.py +++ b/src/toss_quant/main.py @@ -1,4 +1,4 @@ -from toss_api import TossInvestAPI +from toss_quant.toss_api import TossInvestAPI def main(): # 1. API 객체 초기화 diff --git a/src/toss_quant/toss_api.py b/src/toss_quant/toss_api.py new file mode 100644 index 0000000..04faf36 --- /dev/null +++ b/src/toss_quant/toss_api.py @@ -0,0 +1,368 @@ +import os +import time +import requests +# pyrefly: ignore [missing-import] +from dotenv import load_dotenv + +# .env 파일 로드 +load_dotenv() + +class TossInvestAPI: + def __init__(self, client_id=None, client_secret=None, account_seq=None, base_url=None): + # TOSS_CLIENT_ID / TOSS_CLIENT_SECRET 선호, 없으면 기존 TOSS_API_KEY / TOSS_SECRET_KEY 사용 + self.client_id = client_id or os.getenv("TOSS_CLIENT_ID") or os.getenv("TOSS_API_KEY") + self.client_secret = client_secret or os.getenv("TOSS_CLIENT_SECRET") or os.getenv("TOSS_SECRET_KEY") + + # Base URL 설정 (기본값: https://openapi.tossinvest.com) + self.base_url = (base_url or os.getenv("TOSS_BASE_URL") or "https://openapi.tossinvest.com").rstrip("/") + + # 계좌 식별 키 캐싱 + self._account_seq = account_seq or os.getenv("TOSS_ACCOUNT_SEQ") + # 기존 계좌번호 (자동 매칭용) + self.account_number = os.getenv("TOSS_ACCOUNT_NUMBER") + + # OAuth 2.0 액세스 토큰 관리 필드 + self._access_token = None + self._token_expires_at = 0 + + def _get_access_token(self): + """ + OAuth 2.0 Client Credentials Grant 방식으로 액세스 토큰을 발급/갱신하고 관리합니다. + 만료 시간 30초 전부터 토큰을 자동으로 재발급받습니다. + """ + if self._access_token and time.time() < self._token_expires_at - 30: + return self._access_token + + url = f"{self.base_url}/oauth2/token" + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json" + } + data = { + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret + } + + try: + response = requests.post(url, headers=headers, data=data) + response.raise_for_status() + token_info = response.json() + + self._access_token = token_info.get("access_token") + expires_in = token_info.get("expires_in", 3600) + self._token_expires_at = time.time() + expires_in + + return self._access_token + except requests.exceptions.RequestException as e: + print("OAuth2 토큰 발급 중 오류 발생:") + if e.response is not None: + print(f"HTTP {e.response.status_code}: {e.response.text}") + raise e + + def _resolve_account_seq(self): + """ + 요청에 사용할 계좌 식별 키(accountSeq)를 결정하여 반환합니다. + 캐싱된 값이 없으면 계좌 목록 조회를 호출하여 폴백 처리합니다. + """ + if self._account_seq: + return self._account_seq + + accounts_data = self.get_accounts() + if not accounts_data or "result" not in accounts_data or not accounts_data["result"]: + raise ValueError("계좌 조회에 실패했거나 등록된 계좌가 존재하지 않습니다.") + + account_list = accounts_data["result"] + + # 계좌번호가 설정되어 있는 경우 매칭 시도 + if self.account_number and self.account_number != "본인의_증권계좌번호": + for acc in account_list: + if acc.get("accountNo") == self.account_number: + self._account_seq = acc.get("accountSeq") + return self._account_seq + + # 매칭되는 계좌가 없거나 계좌번호 정보가 없으면 첫 번째 계좌 선택 + self._account_seq = account_list[0].get("accountSeq") + return self._account_seq + + def _request(self, method, endpoint, params=None, data=None, requires_account=False): + """ + 토스증권 OpenAPI에 REST 요청을 전달하는 공통 헬퍼 메서드. + 인증 토큰 및 필요시 계좌 식별 헤더를 자동으로 삽입하여 처리합니다. + """ + url = f"{self.base_url}{endpoint}" + + # 인증 헤더 구성 + access_token = self._get_access_token() + headers = { + "Authorization": f"Bearer {access_token}", + "Accept": "application/json" + } + + # JSON 요청 바디가 있을 경우 Content-Type 헤더 추가 + if data is not None: + headers["Content-Type"] = "application/json" + + # 계좌 컨텍스트 헤더가 필요한 경우 자동 확인 및 삽입 + if requires_account: + try: + account_seq = self._resolve_account_seq() + headers["X-Tossinvest-Account"] = str(account_seq) + except Exception as e: + print(f"계좌 식별 헤더 삽입 중 오류 발생: {e}") + return None + + try: + response = requests.request(method, url, headers=headers, params=params, json=data) + + # API 에러 핸들링 (4xx/5xx) + if response.status_code >= 400: + try: + err_json = response.json() + err_detail = err_json.get("error", {}) + print(f"API 에러 [{err_detail.get('code')}]: {err_detail.get('message')} " + f"(Request ID: {err_detail.get('requestId')})") + if err_detail.get("data"): + print(f"에러 상세 데이터: {err_detail.get('data')}") + return err_json + except Exception: + print(f"API 요청 오류 ({response.status_code}): {response.text}") + return None + + return response.json() + except requests.exceptions.RequestException as e: + print(f"네트워크 요청 오류: {e}") + return None + + # ========================================== + # 1. 인증 (Auth) API + # ========================================== + # _get_access_token 내부 로직으로 대체 처리되므로 명시적 호출은 생략 가능합니다. + + # ========================================== + # 2. 시세·종목 정보 (Market Data & Stock Info) + # ========================================== + def get_prices(self, symbols): + """ + 종목의 현재가 정보 조회 (최대 200개 종목 다건 조회 지원) + - symbols: 단일 종목 심볼 문자열(예: '005930') 또는 심볼 리스트/튜플 + """ + if isinstance(symbols, (list, tuple)): + symbols_str = ",".join(symbols) + else: + symbols_str = symbols + + params = {"symbols": symbols_str} + return self._request("GET", "/api/v1/prices", params=params) + + def get_orderbook(self, symbol): + """특정 종목의 매수/매도 호가 및 잔량 조회""" + params = {"symbol": symbol} + return self._request("GET", "/api/v1/orderbook", params=params) + + def get_price_limits(self, symbol): + """종목의 당일 상한가 및 하한가 조회""" + params = {"symbol": symbol} + return self._request("GET", "/api/v1/price-limits", params=params) + + def get_trades(self, symbol, count=50): + """당일 최근 체결 내역 조회 (최대 50건)""" + params = {"symbol": symbol, "count": count} + return self._request("GET", "/api/v1/trades", params=params) + + def get_candles(self, symbol, interval, count=100, before=None, adjusted=True): + """ + 종목의 캔들(OHLCV) 차트 데이터 조회 (최대 200개 봉) + - interval: '1m' (1분봉) 또는 '1d' (일봉) + - before: 페이지네이션 상한 시각 (ISO 8601 문자열) + """ + params = { + "symbol": symbol, + "interval": interval, + "count": count, + "adjusted": adjusted + } + if before: + params["before"] = before + return self._request("GET", "/api/v1/candles", params=params) + + def get_stock_info(self, symbols): + """ + 종목 기본 정보 조회 (symbol, 종목명, 시장, 통화, 상장 상태 등) + - symbols: 단일 종목 심볼 문자열 또는 리스트/튜플 + """ + if isinstance(symbols, (list, tuple)): + symbols_str = ",".join(symbols) + else: + symbols_str = symbols + + params = {"symbols": symbols_str} + return self._request("GET", "/api/v1/stocks", params=params) + + def get_stock_warnings(self, symbol): + """특정 종목의 매수 유의사항 조회 (정리매매, 과열, 경고, VI, 신주인수권 등)""" + return self._request("GET", f"/api/v1/stocks/{symbol}/warnings") + + def get_exchange_rate(self): + """KRW ↔ USD 환율 조회""" + return self._request("GET", "/api/v1/exchange-rate") + + def get_market_calendar(self, country="KR"): + """ + 국내(KR) 또는 미국(US) 장 운영 시간 정보 조회 + - country: 'KR' 또는 'US' + """ + return self._request("GET", f"/api/v1/market-calendar/{country.upper()}") + + # ========================================== + # 3. 계좌·보유 자산 (Account & Asset) + # ========================================== + def get_accounts(self): + """사용자의 종합매매(BROKERAGE) 계좌 목록 조회""" + return self._request("GET", "/api/v1/accounts") + + def get_holdings(self, symbol=None): + """ + 보유 주식 상세 정보 조회 (국내/미국 주식 대상) + - symbol: 특정 종목의 보유 정보만 필터링 조회할 경우 전달 + """ + params = {} + if symbol: + params["symbol"] = symbol + return self._request("GET", "/api/v1/holdings", params=params, requires_account=True) + + # ========================================== + # 4. 주문 API (Order & Order History & Info) + # ========================================== + def create_order( + self, + symbol, + side, + order_type="LIMIT", + price=None, + quantity=None, + order_amount=None, + client_order_id=None, + time_in_force="DAY", + confirm_high_value_order=False + ): + """ + 매수 또는 매도 주문을 생성합니다. + + 파라미터 설명: + - symbol: 종목 심볼 (예: '005930', 'AAPL') + - side: 'BUY' (매수) 또는 'SELL' (매도) + - order_type: 호가 유형 ('LIMIT' 또는 'MARKET') + - price: 주문 가격 (지정가 LIMIT 주문인 경우 필수) + - quantity: 주문 수량 (주 단위 정수. order_amount와 중복 설정 불가) + - order_amount: 주문 금액 (달러 단위. US 주식 전용. quantity와 중복 설정 불가) + - client_order_id: 클라이언트 지정 주문 식별자 (멱등성 키) + - time_in_force: 주문 유효 조건 ('DAY' 또는 'CLS') + - confirm_high_value_order: 1억 원 이상 주문 시 필수 동의 확인 플래그 (True 설정 시 승인) + """ + payload = { + "symbol": symbol, + "side": side.upper(), + "orderType": order_type.upper(), + "timeInForce": time_in_force.upper(), + "confirmHighValueOrder": confirm_high_value_order + } + + if price is not None: + payload["price"] = price + if quantity is not None: + payload["quantity"] = quantity + if order_amount is not None: + payload["orderAmount"] = order_amount + if client_order_id is not None: + payload["clientOrderId"] = client_order_id + + return self._request("POST", "/api/v1/orders", data=payload, requires_account=True) + + def cancel_order(self, order_id): + """대기 중인 기존 주문 취소""" + return self._request("POST", f"/api/v1/orders/{order_id}/cancel", data={}, requires_account=True) + + def modify_order(self, order_id, price=None, quantity=None): + """ + 기존 주문의 가격 또는 수량 정정 + - KR 주식: quantity 필수 전달 필요 + - US 주식: 가격 변경만 지원하므로 quantity 전달 불가 + """ + payload = {} + if price is not None: + payload["price"] = price + if quantity is not None: + payload["quantity"] = quantity + return self._request("POST", f"/api/v1/orders/{order_id}/modify", data=payload, requires_account=True) + + def get_orders(self, status, symbol=None, from_date=None, to_date=None, cursor=None, limit=20): + """ + 주문 목록 조회 + - status: 'OPEN' (진행 중 주문) 또는 'CLOSED' (종료된 주문) + - symbol: 특정 종목의 주문 필터 + - from_date: 조회 시작일 (YYYY-MM-DD) + - to_date: 조회 종료일 (YYYY-MM-DD) + - cursor: 페이지네이션용 다음 페이지 커서 (CLOSED 상태 조회 시에만 사용) + - limit: 페이지네이션 크기 (CLOSED 상태 조회 시에만 사용, 최대 100) + """ + params = { + "status": status.upper(), + "limit": limit + } + if symbol: + params["symbol"] = symbol + if from_date: + params["from"] = from_date + if to_date: + params["to"] = to_date + if cursor: + params["cursor"] = cursor + + return self._request("GET", "/api/v1/orders", params=params, requires_account=True) + + def get_order(self, order_id): + """특정 주문 상세 조회 (대기/체결/취소 등 모든 상태 대상)""" + return self._request("GET", f"/api/v1/orders/{order_id}", requires_account=True) + + def get_buying_power(self, currency="KRW"): + """현금 기반 매수 가능 금액 조회 (KRW/USD 통화별)""" + params = {"currency": currency.upper()} + return self._request("GET", "/api/v1/buying-power", params=params, requires_account=True) + + def get_commissions(self): + """국내 및 미국 시장별 매매 수수료율 조회""" + return self._request("GET", "/api/v1/commissions", requires_account=True) + + def get_sellable_quantity(self, symbol): + """보유 종목 중 현재 시점의 판매 가능 수량 조회""" + params = {"symbol": symbol} + return self._request("GET", "/api/v1/sellable-quantity", params=params, requires_account=True) + + # ========================================== + # 5. 하위 호환성 (Backward Compatibility) 지원용 + # ========================================== + def get_account_balance(self): + """ + 기존 toss_api와의 호환성을 위한 계좌 잔고 및 보유 종목 통합 조회. + 매수 가능 금액(Buying Power)과 보유 자산(Holdings)을 조합하여 반환합니다. + """ + try: + buying_power_krw = self.get_buying_power("KRW") + buying_power_usd = self.get_buying_power("USD") + holdings = self.get_holdings() + return { + "buying_power_krw": buying_power_krw, + "buying_power_usd": buying_power_usd, + "holdings": holdings + } + except Exception as e: + print(f"잔고 통합 조회 중 오류 발생: {e}") + return None + + def get_current_price(self, ticker): + """ + 기존 toss_api와의 호환성을 위한 현재가 조회. + 내부적으로 get_prices(symbols) API를 호출합니다. + """ + return self.get_prices(ticker) diff --git a/toss_api.py b/toss_api.py deleted file mode 100644 index c281df8..0000000 --- a/toss_api.py +++ /dev/null @@ -1,68 +0,0 @@ -import os -import requests -from dotenv import load_dotenv - -# .env 파일 로드 -load_dotenv() - -class TossInvestAPI: - def __init__(self): - self.api_key = os.getenv("TOSS_API_KEY") - self.secret_key = os.getenv("TOSS_SECRET_KEY") - self.account_number = os.getenv("TOSS_ACCOUNT_NUMBER") - self.base_url = os.getenv("TOSS_BASE_URL") - - # 공통 헤더 설정 (실제 가이드에 맞게 수정 필요) - self.headers = { - "Authorization": f"Bearer {self.api_key}", - "X-Secret-Key": self.secret_key, - "Content-Type": "application/json" - } - - def _request(self, method, endpoint, params=None, data=None): - """API 요청을 처리하는 내부 헬퍼 함수""" - url = f"{self.base_url}{endpoint}" - try: - response = requests.request(method, url, headers=self.headers, params=params, json=data) - response.raise_for_status() - return response.json() - except requests.exceptions.RequestException as e: - print(f"API 요청 오류: {e}") - return None - - # ========================================== - # 1. 조회 API - # ========================================== - def get_account_balance(self): - """계좌 잔고 및 보유 종목 조회""" - endpoint = f"/v1/account/{self.account_number}/balance" # 예시 URL - return self._request("GET", endpoint) - - def get_current_price(self, ticker): - """특정 종목의 현재가 조회""" - endpoint = f"/v1/market/price/{ticker}" # 예시 URL - return self._request("GET", endpoint) - - # ========================================== - # 2. 주문 API (구매/판매/취소) - # ========================================== - def create_order(self, ticker, order_type, price, quantity): - """ - 매수/매도 주문 실행 - - order_type: "BUY" (매수) 또는 "SELL" (매도) - """ - endpoint = "/v1/orders" # 예시 URL - payload = { - "account_number": self.account_number, - "ticker": ticker, - "order_type": order_type, - "price": price, - "quantity": quantity, - "order_class": "LIMIT" # 지정가 주문 (단타 목표가 설정에 필수) - } - return self._request("POST", endpoint, data=payload) - - def cancel_order(self, order_id): - """미체결 주문 취소 (지정가 매매 시 필수)""" - endpoint = f"/v1/orders/{order_id}/cancel" # 예시 URL - return self._request("POST", endpoint)