백엔드 전체 구현 완료: 내부 서비스(Auth, Client, Realtime), API 엔드포인트 및 스케줄러 구현
This commit is contained in:
197
backend/app/services/kis_client.py
Normal file
197
backend/app/services/kis_client.py
Normal file
@@ -0,0 +1,197 @@
|
||||
import httpx
|
||||
from typing import Dict, Optional, Any
|
||||
from app.services.kis_auth import kis_auth
|
||||
from app.core.rate_limiter import global_rate_limiter
|
||||
from app.db.database import SessionLocal
|
||||
from app.db.models import ApiSettings
|
||||
from sqlalchemy import select
|
||||
|
||||
class KisClient:
|
||||
"""
|
||||
Brokerage Service Interface for KIS API.
|
||||
Implements Section 9 Integration Map.
|
||||
"""
|
||||
|
||||
# Domestic URLs
|
||||
URL_DOMESTIC_ORDER = "/uapi/domestic-stock/v1/trading/order-cash"
|
||||
URL_DOMESTIC_PRICE = "/uapi/domestic-stock/v1/quotations/inquire-price"
|
||||
URL_DOMESTIC_BALANCE = "/uapi/domestic-stock/v1/trading/inquire-balance"
|
||||
|
||||
# Overseas URLs
|
||||
URL_OVERSEAS_ORDER = "/uapi/overseas-stock/v1/trading/order"
|
||||
URL_OVERSEAS_PRICE = "/uapi/overseas-price/v1/quotations/price"
|
||||
URL_OVERSEAS_BALANCE = "/uapi/overseas-stock/v1/trading/inquire-balance"
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
async def _get_settings(self):
|
||||
async with SessionLocal() as session:
|
||||
stmt = select(ApiSettings).where(ApiSettings.id == 1)
|
||||
result = await session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def _call_api(self, method: str, url_path: str, tr_id: str, params: Dict = None, data: Dict = None) -> Dict:
|
||||
"""
|
||||
Common API Caller with Rate Limiting and Auth.
|
||||
"""
|
||||
# 1. Rate Limit
|
||||
await global_rate_limiter.wait()
|
||||
|
||||
# 2. Get Token & Base URL
|
||||
# Assuming Real Environment for now. TODO: Support Virtual
|
||||
base_url = kis_auth.BASE_URL_REAL
|
||||
token = await kis_auth.get_access_token()
|
||||
settings = await self._get_settings()
|
||||
|
||||
if not settings:
|
||||
raise ValueError("Settings not found")
|
||||
|
||||
# 3. Headers
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"authorization": f"Bearer {token}",
|
||||
"appkey": settings.appKey,
|
||||
"appsecret": settings.appSecret,
|
||||
"tr_id": tr_id,
|
||||
"tr_cont": "",
|
||||
"custtype": "P"
|
||||
}
|
||||
|
||||
full_url = f"{base_url}{url_path}"
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
if method == "GET":
|
||||
resp = await client.get(full_url, headers=headers, params=params)
|
||||
else:
|
||||
resp = await client.post(full_url, headers=headers, json=data)
|
||||
|
||||
# TODO: Handle 401 Re-Auth Logic here (catch, clear token, retry)
|
||||
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
# -----------------------------
|
||||
# 1. Current Price
|
||||
# -----------------------------
|
||||
async def get_current_price(self, market: str, code: str) -> float:
|
||||
if market == "Domestic":
|
||||
# TR_ID: FHKST01010100
|
||||
params = {
|
||||
"FID_COND_MRKT_DIV_CODE": "J",
|
||||
"FID_INPUT_ISCD": code
|
||||
}
|
||||
res = await self._call_api("GET", self.URL_DOMESTIC_PRICE, "FHKST01010100", params=params)
|
||||
return float(res['output']['stck_prpr'])
|
||||
|
||||
elif market == "Overseas":
|
||||
# TR_ID: HHDFS00000300
|
||||
# Need Exchange Code (e.g., NASD). Assuming NASD for generic US or mapped.
|
||||
# Ideally code should be 'NASD:AAPL' or separate excg param.
|
||||
# For now assuming 'NASD' if not provided implicitly.
|
||||
# Or code is just 'AAPL'.
|
||||
excg = "NASD" # Default
|
||||
params = {
|
||||
"AUTH": "",
|
||||
"EXCD": excg,
|
||||
"SYMB": code
|
||||
}
|
||||
res = await self._call_api("GET", self.URL_OVERSEAS_PRICE, "HHDFS00000300", params=params)
|
||||
return float(res['output']['last'])
|
||||
|
||||
return 0.0
|
||||
|
||||
# -----------------------------
|
||||
# 2. Balance
|
||||
# -----------------------------
|
||||
async def get_balance(self, market: str) -> Dict:
|
||||
settings = await self._get_settings()
|
||||
acc_no = settings.accountNumber
|
||||
# acc_no is 8 digits. Split? "500xxx-01" -> 500xxx, 01
|
||||
if '-' in acc_no:
|
||||
cano, prdt = acc_no.split('-')
|
||||
else:
|
||||
cano = acc_no[:8]
|
||||
prdt = acc_no[8:]
|
||||
|
||||
if market == "Domestic":
|
||||
# TR_ID: TTTC8434R (Real)
|
||||
params = {
|
||||
"CANO": cano,
|
||||
"ACNT_PRDT_CD": prdt,
|
||||
"AFHR_FLPR_YN": "N",
|
||||
"OFL_YN": "",
|
||||
"INQR_DVSN": "02",
|
||||
"UNPR_DVSN": "01",
|
||||
"FUND_STTL_ICLD_YN": "N",
|
||||
"FNCG_AMT_AUTO_RDPT_YN": "N",
|
||||
"PRCS_DVSN": "01",
|
||||
"CTX_AREA_FK100": "",
|
||||
"CTX_AREA_NK100": ""
|
||||
}
|
||||
res = await self._call_api("GET", self.URL_DOMESTIC_BALANCE, "TTTC8434R", params=params)
|
||||
return res
|
||||
|
||||
elif market == "Overseas":
|
||||
# TR_ID: TTTS3012R (Real)
|
||||
params = {
|
||||
"CANO": cano,
|
||||
"ACNT_PRDT_CD": prdt,
|
||||
"OVRS_EXCG_CD": "NASD", # Default
|
||||
"TR_CRCY_CD": "USD",
|
||||
"CTX_AREA_FK200": "",
|
||||
"CTX_AREA_NK200": ""
|
||||
}
|
||||
res = await self._call_api("GET", self.URL_OVERSEAS_BALANCE, "TTTS3012R", params=params)
|
||||
return res
|
||||
|
||||
return {}
|
||||
|
||||
# -----------------------------
|
||||
# 3. Order
|
||||
# -----------------------------
|
||||
async def place_order(self, market: str, side: str, code: str, quantity: int, price: float) -> Dict:
|
||||
"""
|
||||
side: 'buy' or 'sell'
|
||||
price: 0 for Market? KIS logic varies.
|
||||
"""
|
||||
settings = await self._get_settings()
|
||||
if '-' in settings.accountNumber:
|
||||
cano, prdt = settings.accountNumber.split('-')
|
||||
else:
|
||||
cano = settings.accountNumber[:8]
|
||||
prdt = settings.accountNumber[8:]
|
||||
|
||||
if market == "Domestic":
|
||||
# TR_ID: TTT 0802U (Buy), 0801U (Sell) -> using sample 0012U/0011U
|
||||
# 0012U (Buy), 0011U (Sell)
|
||||
tr_id = "TTTC0012U" if side == "buy" else "TTTC0011U"
|
||||
|
||||
data = {
|
||||
"CANO": cano,
|
||||
"ACNT_PRDT_CD": prdt,
|
||||
"PDNO": code,
|
||||
"ORD_DVSN": "00", # Limit (00). 01=Market
|
||||
"ORD_QTY": str(quantity),
|
||||
"ORD_UNPR": str(int(price)), # Cash Order requires integer price string
|
||||
}
|
||||
return await self._call_api("POST", self.URL_DOMESTIC_ORDER, tr_id, data=data)
|
||||
|
||||
elif market == "Overseas":
|
||||
# TR_ID: TTTT1002U (US Buy), TTTT1006U (US Sell)
|
||||
# Assuming US (NASD)
|
||||
tr_id = "TTTT1002U" if side == "buy" else "TTTT1006U"
|
||||
|
||||
data = {
|
||||
"CANO": cano,
|
||||
"ACNT_PRDT_CD": prdt,
|
||||
"OVRS_EXCG_CD": "NASD",
|
||||
"PDNO": code,
|
||||
"ORD_QTY": str(quantity),
|
||||
"OVRS_ORD_UNPR": str(price),
|
||||
"ORD_SVR_DVSN_CD": "0",
|
||||
"ORD_DVSN": "00" # Limit
|
||||
}
|
||||
return await self._call_api("POST", self.URL_OVERSEAS_ORDER, tr_id, data=data)
|
||||
|
||||
kis_client = KisClient()
|
||||
Reference in New Issue
Block a user