Files
auto-stock/backend/app/api/endpoints/market_data.py
shuim 028a1c352b feat: 자동매매 신뢰성 개선 — 시세 실서버 라우팅·일일 한도 전역화·수동 매수 API
- korea_investment_api: KIS_QUOTES_VIA_REAL — 모의투자 모드에서 시세/차트/호가 조회를
  실계좌 시세 서버(20/s)로 위임, 주문·잔고는 모의서버 유지 (EGW00201 대응)
- recommendations: execute_auto_trade_background asyncio.Lock 중복 실행 방지 +
  MAX_DAILY_TRADES를 당일 BUY 건수 차감 전역 기준으로 적용 (11종목 동시 매수 재발 방지)
- manual_buy.py 신규: POST /api/v1/portfolio/buy (리스크 가드 + safe_place_order + db_worker)
- mcp-server: claude_code_client(claude -p 헤드리스 모드) 추가, trade_monitor 부분매도 시
  active 유지·remaining_pct 갱신, API 키 헤더·/api/v1 경로 수정, ODNO 주문ID 수정
- config: MCP_REQUEST_TIMEOUT, MCP_RISK_CACHE_TTL, BATCH_ANALYSIS_INTERVAL,
  SWING_INDEX_BREAKER_ENABLED(OFF), API_TRIGGERED_AUTO_TRADE(OFF) 추가
- docker-compose: 포트 충돌 해소 (postgres 15433, frontend 3001)
- frontend: 추천 패널 매수 버튼, 차트/호가/저널 패널 개선
- CLAUDE.md 압축 최적화 (규칙 변경 없음), _sweep_single_axis.py 백테스트 단일축 스윕

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:37:08 +09:00

841 lines
36 KiB
Python

