수정본
This commit is contained in:
@@ -1,17 +1,34 @@
|
||||
"""
|
||||
시세 데이터 API 엔드포인트
|
||||
|
||||
- GET /price/{stock_code}: 실시간 현재가 조회
|
||||
- GET /chart/{stock_code}: 차트 데이터 (일봉/주봉/월봉)
|
||||
- GET /orderbook/{stock_code}: 호가 정보 조회
|
||||
- GET /ai-analysis/{stock_code}: AI 기반 종목 분석
|
||||
"""
|
||||
# 필요한 라이브러리 및 모듈 임포트
|
||||
from datetime import datetime, timedelta
|
||||
import random
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi import APIRouter, HTTPException, Query, Depends
|
||||
from typing import List, Dict
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
|
||||
# 내부 모듈 임포트
|
||||
from app.core.config import settings
|
||||
from app.core.database import get_db
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
|
||||
# APIRouter 인스턴스 생성
|
||||
router = APIRouter()
|
||||
# 로거 설정
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# 주식 가격 응답을 위한 Pydantic 모델 정의
|
||||
class StockPriceResponse(BaseModel):
|
||||
stock_code: str
|
||||
current_price: float
|
||||
@@ -24,15 +41,18 @@ class StockPriceResponse(BaseModel):
|
||||
|
||||
@router.get("/price/{stock_code}")
|
||||
async def get_stock_price(stock_code: str):
|
||||
"""주식 현재가 조회"""
|
||||
"""주식 현재가 조회 엔드포인트"""
|
||||
try:
|
||||
# 한국투자증권 API를 통해 현재가 데이터 조회
|
||||
data = await korea_investment_api.get_current_price(stock_code)
|
||||
|
||||
# API 응답에 'output' 필드가 없는 경우 예외 처리
|
||||
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)),
|
||||
@@ -44,59 +64,115 @@ async def get_stock_price(stock_code: str):
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# 예외 발생 시 500 에러와 함께 상세 내용 반환
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch stock price: {str(e)}")
|
||||
|
||||
|
||||
def _generate_sample_chart(stock_code: str, period: str) -> dict:
|
||||
"""로컬 개발 환경을 위한 샘플 차트 데이터를 생성"""
|
||||
@router.get("/prices")
|
||||
async def get_stock_prices(codes: str):
|
||||
"""여러 종목의 현재가를 일괄 조회하는 엔드포인트
|
||||
|
||||
Query:
|
||||
- codes: 콤마로 구분된 종목코드들 (예: 005930,000660,035420)
|
||||
"""
|
||||
try:
|
||||
# codes 파라미터가 없는 경우 빈 데이터 반환
|
||||
if not codes:
|
||||
return {"prices": {}, "count": 0, "timestamp": datetime.now().isoformat()}
|
||||
|
||||
# 콤마로 구분된 종목 코드 문자열을 리스트로 변환
|
||||
code_list: List[str] = [c.strip() for c in codes.split(",") if c.strip()]
|
||||
prices: Dict[str, float] = {}
|
||||
|
||||
# 각 종목 코드에 대해 순차적으로 현재가 조회
|
||||
# 클래스 내부에 API 호출 간격을 조절하는 쓰로틀링 기능이 내장되어 있음
|
||||
for code in code_list:
|
||||
try:
|
||||
data = await korea_investment_api.get_current_price(code)
|
||||
if data and data.get("output"):
|
||||
o = data["output"]
|
||||
# 콤마가 포함된 가격 문자열을 float으로 변환
|
||||
current_price = float(str(o.get("stck_prpr", "0")).replace(",", ""))
|
||||
prices[code] = current_price
|
||||
except Exception as e:
|
||||
# 특정 종목 조회 실패 시 경고 로그 기록
|
||||
logger.warning(f"Failed to fetch price for {code}: {e}")
|
||||
|
||||
# 조회 결과를 딕셔너리 형태로 반환
|
||||
return {
|
||||
"prices": prices,
|
||||
"count": len(prices),
|
||||
"requested": len(code_list),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
# 예외 발생 시 500 에러와 함께 상세 내용 반환
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch prices: {str(e)}")
|
||||
|
||||
|
||||
def _generate_sample_chart(stock_code: str, period: str, limit: int = 100) -> dict:
|
||||
"""로컬 개발 환경 및 API 키가 없을 때 사용할 샘플 차트 데이터를 생성하는 함수"""
|
||||
# 유효한 주기(period)인지 확인, 아니면 'D'(일봉)으로 설정
|
||||
if period not in {"D", "W", "M"}:
|
||||
period = "D"
|
||||
|
||||
# 주기에 따라 데이터 포인트 수와 날짜 간격 설정
|
||||
if period == "D":
|
||||
points = 60
|
||||
points = min(limit, 240) # 일봉은 최대 240개
|
||||
step = 1
|
||||
elif period == "W":
|
||||
points = 52
|
||||
points = min(limit, 240) # 주봉도 최대 240개
|
||||
step = 7
|
||||
else: # "M"
|
||||
points = 36
|
||||
else: # "M" (월봉)
|
||||
points = min(limit, 240) # 월봉도 최대 240개
|
||||
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)
|
||||
|
||||
# 가격을 10단위로 반올림하는 헬퍼 함수
|
||||
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))
|
||||
|
||||
# 저가가 0 이하로 내려가지 않도록 보정
|
||||
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),
|
||||
@@ -106,8 +182,10 @@ def _generate_sample_chart(stock_code: str, period: str) -> dict:
|
||||
"volume": max(1000, volume),
|
||||
})
|
||||
|
||||
# 다음 캔들의 시가를 위해 현재 종가 저장
|
||||
price = close_price
|
||||
|
||||
# 최종 샘플 차트 데이터를 딕셔너리 형태로 반환
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"period": period,
|
||||
@@ -116,63 +194,99 @@ def _generate_sample_chart(stock_code: str, period: str) -> dict:
|
||||
}
|
||||
|
||||
|
||||
# 차트 데이터를 위한 간단한 인메모리 캐시 딕셔너리
|
||||
chart_cache = {}
|
||||
|
||||
@router.get("/chart/{stock_code}")
|
||||
async def get_stock_chart(
|
||||
stock_code: str,
|
||||
period: str = "D",
|
||||
realtime: bool = Query(False, description="최신 현재가를 포함하여 실시간으로 보정된 마지막 캔들 반환")
|
||||
realtime: bool = Query(False, description="최신 현재가를 포함하여 실시간으로 보정된 마지막 캔들 반환"),
|
||||
limit: int = Query(100, description="조회할 데이터 개수 (기본: 100, 최대: 240)")
|
||||
):
|
||||
"""주식 차트 데이터 조회
|
||||
|
||||
"""주식 차트 데이터 조회 엔드포인트 (캐싱 적용)
|
||||
|
||||
Args:
|
||||
stock_code: 종목 코드
|
||||
period: D(일봉), W(주봉), M(월봉)
|
||||
limit: 조회할 데이터 개수 (일봉 100개는 약 3개월치 데이터)
|
||||
"""
|
||||
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")
|
||||
|
||||
# 캐시 키 생성 (예: "005930__D")
|
||||
cache_key = f"{stock_code}__{period}"
|
||||
current_time = datetime.now()
|
||||
|
||||
# 캐시에서 데이터 조회
|
||||
cached_data = chart_cache.get(cache_key)
|
||||
use_cache = False
|
||||
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
|
||||
|
||||
# 캐시 데이터가 있고, 10분(600초) 이내에 생성된 경우 캐시 사용
|
||||
if cached_data:
|
||||
last_updated = cached_data.get("timestamp")
|
||||
if last_updated and (current_time - last_updated).total_seconds() < 600:
|
||||
chart_data = list(cached_data.get("data", [])) # 원본 수정을 방지하기 위해 데이터 복사
|
||||
use_cache = True
|
||||
# logger.debug(f"Chart cache hit for {stock_code}")
|
||||
|
||||
# KIS API는 최신 데이터가 가장 먼저 올 수 있음 → 그대로 두고 프론트에서 reverse 처리
|
||||
# 캐시를 사용하지 않는 경우 (캐시가 없거나 만료됨)
|
||||
if not use_cache:
|
||||
# API 키가 설정되지 않은 경우 샘플 데이터 생성
|
||||
if not settings.KOREA_INVESTMENT_APP_KEY or not settings.KOREA_INVESTMENT_APP_SECRET:
|
||||
return _generate_sample_chart(stock_code, period, limit)
|
||||
|
||||
# 한국투자증권 API를 통해 차트 데이터 조회
|
||||
data = await korea_investment_api.get_daily_chart(stock_code, period, limit=limit)
|
||||
|
||||
if "output2" not in data:
|
||||
raise HTTPException(status_code=404, detail="Chart data not found")
|
||||
|
||||
chart_data = [] # 데이터 리스트 초기화
|
||||
for item in data["output2"]:
|
||||
# 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"))
|
||||
|
||||
# 콤마를 제거하고 float으로 변환
|
||||
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
|
||||
|
||||
# 조회한 데이터를 캐시에 저장
|
||||
chart_cache[cache_key] = {
|
||||
"timestamp": current_time,
|
||||
"data": chart_data
|
||||
}
|
||||
|
||||
# KIS API는 최신 데이터가 가장 먼저 오는 경향이 있으므로, 프론트엔드에서 날짜순으로 정렬(reverse)하여 사용
|
||||
|
||||
latest_candle = None
|
||||
# 'realtime' 쿼리 파라미터가 True인 경우, 현재가를 반영한 실시간 캔들 생성
|
||||
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(",", ""))
|
||||
@@ -188,14 +302,16 @@ async def get_stock_chart(
|
||||
"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,
|
||||
@@ -206,9 +322,9 @@ async def get_stock_chart(
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
# 외부 API 실패 시 개발 편의를 위해 샘플 데이터 제공 (이유 포함)
|
||||
# API 호출 등에서 예외 발생 시, 개발 편의를 위해 샘플 데이터를 반환
|
||||
sample = _generate_sample_chart(stock_code, period)
|
||||
sample["error"] = str(e)
|
||||
sample["error"] = str(e) # 에러 메시지 포함
|
||||
sample["is_sample"] = True
|
||||
sample["realtime"] = False
|
||||
sample["latest"] = None
|
||||
@@ -217,19 +333,19 @@ async def get_stock_chart(
|
||||
|
||||
@router.get("/news")
|
||||
async def get_market_news(limit: int = 10):
|
||||
"""시장 뉴스 조회 (네이버 금융 RSS)"""
|
||||
import feedparser
|
||||
"""시장 뉴스 조회 엔드포인트 (Google News RSS 활용)"""
|
||||
import feedparser # RSS 파싱을 위한 라이브러리
|
||||
from datetime import datetime
|
||||
|
||||
try:
|
||||
# 네이버 금융 증권 뉴스 RSS
|
||||
feed_url = "https://news.naver.com/main/list.naver?mode=LSD&mid=sec&sid1=101&listType=title"
|
||||
# Google News에서 '한국 증시 주식' 키워드로 검색한 결과의 RSS 주소
|
||||
rss_url = "https://news.google.com/rss/search?q=한국+증시+주식&hl=ko&gl=KR&ceid=KR:ko"
|
||||
|
||||
# RSS 피드 파싱
|
||||
feed = feedparser.parse(rss_url)
|
||||
|
||||
news_list = []
|
||||
# 조회할 개수(limit)만큼 반복하여 뉴스 항목 추출
|
||||
for idx, entry in enumerate(feed.entries[:limit]):
|
||||
news_list.append({
|
||||
"id": idx + 1,
|
||||
@@ -243,7 +359,7 @@ async def get_market_news(limit: int = 10):
|
||||
return news_list
|
||||
|
||||
except Exception as e:
|
||||
# 에러 발생 시 샘플 데이터 반환
|
||||
# 에러 발생 시 미리 정의된 샘플 뉴스 데이터 반환
|
||||
return [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -270,3 +386,290 @@ async def get_market_news(limit: int = 10):
|
||||
"url": "#"
|
||||
}
|
||||
][:limit]
|
||||
|
||||
|
||||
@router.get("/orderbook/{stock_code}")
|
||||
async def get_orderbook(stock_code: str):
|
||||
"""호가 정보 조회 엔드포인트 (매수/매도 10단계 호가)"""
|
||||
try:
|
||||
logger.info(f"🔍 Fetching orderbook for stock: {stock_code}")
|
||||
# 한국투자증권 API를 통해 호가 데이터 조회
|
||||
data = await korea_investment_api.get_orderbook(stock_code)
|
||||
|
||||
# API 응답 코드가 '0'(성공)이 아닌 경우 에러 처리
|
||||
if data.get("rt_cd") != "0":
|
||||
logger.error(f"❌ Orderbook API error for {stock_code}: rt_cd={data.get('rt_cd')}, msg={data.get('msg1')}")
|
||||
raise HTTPException(status_code=404, detail=data.get("msg1", "Orderbook data not found"))
|
||||
|
||||
# API 응답에서 output1(호가), output2(체결가) 데이터 추출
|
||||
output1 = data.get("output1", {})
|
||||
output2 = data.get("output2", {})
|
||||
|
||||
# 현재가와 누적 거래량 정보 추출
|
||||
current_price = 0
|
||||
current_volume = 0
|
||||
if output2 and output2.get("stck_prpr"):
|
||||
current_price = int(output2.get("stck_prpr", 0))
|
||||
current_volume = int(output2.get("acml_vol", 0) or 0)
|
||||
elif output1.get("stck_prpr"):
|
||||
current_price = int(output1.get("stck_prpr", 0))
|
||||
current_volume = int(output1.get("acml_vol", 0) or 0)
|
||||
|
||||
# 호가 데이터를 담을 딕셔너리 초기화
|
||||
orderbook = {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": output1.get("hts_kor_isnm", ""),
|
||||
"current_price": current_price,
|
||||
"current_volume": current_volume,
|
||||
"asks": [], # 매도 호가 (낮은 가격 -> 높은 가격 순으로 임시 저장 후 reverse)
|
||||
"bids": [], # 매수 호가 (높은 가격 -> 낮은 가격 순)
|
||||
"total_ask_volume": 0,
|
||||
"total_bid_volume": 0,
|
||||
}
|
||||
|
||||
# 디버깅을 위한 원본 매도호가 데이터 로그
|
||||
logger.info(f"📊 [{stock_code}] 매도호가 원본 데이터:")
|
||||
for i in range(1, 11):
|
||||
price = output1.get(f"askp{i}", 0)
|
||||
volume = output1.get(f"askp_rsqn{i}", 0)
|
||||
logger.info(f" askp{i}: {price} ({volume}주)")
|
||||
|
||||
# 디버깅을 위한 원본 매수호가 데이터 로그
|
||||
logger.info(f"📊 [{stock_code}] 매수호가 원본 데이터:")
|
||||
for i in range(1, 11):
|
||||
price = output1.get(f"bidp{i}", 0)
|
||||
volume = output1.get(f"bidp_rsqn{i}", 0)
|
||||
logger.info(f" bidp{i}: {price} ({volume}주)")
|
||||
|
||||
# 매도호가 10단계 파싱 (askp1 ~ askp10)
|
||||
temp_asks = []
|
||||
for i in range(1, 11):
|
||||
price = int(output1.get(f"askp{i}", 0) or 0)
|
||||
volume = int(output1.get(f"askp_rsqn{i}", 0) or 0)
|
||||
if price > 0:
|
||||
temp_asks.append({"price": price, "volume": volume})
|
||||
orderbook["total_ask_volume"] += volume
|
||||
|
||||
# UI 표기를 위해 매도호가는 높은 가격이 위로 가도록 리스트를 뒤집음
|
||||
orderbook["asks"] = list(reversed(temp_asks))
|
||||
|
||||
# 매수호가 10단계 파싱 (bidp1 ~ bidp10)
|
||||
for i in range(1, 11):
|
||||
price = int(output1.get(f"bidp{i}", 0) or 0)
|
||||
volume = int(output1.get(f"bidp_rsqn{i}", 0) or 0)
|
||||
if price > 0:
|
||||
orderbook["bids"].append({"price": price, "volume": volume})
|
||||
orderbook["total_bid_volume"] += volume
|
||||
|
||||
# 최종 파싱 결과 로그
|
||||
logger.info(f"✅ Orderbook {stock_code}: current={current_price}, asks={len(orderbook['asks'])}, bids={len(orderbook['bids'])}")
|
||||
logger.info(f" 파싱된 매도호가: {[ask['price'] for ask in orderbook['asks']]}")
|
||||
logger.info(f" 파싱된 매수호가: {[bid['price'] for bid in orderbook['bids']]}")
|
||||
if orderbook['asks']:
|
||||
logger.info(f" Best ask: {orderbook['asks'][-1]['price']}") # 가장 낮은 매도호가
|
||||
if orderbook['bids']:
|
||||
logger.info(f" Best bid: {orderbook['bids'][0]['price']}") # 가장 높은 매수호가
|
||||
|
||||
return orderbook
|
||||
|
||||
except HTTPException:
|
||||
# HTTPException은 그대로 다시 발생시킴
|
||||
raise
|
||||
except Exception as e:
|
||||
# 그 외 예외는 500 에러로 처리
|
||||
logger.error(f"Error fetching orderbook for {stock_code}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to fetch orderbook: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/ai-analysis/{stock_code}")
|
||||
async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
|
||||
"""AI 기반 종목 분석 엔드포인트 (기술적 지표 + 규칙 기반 분석)"""
|
||||
try:
|
||||
import httpx
|
||||
from app.core.config import settings
|
||||
|
||||
logger.info(f"🤖 AI 분석 요청: {stock_code}")
|
||||
|
||||
# 1. 최신 시장 데이터(현재가 등) 가져오기
|
||||
price_data = await korea_investment_api.get_current_price(stock_code)
|
||||
if price_data.get("rt_cd") != "0":
|
||||
raise HTTPException(status_code=404, detail="시장 데이터를 가져올 수 없습니다")
|
||||
|
||||
output = price_data.get("output", {})
|
||||
current_price = int(output.get("stck_prpr", 0))
|
||||
high_price = int(output.get("stck_hgpr", 0))
|
||||
low_price = int(output.get("stck_lwpr", 0))
|
||||
volume = int(output.get("acml_vol", 0))
|
||||
|
||||
# 2. DB에서 가장 최근의 기술적 지표 분석 데이터 가져오기
|
||||
from app.models.models import StockAnalysis
|
||||
result = await db.execute(
|
||||
select(StockAnalysis)
|
||||
.filter(StockAnalysis.stock_code == stock_code)
|
||||
.order_by(StockAnalysis.analysis_date.desc())
|
||||
.limit(1)
|
||||
)
|
||||
analysis_record = result.scalar_one_or_none()
|
||||
|
||||
indicators = {}
|
||||
if analysis_record:
|
||||
indicators = {
|
||||
"rsi": float(analysis_record.rsi) if analysis_record.rsi else None,
|
||||
"ma5": float(analysis_record.ma5) if analysis_record.ma5 else None,
|
||||
"ma20": float(analysis_record.ma20) if analysis_record.ma20 else None,
|
||||
"ma60": float(analysis_record.ma60) if analysis_record.ma60 else None,
|
||||
"stochastic_k": float(analysis_record.stochastic_k) if analysis_record.stochastic_k else None,
|
||||
"stochastic_d": float(analysis_record.stochastic_d) if analysis_record.stochastic_d else None,
|
||||
"bollinger_upper": float(analysis_record.bollinger_upper) if analysis_record.bollinger_upper else None,
|
||||
"bollinger_middle": float(analysis_record.bollinger_middle) if analysis_record.bollinger_middle else None,
|
||||
"bollinger_lower": float(analysis_record.bollinger_lower) if analysis_record.bollinger_lower else None,
|
||||
}
|
||||
|
||||
# 3. 규칙 기반 분석 로직
|
||||
recommendation = "HOLD" # 기본 추천: 보유
|
||||
confidence = 0.0 # 신뢰도
|
||||
trend = "SIDEWAYS" # 추세
|
||||
analysis_text = "" # 분석 요약 텍스트
|
||||
|
||||
# 매수/매도 신호 점수 계산
|
||||
buy_signals = 0
|
||||
sell_signals = 0
|
||||
total_signals = 0
|
||||
|
||||
# 기술적 지표 데이터가 있는 경우 분석 시작
|
||||
if indicators and any(indicators.values()):
|
||||
signals = [] # 분석된 신호들을 담을 리스트
|
||||
|
||||
# RSI 분석
|
||||
if indicators.get("rsi") is not None:
|
||||
rsi = indicators["rsi"]
|
||||
total_signals += 1
|
||||
if rsi < 30: # 과매도
|
||||
buy_signals += 1
|
||||
signals.append(f"RSI 과매도({rsi:.1f})")
|
||||
elif rsi > 70: # 과매수
|
||||
sell_signals += 1
|
||||
signals.append(f"RSI 과매수({rsi:.1f})")
|
||||
elif rsi < 40: # 매수 고려
|
||||
buy_signals += 0.5
|
||||
signals.append(f"RSI 매수권장({rsi:.1f})")
|
||||
elif rsi > 60: # 매도 고려
|
||||
sell_signals += 0.5
|
||||
signals.append(f"RSI 매도권장({rsi:.1f})")
|
||||
|
||||
# 이동평균선(MA) 분석
|
||||
ma5 = indicators.get("ma5")
|
||||
ma20 = indicators.get("ma20")
|
||||
ma60 = indicators.get("ma60")
|
||||
|
||||
if ma5 and ma20:
|
||||
# 현재가와 20일 이동평균선의 괴리율을 계산하여 데이터의 신선도 체크
|
||||
ma_deviation = abs(current_price - ma20) / ma20 if ma20 > 0 else 999
|
||||
|
||||
if ma_deviation < 0.5: # 괴리율이 50% 미만일 때만 유효한 데이터로 간주
|
||||
total_signals += 1
|
||||
|
||||
if ma60 and ma5 > ma20 > ma60: # 정배열 (강한 상승 추세)
|
||||
buy_signals += 1
|
||||
trend = "UP"
|
||||
signals.append("이동평균 정배열(상승)")
|
||||
elif ma60 and ma5 < ma20 < ma60: # 역배열 (강한 하락 추세)
|
||||
sell_signals += 1
|
||||
trend = "DOWN"
|
||||
signals.append("이동평균 역배열(하락)")
|
||||
elif ma5 < ma20 and current_price < ma20: # 데드크로스 (하락 신호)
|
||||
sell_signals += 0.5
|
||||
trend = "DOWN"
|
||||
signals.append("데드크로스 신호")
|
||||
else:
|
||||
logger.debug(f"이동평균선 데이터 오래됨 (현재가: {current_price}, MA20: {ma20}, 괴리율: {ma_deviation*100:.1f}%)")
|
||||
|
||||
# 볼린저 밴드 분석
|
||||
bb_upper = indicators.get("bollinger_upper")
|
||||
bb_lower = indicators.get("bollinger_lower")
|
||||
bb_middle = indicators.get("bollinger_middle")
|
||||
|
||||
if bb_upper and bb_lower and bb_middle and bb_upper > bb_lower:
|
||||
# 현재가가 볼린저 밴드 내 어느 위치에 있는지 계산 (0~1)
|
||||
bb_position = (current_price - bb_lower) / (bb_upper - bb_lower)
|
||||
|
||||
# 밴드 위치가 -0.2 ~ 1.2 범위 내에 있을 때만 유효한 데이터로 간주 (괴리율 체크)
|
||||
if -0.2 <= bb_position <= 1.2:
|
||||
total_signals += 1
|
||||
if bb_position > 0.8: # 상단 근접 (과매수)
|
||||
sell_signals += 1
|
||||
signals.append(f"볼린저 밴드 상단근접({bb_position*100:.0f}%)")
|
||||
elif bb_position < 0.2: # 하단 근접 (과매도)
|
||||
buy_signals += 1
|
||||
signals.append(f"볼린저 밴드 하단근접({bb_position*100:.0f}%)")
|
||||
else:
|
||||
logger.debug(f"볼린저 밴드 데이터 오래됨 (현재가: {current_price}, 밴드: {bb_lower:.0f}~{bb_upper:.0f}, 위치: {bb_position*100:.0f}%)")
|
||||
|
||||
# 스토캐스틱 분석
|
||||
stoch_k = indicators.get("stochastic_k")
|
||||
stoch_d = indicators.get("stochastic_d")
|
||||
|
||||
if stoch_k is not None and stoch_d is not None:
|
||||
total_signals += 1
|
||||
if stoch_k < 20 and stoch_d < 20: # 과매도
|
||||
buy_signals += 1
|
||||
signals.append(f"스토캐스틱 과매도({stoch_k:.1f})")
|
||||
elif stoch_k > 80 and stoch_d > 80: # 과매수
|
||||
sell_signals += 1
|
||||
signals.append(f"스토캐스틱 과매수({stoch_k:.1f})")
|
||||
elif stoch_k > stoch_d and stoch_k < 50: # 상승 전환 신호
|
||||
buy_signals += 0.3
|
||||
signals.append("스토캐스틱 상승전환")
|
||||
elif stoch_k < stoch_d and stoch_k > 50: # 하락 전환 신호
|
||||
sell_signals += 0.3
|
||||
signals.append("스토캐스틱 하락전환")
|
||||
|
||||
# 최종 추천 결정
|
||||
if total_signals > 0:
|
||||
# (매수 신호 - 매도 신호) / 전체 신호 수로 점수 계산
|
||||
signal_score = (buy_signals - sell_signals) / total_signals
|
||||
confidence = min(abs(signal_score), 1.0) # 신뢰도는 점수의 절대값
|
||||
|
||||
if signal_score > 0.3: # 점수가 0.3보다 크면 '매수'
|
||||
recommendation = "BUY"
|
||||
elif signal_score < -0.3: # 점수가 -0.3보다 작으면 '매도'
|
||||
recommendation = "SELL"
|
||||
else: # 그 외에는 '보유'
|
||||
recommendation = "HOLD"
|
||||
|
||||
analysis_text = " | ".join(signals) if signals else "충분한 신호 없음"
|
||||
else:
|
||||
analysis_text = "기술적 지표 데이터 부족"
|
||||
else:
|
||||
analysis_text = "기술적 지표 데이터 없음"
|
||||
|
||||
# 추세 최종 결정 (이동평균선 없으면 가격 변동으로)
|
||||
if trend == "SIDEWAYS" and high_price and low_price and current_price:
|
||||
daily_change_pct = ((current_price - low_price) / low_price) * 100 if low_price > 0 else 0
|
||||
if daily_change_pct > 10:
|
||||
trend = "UP"
|
||||
elif daily_change_pct < -10:
|
||||
trend = "DOWN"
|
||||
|
||||
# 4. 종합 결과 반환
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": output.get("stck_prdt_name", ""),
|
||||
"current_price": current_price,
|
||||
"high_price": high_price,
|
||||
"low_price": low_price,
|
||||
"volume": volume,
|
||||
"indicators": indicators,
|
||||
"ai_analysis": analysis_text,
|
||||
"recommendation": recommendation,
|
||||
"confidence": confidence,
|
||||
"trend": trend
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
# HTTPException은 그대로 다시 발생시킴
|
||||
raise
|
||||
except Exception as e:
|
||||
# 그 외 예외는 500 에러로 처리
|
||||
logger.error(f"Error in AI analysis for {stock_code}: {e}")
|
||||
raise HTTPException(status_code=500, detail=f"AI 분석 실패: {str(e)}")
|
||||
|
||||
Reference in New Issue
Block a user