refactor(phase1): Add market data module for price/chart queries
This commit is contained in:
247
backend/app/trading/market_data.py
Normal file
247
backend/app/trading/market_data.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
"""
|
||||||
|
Market Data Module
|
||||||
|
시세·차트 조회 전담 모듈 (Redis 캐시 적용)
|
||||||
|
"""
|
||||||
|
from typing import Dict, Any, Optional, List
|
||||||
|
import logging
|
||||||
|
import asyncio
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
import pytz
|
||||||
|
|
||||||
|
from app.trading.kis_client import kis_client, real_market_client
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 차트 캐시 TTL 상수
|
||||||
|
TTL_DAILY_CHART = 300
|
||||||
|
TTL_MINUTE_CHART = 60
|
||||||
|
TTL_MINUTE_FULLDAY = 60
|
||||||
|
TTL_OVERSEAS_CHART = 300
|
||||||
|
|
||||||
|
|
||||||
|
async def _cache_get(key: str) -> Optional[Any]:
|
||||||
|
"""
|
||||||
|
Redis 캐시에서 값 조회 (non-blocking)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from app.core.redis import get_json
|
||||||
|
return await get_json(key)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def _cache_set(key: str, ttl: int, data: Any) -> None:
|
||||||
|
"""Redis 캐시에 값 저장 (non-blocking)"""
|
||||||
|
try:
|
||||||
|
from app.core.redis import setex_json
|
||||||
|
await setex_json(key, ttl, data)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class MarketDataProvider:
|
||||||
|
"""시세 및 차트 데이터 제공자"""
|
||||||
|
|
||||||
|
def __init__(self, client=None, real_client=None):
|
||||||
|
self.client = client or kis_client
|
||||||
|
self.real_client = real_client or real_market_client
|
||||||
|
|
||||||
|
async def get_current_price(self, stock_code: str) -> Dict[str, Any]:
|
||||||
|
"""현재가 조회"""
|
||||||
|
try:
|
||||||
|
client = self.real_client or self.client
|
||||||
|
result, _ = await client.request(
|
||||||
|
"GET",
|
||||||
|
"/uapi/domestic-stock/v1/quotations/inquire-price",
|
||||||
|
"FHKST01010100",
|
||||||
|
params={
|
||||||
|
"FID_COND_MRKT_DIV_CODE": "J",
|
||||||
|
"FID_INPUT_ISCD": stock_code
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 현재가 조회 실패 {stock_code}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_daily_chart(
|
||||||
|
self,
|
||||||
|
stock_code: str,
|
||||||
|
period: str = "D",
|
||||||
|
limit: int = 240
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""일봉/주봉/월봉 조회 (Redis 캐시, 페이지네이션)"""
|
||||||
|
cache_key = f"chart:daily:{stock_code}:{period}:{limit}"
|
||||||
|
|
||||||
|
# 캐시 히트
|
||||||
|
cached = await _cache_get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
logger.debug(f"📦 [캐시 히트] {cache_key}")
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# KIS API 호출
|
||||||
|
all_output2 = []
|
||||||
|
params = {
|
||||||
|
"FID_COND_MRKT_DIV_CODE": "J",
|
||||||
|
"FID_INPUT_ISCD": stock_code,
|
||||||
|
"FID_PERIOD_DIV_CODE": period,
|
||||||
|
"FID_ORG_ADJ_PRC": "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = self.real_client or self.client
|
||||||
|
result, headers = await client.request(
|
||||||
|
"GET",
|
||||||
|
"/uapi/domestic-stock/v1/quotations/inquire-daily-price",
|
||||||
|
"FHKST01010400",
|
||||||
|
params=params
|
||||||
|
)
|
||||||
|
|
||||||
|
data_key = 'output2' if isinstance(result.get('output2'), list) else 'output'
|
||||||
|
if not result or result.get('rt_cd') != '0' or not result.get(data_key):
|
||||||
|
return result
|
||||||
|
|
||||||
|
all_output2.extend(result.get(data_key, []))
|
||||||
|
tr_cont = headers.get('tr_cont')
|
||||||
|
|
||||||
|
# 연속 조회
|
||||||
|
while tr_cont in ('F', 'M') and len(all_output2) < limit:
|
||||||
|
await asyncio.sleep(self.client.min_request_interval + 0.1)
|
||||||
|
extra_headers = {'tr_cont': 'Y'}
|
||||||
|
|
||||||
|
result_cont, headers_cont = await client.request(
|
||||||
|
"GET",
|
||||||
|
"/uapi/domestic-stock/v1/quotations/inquire-daily-price",
|
||||||
|
"FHKST01010400",
|
||||||
|
params=params,
|
||||||
|
extra_headers=extra_headers
|
||||||
|
)
|
||||||
|
|
||||||
|
if result_cont and result_cont.get('rt_cd') == '0':
|
||||||
|
cont_key = 'output2' if isinstance(result_cont.get('output2'), list) else 'output'
|
||||||
|
all_output2.extend(result_cont.get(cont_key, []))
|
||||||
|
|
||||||
|
tr_cont = headers_cont.get('tr_cont') if headers_cont else None
|
||||||
|
|
||||||
|
final_result = result
|
||||||
|
final_result['output2'] = all_output2
|
||||||
|
logger.info(f"✅ 차트 데이터 {len(all_output2)}개 수집: {stock_code}")
|
||||||
|
|
||||||
|
# 캐시 저장
|
||||||
|
await _cache_set(cache_key, TTL_DAILY_CHART, final_result)
|
||||||
|
return final_result
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 차트 조회 실패 {stock_code}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_minute_chart(
|
||||||
|
self,
|
||||||
|
stock_code: str,
|
||||||
|
ref_time: str = "",
|
||||||
|
include_past: bool = True,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""국내주식 분봉차트 (Redis 캐시)"""
|
||||||
|
try:
|
||||||
|
if not ref_time:
|
||||||
|
kst = pytz.timezone("Asia/Seoul")
|
||||||
|
ref_time = datetime.now(kst).strftime("%H%M%S")
|
||||||
|
|
||||||
|
ref_bucket = ref_time[:4] # HHMM
|
||||||
|
cache_key = f"chart:minute:{stock_code}:{ref_bucket}"
|
||||||
|
|
||||||
|
# 캐시 히트
|
||||||
|
cached = await _cache_get(cache_key)
|
||||||
|
if cached is not None:
|
||||||
|
logger.debug(f"📦 [캐시 히트] {cache_key}")
|
||||||
|
return cached
|
||||||
|
|
||||||
|
# KIS API 호출
|
||||||
|
client = self.real_client or self.client
|
||||||
|
params = {
|
||||||
|
"FID_ETC_CLS_CODE": "",
|
||||||
|
"FID_INPUT_ISCD": stock_code,
|
||||||
|
"FID_INPUT_HOUR_1": ref_time,
|
||||||
|
"FID_PW_DATA_INCU_YN": "Y" if include_past else "N",
|
||||||
|
}
|
||||||
|
result, _ = await client.request(
|
||||||
|
"GET",
|
||||||
|
"/uapi/domestic-stock/v1/quotations/inquire-time-itemchartprice",
|
||||||
|
"FHKST03010200",
|
||||||
|
params=params,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 캐시 저장
|
||||||
|
await _cache_set(cache_key, TTL_MINUTE_CHART, result)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 분봉 차트 실패 {stock_code}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_overseas_price(self, symbol: str, exchange_code: str = "NASD") -> Dict[str, Any]:
|
||||||
|
"""해외주식 현재가"""
|
||||||
|
try:
|
||||||
|
client = self.real_client or self.client
|
||||||
|
result, _ = await client.request(
|
||||||
|
"GET",
|
||||||
|
"/uapi/overseas-price/v1/quotations/price",
|
||||||
|
"HHDFS00000300",
|
||||||
|
params={
|
||||||
|
"AUTH": "",
|
||||||
|
"EXCD": exchange_code.upper(),
|
||||||
|
"SYMB": symbol.upper()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 해외주식 시세 실패 {symbol}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_orderbook(self, stock_code: str) -> Dict[str, Any]:
|
||||||
|
"""호가 정보 조회 (10단계)"""
|
||||||
|
try:
|
||||||
|
client = self.real_client or self.client
|
||||||
|
result, _ = await client.request(
|
||||||
|
"GET",
|
||||||
|
"/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn",
|
||||||
|
"FHKST01010200",
|
||||||
|
params={
|
||||||
|
"FID_COND_MRKT_DIV_CODE": "J",
|
||||||
|
"FID_INPUT_ISCD": stock_code
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 호가 조회 실패 {stock_code}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def get_volume_rank(self, market: str = "J") -> Dict[str, Any]:
|
||||||
|
"""거래량 순위 조회"""
|
||||||
|
try:
|
||||||
|
client = self.real_client or self.client
|
||||||
|
result, _ = await client.request(
|
||||||
|
"GET",
|
||||||
|
"/uapi/domestic-stock/v1/quotations/volume-rank",
|
||||||
|
"FHPST01710000",
|
||||||
|
params={
|
||||||
|
"FID_COND_MRKT_DIV_CODE": market,
|
||||||
|
"FID_COND_SCR_DIV_CODE": "20171",
|
||||||
|
"FID_INPUT_ISCD": "0000",
|
||||||
|
"FID_DIV_CLS_CODE": "0",
|
||||||
|
"FID_BLNG_CLS_CODE": "0",
|
||||||
|
"FID_TRGT_CLS_CODE": "111111111",
|
||||||
|
"FID_TRGT_EXLS_CLS_CODE": "000000",
|
||||||
|
"FID_INPUT_PRICE_1": "5000",
|
||||||
|
"FID_INPUT_PRICE_2": "",
|
||||||
|
"FID_VOL_CNT": "",
|
||||||
|
"FID_INPUT_DATE_1": ""
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ 거래량 순위 실패: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# 싱글톤
|
||||||
|
market_data_provider = MarketDataProvider()
|
||||||
Reference in New Issue
Block a user