Initial commit
This commit is contained in:
272
backend/app/api/endpoints/market_data.py
Normal file
272
backend/app/api/endpoints/market_data.py
Normal file
@@ -0,0 +1,272 @@
|
||||
from datetime import datetime, timedelta
|
||||
import random
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import settings
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StockPriceResponse(BaseModel):
|
||||
stock_code: str
|
||||
current_price: float
|
||||
change_rate: float
|
||||
volume: int
|
||||
high_price: float
|
||||
low_price: float
|
||||
open_price: float
|
||||
|
||||
|
||||
@router.get("/price/{stock_code}")
|
||||
async def get_stock_price(stock_code: str):
|
||||
"""주식 현재가 조회"""
|
||||
try:
|
||||
data = await korea_investment_api.get_current_price(stock_code)
|
||||
|
||||
if "output" not in data:
|
||||
raise HTTPException(status_code=404, detail="Stock data not found")
|
||||
|
||||
output = data["output"]
|
||||
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"current_price": float(output.get("stck_prpr", 0)),
|
||||
"change_rate": float(output.get("prdy_ctrt", 0)),
|
||||
"volume": int(output.get("acml_vol", 0)),
|
||||
"high_price": float(output.get("stck_hgpr", 0)),
|
||||
"low_price": float(output.get("stck_lwpr", 0)),
|
||||
"open_price": float(output.get("stck_oprc", 0))
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch stock price: {str(e)}")
|
||||
|
||||
|
||||
def _generate_sample_chart(stock_code: str, period: str) -> dict:
|
||||
"""로컬 개발 환경을 위한 샘플 차트 데이터를 생성"""
|
||||
if period not in {"D", "W", "M"}:
|
||||
period = "D"
|
||||
|
||||
if period == "D":
|
||||
points = 60
|
||||
step = 1
|
||||
elif period == "W":
|
||||
points = 52
|
||||
step = 7
|
||||
else: # "M"
|
||||
points = 36
|
||||
step = 30
|
||||
|
||||
today = datetime.now()
|
||||
start_date = today - timedelta(days=(points - 1) * step)
|
||||
|
||||
rng = random.Random(f"{stock_code}:{period}")
|
||||
base_price = rng.randint(30000, 90000)
|
||||
drift = rng.uniform(-0.04, 0.06)
|
||||
volatility = rng.uniform(0.004, 0.018)
|
||||
volume_base = rng.randint(80000, 180000)
|
||||
|
||||
def round_price(value: float) -> int:
|
||||
return max(1000, int(round(value / 10)) * 10)
|
||||
|
||||
price = float(base_price)
|
||||
chart_data = []
|
||||
|
||||
for idx in range(points):
|
||||
current_date = start_date + timedelta(days=idx * step)
|
||||
daily_change = drift / points + rng.uniform(-volatility, volatility)
|
||||
|
||||
open_price = price
|
||||
close_price = max(1000.0, open_price * (1 + daily_change))
|
||||
|
||||
high_price = max(open_price, close_price) * (1 + rng.uniform(0, volatility * 1.8))
|
||||
low_price = min(open_price, close_price) * (1 - rng.uniform(0, volatility * 1.8))
|
||||
|
||||
if low_price <= 0:
|
||||
low_price = min(open_price, close_price) * 0.98
|
||||
|
||||
volume = int(volume_base * (1 + rng.uniform(-0.35, 0.35)))
|
||||
|
||||
open_value = round_price(open_price)
|
||||
close_value = round_price(close_price)
|
||||
high_value = max(round_price(high_price), open_value, close_value)
|
||||
low_value = min(round_price(low_price), open_value, close_value)
|
||||
|
||||
chart_data.append({
|
||||
"date": current_date.strftime("%Y%m%d"),
|
||||
"open": float(open_value),
|
||||
"high": float(high_value),
|
||||
"low": float(low_value),
|
||||
"close": float(close_value),
|
||||
"volume": max(1000, volume),
|
||||
})
|
||||
|
||||
price = close_price
|
||||
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"period": period,
|
||||
"data": chart_data,
|
||||
"is_sample": True,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/chart/{stock_code}")
|
||||
async def get_stock_chart(
|
||||
stock_code: str,
|
||||
period: str = "D",
|
||||
realtime: bool = Query(False, description="최신 현재가를 포함하여 실시간으로 보정된 마지막 캔들 반환")
|
||||
):
|
||||
"""주식 차트 데이터 조회
|
||||
|
||||
Args:
|
||||
stock_code: 종목 코드
|
||||
period: D(일봉), W(주봉), M(월봉)
|
||||
"""
|
||||
try:
|
||||
if not settings.KOREA_INVESTMENT_APP_KEY or not settings.KOREA_INVESTMENT_APP_SECRET:
|
||||
return _generate_sample_chart(stock_code, period)
|
||||
|
||||
data = await korea_investment_api.get_daily_chart(stock_code, period)
|
||||
|
||||
if "output" not in data:
|
||||
raise HTTPException(status_code=404, detail="Chart data not found")
|
||||
|
||||
chart_data = []
|
||||
for item in data["output"]:
|
||||
# KIS API 가격 데이터 안전하게 파싱
|
||||
try:
|
||||
close_str = str(item.get("stck_clpr", "0"))
|
||||
open_str = str(item.get("stck_oprc", "0"))
|
||||
high_str = str(item.get("stck_hgpr", "0"))
|
||||
low_str = str(item.get("stck_lwpr", "0"))
|
||||
|
||||
# 소수점이 포함되어 있으면 제거하고 정수로 변환
|
||||
close_val = float(close_str.replace(",", ""))
|
||||
open_val = float(open_str.replace(",", ""))
|
||||
high_val = float(high_str.replace(",", ""))
|
||||
low_val = float(low_str.replace(",", ""))
|
||||
|
||||
chart_data.append({
|
||||
"date": item.get("stck_bsop_date"),
|
||||
"open": open_val,
|
||||
"high": high_val,
|
||||
"low": low_val,
|
||||
"close": close_val,
|
||||
"volume": int(item.get("acml_vol", 0))
|
||||
})
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.error(f"가격 파싱 오류: {item} - {e}")
|
||||
continue
|
||||
|
||||
# KIS API는 최신 데이터가 가장 먼저 올 수 있음 → 그대로 두고 프론트에서 reverse 처리
|
||||
|
||||
latest_candle = None
|
||||
if realtime:
|
||||
try:
|
||||
price_data = await korea_investment_api.get_current_price(stock_code)
|
||||
if price_data and price_data.get("output"):
|
||||
o = price_data["output"]
|
||||
# 현재가를 마지막(최신) 캔들로 보정 (지표 계산 시 영향 최소화 위해 기존 첫 캔들과 가격 다르면 prepend)
|
||||
current_price = float(str(o.get("stck_prpr", "0")).replace(",", ""))
|
||||
high_price = float(str(o.get("stck_hgpr", "0")).replace(",", ""))
|
||||
low_price = float(str(o.get("stck_lwpr", "0")).replace(",", ""))
|
||||
open_price = float(str(o.get("stck_oprc", current_price)).replace(",", ""))
|
||||
volume = int(o.get("acml_vol", 0))
|
||||
today_str = o.get("stck_bsop_date") or datetime.now().strftime("%Y%m%d")
|
||||
latest_candle = {
|
||||
"date": today_str,
|
||||
"open": open_price,
|
||||
"high": high_price,
|
||||
"low": low_price,
|
||||
"close": current_price,
|
||||
"volume": volume,
|
||||
"realtime": True
|
||||
}
|
||||
# 기존 첫 번째 캔들의 날짜와 같으면 교체, 다르면 맨 앞에 추가
|
||||
if chart_data and chart_data[0]["date"] == latest_candle["date"]:
|
||||
chart_data[0] = latest_candle
|
||||
else:
|
||||
chart_data.insert(0, latest_candle)
|
||||
except Exception as e:
|
||||
logger.warning(f"실시간 현재가 캔들 생성 실패: {e}")
|
||||
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"period": period,
|
||||
"data": chart_data,
|
||||
"is_sample": False,
|
||||
"realtime": realtime,
|
||||
"latest": latest_candle
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# 외부 API 실패 시 개발 편의를 위해 샘플 데이터 제공 (이유 포함)
|
||||
sample = _generate_sample_chart(stock_code, period)
|
||||
sample["error"] = str(e)
|
||||
sample["is_sample"] = True
|
||||
sample["realtime"] = False
|
||||
sample["latest"] = None
|
||||
return sample
|
||||
|
||||
|
||||
@router.get("/news")
|
||||
async def get_market_news(limit: int = 10):
|
||||
"""시장 뉴스 조회 (네이버 금융 RSS)"""
|
||||
import feedparser
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
# 네이버 금융 증권 뉴스 RSS
|
||||
feed_url = "https://news.naver.com/main/list.naver?mode=LSD&mid=sec&sid1=101&listType=title"
|
||||
rss_url = "https://news.google.com/rss/search?q=한국+증시+주식&hl=ko&gl=KR&ceid=KR:ko"
|
||||
|
||||
# RSS 피드 파싱
|
||||
feed = feedparser.parse(rss_url)
|
||||
|
||||
news_list = []
|
||||
for idx, entry in enumerate(feed.entries[:limit]):
|
||||
news_list.append({
|
||||
"id": idx + 1,
|
||||
"title": entry.get("title", "제목 없음"),
|
||||
"summary": entry.get("summary", entry.get("description", ""))[:200] + "..." if entry.get("summary", entry.get("description", "")) else "",
|
||||
"source": entry.get("source", {}).get("title", "Google News") if hasattr(entry.get("source", {}), "get") else "Google News",
|
||||
"published_at": entry.get("published", datetime.now().isoformat()),
|
||||
"url": entry.get("link", "#")
|
||||
})
|
||||
|
||||
return news_list
|
||||
|
||||
except Exception as e:
|
||||
# 에러 발생 시 샘플 데이터 반환
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
"title": "코스피, 외국인 매수세에 상승 마감",
|
||||
"summary": "외국인 투자자들의 순매수세가 이어지면서 코스피가 상승 마감했습니다.",
|
||||
"source": "한국경제",
|
||||
"published_at": datetime.now().isoformat(),
|
||||
"url": "#"
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"title": "반도체 업종 강세, 삼성전자 상승",
|
||||
"summary": "반도체 업종이 강세를 보이며 삼성전자 주가가 상승했습니다.",
|
||||
"source": "매일경제",
|
||||
"published_at": datetime.now().isoformat(),
|
||||
"url": "#"
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"title": "배터리 3사 동반 상승",
|
||||
"summary": "LG에너지솔루션, 삼성SDI, SK온이 동반 상승했습니다.",
|
||||
"source": "이데일리",
|
||||
"published_at": datetime.now().isoformat(),
|
||||
"url": "#"
|
||||
}
|
||||
][:limit]
|
||||
Reference in New Issue
Block a user