"""
시세 데이터 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 asyncio
import random
import logging
import pytz
_KST = pytz.timezone("Asia/Seoul")
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
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:
# 한국투자증권 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)),
"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:
# 예외 발생 시 500 에러와 함께 상세 내용 반환
logger.warning(f"Price unavailable for stock: {e}")
return {"stock_code": stock_code, "current_price": 0, "error": "temporarily_unavailable"}
_PRICES_MAX_CODES = 50 # /prices 엔드포인트 최대 종목 수 (KIS API 레이트 리밋 방어)
@router.get("/prices")
async def get_stock_prices(codes: str):
"""여러 종목의 현재가를 일괄 조회하는 엔드포인트
Query:
- codes: 콤마로 구분된 종목코드들 (예: 005930,000660,035420)
- 최대 50개 종목 허용. 초과 시 앞의 50개만 조회.
"""
try:
# codes 파라미터가 없는 경우 빈 데이터 반환
if not codes:
return {"prices": {}, "count": 0, "timestamp": datetime.now().isoformat()}
# 콤마로 구분된 종목 코드 문자열을 리스트로 변환 (최대 50개 제한)
code_list: List[str] = [c.strip() for c in codes.split(",") if c.strip()]
if len(code_list) > _PRICES_MAX_CODES:
logger.warning(f"/prices 종목 수 초과: 요청={len(code_list)}, 최대={_PRICES_MAX_CODES}개로 제한")
code_list = code_list[:_PRICES_MAX_CODES]
prices: Dict[str, float] = {}
# 병렬로 현재가 일괄 조회 (asyncio.gather)
async def _fetch_one(code: str):
try:
data = await korea_investment_api.get_current_price(code)
if data and data.get("output"):
o = data["output"]
return code, float(str(o.get("stck_prpr", "0")).replace(",", ""))
except Exception as e:
logger.warning(f"Failed to fetch price for {code}: {e}")
return code, None
results = await asyncio.gather(*[_fetch_one(c) for c in code_list])
for code, price in results:
if price is not None:
prices[code] = price
# 조회 결과를 딕셔너리 형태로 반환
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 = min(limit, 240) # 일봉은 최대 240개
step = 1
elif period == "W":
points = min(limit, 240) # 주봉도 최대 240개
step = 7
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),
"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,
}
# 차트 데이터를 위한 간단한 인메모리 캐시 딕셔너리 (최대 _CHART_CACHE_MAX_SIZE 개)
chart_cache = {}
_CHART_CACHE_MAX_SIZE = 100
_CHART_CACHE_TTL = 600 # 10분
_CHART_STALE_DAYS = 7 # DB 마지막 봉이 이보다 오래되면 네이버 차트 폴백
def _aggregate_weekly_monthly(daily_data: list, period: str) -> list:
"""일봉 데이터를 주봉(W) 또는 월봉(M)으로 집계."""
if not daily_data:
return []
from collections import OrderedDict
buckets: OrderedDict = OrderedDict()
for d in daily_data:
dt = datetime.strptime(d["date"], "%Y%m%d")
if period == "W":
# ISO 주의 월요일을 키로 사용
monday = dt - timedelta(days=dt.weekday())
key = monday.strftime("%Y%m%d")
else: # "M"
key = dt.strftime("%Y%m01")
if key not in buckets:
buckets[key] = {
"date": d["date"], # 해당 기간의 마지막 거래일
"open": d["open"],
"high": d["high"],
"low": d["low"],
"close": d["close"],
"volume": d["volume"],
}
else:
b = buckets[key]
b["date"] = d["date"] # 마지막 거래일로 갱신
b["high"] = max(b["high"], d["high"])
b["low"] = min(b["low"], d["low"])
b["close"] = d["close"]
b["volume"] += d["volume"]
return list(buckets.values())
async def _fetch_chart_from_db(stock_code: str, limit: int) -> list:
"""stock_daily_prices DB에서 일봉 차트 데이터 조회 (oldest first).
배치(collect_krx_data.py / batch_daily_analysis.py)가 사전 수집한 데이터를 사용.
DB에 데이터가 없으면 빈 리스트 반환.
[READ-ONLY 함수 — DB 쓰기 없음]
SELECT만 수행하므로 CLAUDE.md 3.1 "실시간 계층 DB I/O 금지" 규칙의 예외 대상.
단, WebSocket 경로에서 이 함수를 직접 호출하면 안 됩니다.
WebSocket 핸들러는 get_chart_data_cached()를 통해 인메모리 캐시(1차)를 우선 사용하고,
캐시 미스 시에도 이 함수를 호출하지 말고 캐시된 값(빈 리스트 포함)을 반환해야 합니다.
REST 엔드포인트에서만 DB 폴백이 허용됩니다.
"""
try:
from app.core.database import AsyncSessionLocal
from app.models.models import StockDailyPrice
async with AsyncSessionLocal() as db:
result = await db.execute(
select(StockDailyPrice)
.where(StockDailyPrice.stock_code == stock_code)
.order_by(StockDailyPrice.trade_date.desc())
.limit(limit)
)
rows = result.scalars().all()
if not rows:
return []
# DB 결과는 newest first → oldest first로 역순 변환
chart_data = []
for row in reversed(rows):
chart_data.append({
"date": row.trade_date.strftime("%Y%m%d"),
"open": float(row.open_price or 0),
"high": float(row.high_price or 0),
"low": float(row.low_price or 0),
"close": float(row.close_price or 0),
"volume": int(row.volume or 0),
})
return chart_data
except Exception as e:
logger.warning(f"DB 차트 조회 실패 ({stock_code}): {e}")
return []
async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> list:
"""캐시된 차트 데이터 반환 — REST endpoint와 WebSocket 핸들러가 공유.
조회 우선순위:
1. 인메모리 캐시 (10분 TTL)
2. DB (stock_daily_prices) — 일봉 전용, 배치가 사전 수집한 데이터
3. KIS API 폴백 (DB에 데이터 부족 시)
realtime 보정 없음 (현재가는 WebSocket tick이 처리).
"""
if period not in ("D", "W", "M"):
period = "D"
limit = min(limit, 240)
cache_key = f"{stock_code}__{period}__{limit}"
current_time = datetime.now()
# 1차: 인메모리 캐시 (TTL 만료 항목 삭제 + 크기 제한)
cached = chart_cache.get(cache_key)
if cached:
if (current_time - cached["timestamp"]).total_seconds() < _CHART_CACHE_TTL:
return list(cached["data"])
else:
# 만료 항목 즉시 삭제
del chart_cache[cache_key]
# 2차: DB 조회 (KIS API 사용 안 함 — 배치가 수집한 일봉 데이터만 사용)
# 주/월봉은 일봉 데이터에서 집계
db_limit = limit if period == "D" else limit * 7 if period == "W" else limit * 31
db_data = await _fetch_chart_from_db(stock_code, min(db_limit, 1200))
# DB 데이터가 없거나 오래됐으면(마지막 봉이 _CHART_STALE_DAYS일 이상 전) 네이버 차트 폴백
# — KRX 일괄 수집(DATAGOKR)이 멈춘 종목은 DB가 수개월 전에서 끊겨 차트가 현재가와 어긋난다.
_stale = False
if db_data:
try:
_last = datetime.strptime(str(db_data[-1].get("date", ""))[:8], "%Y%m%d")
_stale = (current_time - _last).days > _CHART_STALE_DAYS
except ValueError:
_stale = False
if not db_data or _stale:
if _stale:
logger.info(f"차트 DB 데이터 오래됨 ({stock_code}, 마지막 {db_data[-1].get('date')}) → 네이버 폴백")
try:
from app.services.naver_finance import get_naver_daily_chart
naver_data = await get_naver_daily_chart(stock_code, days=limit * 2)
if naver_data:
naver_rows = []
for item in naver_data:
o = float(item.get("stck_oprc") or 0)
h = float(item.get("stck_hgpr") or 0)
l = float(item.get("stck_lwpr") or 0)
c = float(item.get("stck_clpr") or 0)
d = item.get("stck_bsop_date", "")
if not d or c <= 0 or o <= 0 or h <= 0 or l <= 0:
continue
naver_rows.append({
"date": d,
"open": o,
"high": h,
"low": l,
"close": c,
"volume": int(item.get("acml_vol") or 0),
})
if naver_rows:
db_data = naver_rows[-limit:]
logger.info(f"차트 네이버 폴백: {stock_code} ({len(db_data)}행)")
except Exception as e:
logger.warning(f"네이버 차트 폴백 실패 ({stock_code}): {e}")
if not db_data:
return _generate_sample_chart(stock_code, period, limit).get("data", [])
# 주/월봉 집계
if period in ("W", "M"):
db_data = _aggregate_weekly_monthly(db_data, period)
db_data = db_data[-limit:] # 최신 N개만
# 캐시 크기 제한: 초과 시 가장 오래된 항목 제거
if len(chart_cache) >= _CHART_CACHE_MAX_SIZE:
# 만료 항목 우선 정리
expired_keys = [
k for k, v in chart_cache.items()
if (current_time - v["timestamp"]).total_seconds() >= _CHART_CACHE_TTL
]
for k in expired_keys:
del chart_cache[k]
# 그래도 초과면 가장 오래된 항목 제거
while len(chart_cache) >= _CHART_CACHE_MAX_SIZE:
oldest_key = min(chart_cache, key=lambda k: chart_cache[k]["timestamp"])
del chart_cache[oldest_key]
chart_cache[cache_key] = {"timestamp": current_time, "data": db_data}
logger.debug(f"차트 DB 히트: {stock_code} period={period} ({len(db_data)}행)")
return db_data
@router.get("/chart/{stock_code}")
async def get_stock_chart(
stock_code: str,
period: str = "D",
realtime: bool = Query(False, description="최신 현재가를 포함하여 실시간으로 보정된 마지막 캔들 반환"),
limit: int = Query(100, description="조회할 데이터 개수 (기본: 100, 최대: 240)")
):
"""주식 차트 데이터 조회 엔드포인트 (DB 전용 — KIS API 미사용)
Args:
stock_code: 종목 코드
period: D(일봉), W(주봉), M(월봉)
limit: 조회할 데이터 개수 (일봉 100개는 약 3개월치 데이터)
"""
try:
chart_data = await get_chart_data_cached(stock_code, period, limit)
return {
"stock_code": stock_code,
"period": period,
"data": chart_data,
"is_sample": not chart_data,
"realtime": False,
"latest": None,
}
except Exception as e:
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):
"""시장 뉴스 조회 엔드포인트 (Google News RSS 활용)"""
import feedparser # RSS 파싱을 위한 라이브러리
from datetime import datetime
try:
# 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,
"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]
@router.get("/orderbook/{stock_code}")
async def get_orderbook(stock_code: str):
"""호가 정보 조회 엔드포인트 (매수/매도 10단계 호가)"""
try:
logger.debug(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.debug(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.debug(f" askp{i}: {price} ({volume}주)")
# 디버깅을 위한 원본 매수호가 데이터 로그
logger.debug(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.debug(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.debug(f"Orderbook {stock_code}: current={current_price}, asks={len(orderbook['asks'])}, bids={len(orderbook['bids'])}")
logger.debug(f" 파싱된 매도호가: {[ask['price'] for ask in orderbook['asks']]}")
logger.debug(f" 파싱된 매수호가: {[bid['price'] for bid in orderbook['bids']]}")
if orderbook['asks']:
logger.debug(f" Best ask: {orderbook['asks'][-1]['price']}") # 가장 낮은 매도호가
if orderbook['bids']:
logger.debug(f" Best bid: {orderbook['bids'][0]['price']}") # 가장 높은 매수호가
return orderbook
except HTTPException:
# HTTPException은 그대로 다시 발생시킴
raise
except Exception as e:
# KIS API 장애 시 빈 호가 반환 (프론트 500 폭탄 방지)
logger.warning(f"Orderbook unavailable for {stock_code}: {e}")
return {"stock_code": stock_code, "asks": [], "bids": [], "error": "temporarily_unavailable"}
@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,
}
else:
# DB에 분석 데이터 없으면 DB 일봉으로 직접 계산 (fallback, KIS API 미사용)
try:
from app.services.mean_reversion_strategy import calculate_rsi, calculate_bollinger
db_chart = await _fetch_chart_from_db(stock_code, 60)
if len(db_chart) >= 20:
closes = [c["close"] for c in db_chart]
highs = [c["high"] for c in db_chart]
lows = [c["low"] for c in db_chart]
rsi_val = calculate_rsi(closes)
bb = calculate_bollinger(closes)
ma5 = sum(closes[-5:]) / 5 if len(closes) >= 5 else None
ma20 = sum(closes[-20:]) / 20 if len(closes) >= 20 else None
ma60 = sum(closes[-60:]) / 60 if len(closes) >= 60 else None
indicators = {
"rsi": round(rsi_val, 4) if rsi_val else None,
"ma5": round(ma5, 2) if ma5 else None,
"ma20": round(ma20, 2) if ma20 else None,
"ma60": round(ma60, 2) if ma60 else None,
"stochastic_k": None,
"stochastic_d": None,
"bollinger_upper": round(bb["upper"], 2) if bb else None,
"bollinger_middle": round(bb["middle"], 2) if bb else None,
"bollinger_lower": round(bb["lower"], 2) if bb else None,
}
logger.info(f"📊 {stock_code} 지표 실시간 계산 (DB 없음 fallback)")
except Exception as fe:
logger.debug(f"지표 fallback 계산 실패 ({stock_code}): {fe}")
# 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("hts_kor_isnm", "") or 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,
"analyzed_at": datetime.now(_KST).isoformat(), # KST +09:00 포함
}
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)}")
# ── 해외 주식 엔드포인트 ──────────────────────────────────────────
@router.get("/overseas/price/{ticker}")
async def get_overseas_price(ticker: str):
"""미국 주식 현재가 조회"""
from app.services.exchange_detector import detect_exchange
exchange = detect_exchange(ticker)
result = await korea_investment_api.get_overseas_price(ticker, exchange)
if not result:
raise HTTPException(status_code=404, detail=f"해외 시세 조회 실패: {ticker}")
return result
@router.get("/overseas/chart/{ticker}")
async def get_overseas_chart(ticker: str, period: str = "D", count: int = 100):
"""미국 주식 차트 데이터 조회
period: D(일), W(주), M(월)
"""
from app.services.exchange_detector import detect_exchange
exchange = detect_exchange(ticker)
result = await korea_investment_api.get_overseas_daily_chart(ticker, exchange, period)
if not result:
raise HTTPException(status_code=404, detail=f"해외 차트 조회 실패: {ticker}")
# Limit results
if isinstance(result, list):
result = result[:count]
return result
@router.get("/overseas/exchange-rate")
async def get_exchange_rate():
"""현재 USD/KRW 환율 조회"""
try:
rate = await korea_investment_api.get_exchange_rate()
return {"currency_pair": "USD/KRW", "rate": rate}
except Exception as e:
raise HTTPException(status_code=500, detail=f"환율 조회 실패: {str(e)}")