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]