- config.yaml에서 직접 KIS 설정 관리 (BaseSettings 제거)
- 대시보드 KIS 계좌 잔고 실시간 연동, 로컬 DB 폴백
- 종목 조회 페이지: 시세/호가/일봉차트 (canvas) 구현
- 호가 REST API 엔드포인트 (/api/stocks/{code}/orderbook) 추가
- 토큰 캐시(.auth_cache.json) 저장/복원, 1분 재시도 제한
- KIS WebSocket 자동 연결 + 재연결 로직
- httpx 타임아웃 10초→30초, 라우터 타임아웃 핸들링 (504)
- rate limit: requests_per_second 2.0 (모의투자 초당 2건)
- 사이드바 3메뉴: 실거래 / 과거기록 / 종목조회
138 lines
4.8 KiB
Python
138 lines
4.8 KiB
Python
from __future__ import annotations
|
|
|
|
import httpx
|
|
|
|
from app.core.auth import token_manager
|
|
from app.core.config import get_base_url, settings
|
|
from app.core.logger import setup_logger
|
|
from app.core.rate_limiter import RateLimiter
|
|
|
|
logger = setup_logger("market_data")
|
|
|
|
_PRICE_URL = "/uapi/domestic-stock/v1/quotations/inquire-price"
|
|
_CHART_URL = "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice"
|
|
_ORDERBOOK_URL = "/uapi/domestic-stock/v1/quotations/inquire-asking-price"
|
|
|
|
_FID_INPUT_ISCD = "FID_INPUT_ISCD"
|
|
_FID_COND_MRKT_DIV_CODE = "FID_COND_MRKT_DIV_CODE"
|
|
|
|
|
|
class MarketDataService:
|
|
def __init__(self) -> None:
|
|
self._client = httpx.AsyncClient(timeout=30.0)
|
|
self._rate_limiter = RateLimiter(settings.rate_limit.requests_per_second)
|
|
|
|
def _check_auth(self) -> bool:
|
|
if not token_manager.is_authenticated:
|
|
logger.debug("API 인증되지 않음 - 시세 조회 불가")
|
|
return False
|
|
return True
|
|
|
|
async def get_current_price(self, stock_code: str) -> dict:
|
|
if not self._check_auth():
|
|
return {}
|
|
|
|
await self._rate_limiter.acquire()
|
|
headers = token_manager.get_auth_headers("FHKST01010100")
|
|
params = {_FID_COND_MRKT_DIV_CODE: "J", _FID_INPUT_ISCD: stock_code}
|
|
url = f"{get_base_url()}{_PRICE_URL}"
|
|
|
|
resp = await self._client.get(url, headers=headers, params=params)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
if data.get("rt_cd") != "0":
|
|
logger.warning("현재가 조회 실패: %s - %s", stock_code, data.get("msg1"))
|
|
return {}
|
|
|
|
output = data.get("output", {})
|
|
return {
|
|
"stock_code": stock_code,
|
|
"current_price": int(output.get("stck_prpr", 0)),
|
|
"change_price": int(output.get("prdy_vrss", 0)),
|
|
"change_rate": float(output.get("prdy_ctrt", 0)),
|
|
"open_price": int(output.get("stck_oprc", 0)),
|
|
"high_price": int(output.get("stck_hgpr", 0)),
|
|
"low_price": int(output.get("stck_lwpr", 0)),
|
|
"volume": int(output.get("acml_vol", 0)),
|
|
"trade_amount": int(output.get("acml_tr_pbmn", 0)),
|
|
"stock_name": output.get("hts_kor_isnm", ""),
|
|
}
|
|
|
|
async def get_daily_chart(
|
|
self, stock_code: str, start_date: str, end_date: str, count: int = 30
|
|
) -> list[dict]:
|
|
if not self._check_auth():
|
|
return []
|
|
|
|
await self._rate_limiter.acquire()
|
|
headers = token_manager.get_auth_headers("FHKST03010200")
|
|
params = {
|
|
_FID_COND_MRKT_DIV_CODE: "J",
|
|
_FID_INPUT_ISCD: stock_code,
|
|
"FID_INPUT_DATE_1": start_date,
|
|
"FID_INPUT_DATE_2": end_date,
|
|
"FID_PERIOD_DIV_CODE": "D",
|
|
"FID_ADJ_PRC": "1",
|
|
}
|
|
url = f"{get_base_url()}{_CHART_URL}"
|
|
|
|
resp = await self._client.get(url, headers=headers, params=params)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
if data.get("rt_cd") != "0":
|
|
logger.warning("차트 조회 실패: %s - %s", stock_code, data.get("msg1"))
|
|
return []
|
|
|
|
result = []
|
|
for item in data.get("output2", [])[:count]:
|
|
result.append({
|
|
"date": item.get("stck_bsop_date", ""),
|
|
"open": int(item.get("stck_oprc", 0)),
|
|
"high": int(item.get("stck_hgpr", 0)),
|
|
"low": int(item.get("stck_lwpr", 0)),
|
|
"close": int(item.get("stck_clpr", 0)),
|
|
"volume": int(item.get("acml_vol", 0)),
|
|
})
|
|
return result
|
|
|
|
async def get_orderbook(self, stock_code: str) -> dict:
|
|
if not self._check_auth():
|
|
return {}
|
|
|
|
await self._rate_limiter.acquire()
|
|
headers = token_manager.get_auth_headers("FHKST01010200")
|
|
params = {_FID_COND_MRKT_DIV_CODE: "J", _FID_INPUT_ISCD: stock_code}
|
|
url = f"{get_base_url()}{_ORDERBOOK_URL}"
|
|
|
|
resp = await self._client.get(url, headers=headers, params=params)
|
|
resp.raise_for_status()
|
|
data = resp.json()
|
|
|
|
if data.get("rt_cd") != "0":
|
|
return {}
|
|
|
|
output = data.get("output", [{}])[0] if data.get("output") else {}
|
|
return {
|
|
"stock_code": stock_code,
|
|
"bid_prices": [
|
|
int(output.get(f"phsc_kprc_{i}", 0)) for i in range(1, 6)
|
|
],
|
|
"ask_prices": [
|
|
int(output.get(f"sats_kprc_{i}", 0)) for i in range(1, 6)
|
|
],
|
|
"bid_volumes": [
|
|
int(output.get(f"phsc_vola_{i}", 0)) for i in range(1, 6)
|
|
],
|
|
"ask_volumes": [
|
|
int(output.get(f"sats_ac_vola_{i}", 0)) for i in range(1, 6)
|
|
],
|
|
}
|
|
|
|
async def close(self) -> None:
|
|
await self._client.aclose()
|
|
|
|
|
|
market_data_service = MarketDataService()
|