refactor: migrate source files to src layout and update project configuration

This commit is contained in:
Nate Doohyun Jang
2026-06-19 22:27:30 +09:00
parent 12168d0796
commit 81415e6050
5 changed files with 382 additions and 70 deletions
+12 -1
View File
@@ -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"
+1
View File
@@ -0,0 +1 @@
# toss_quant package
+1 -1
View File
@@ -1,4 +1,4 @@
from toss_api import TossInvestAPI
from toss_quant.toss_api import TossInvestAPI
def main():
# 1. API 객체 초기화
+368
View File
@@ -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)
-68
View File
@@ -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)