Files
auto-stock/backend/app/api/endpoints/market_data.py
shuim 9774c1249b fix: 진입이 최소주문금액에 막혀 거래가 안 나던 문제 — 15만원으로 하향 + 종목당 4%
로그 집계(2026-09-23): 진입 차단 141건이 min_amount_guard. 최소 주문금액 50만원이
종목당 예산(총자산 982만×6%≈59만)과 거의 같아, 신뢰도·현금 보정으로 20~30만원이 되면
전부 탈락했다(기아 230,800원·엘앤에프 217,000원 등).
- MIN_BUY_AMOUNT_KRW 500,000 → 150,000
- MAX_POSITION_SIZE 0.06 → 0.04 (종목당 금액을 낮추고 동시 보유 종목 수를 늘려 표본 확보)
- AI 분석 매수 추천 금액의 신뢰도 하한 0.5 → 0.8 (과도 축소 방지)
- 설정 관계를 테스트로 고정(최소주문 ≤ 종목당 예산의 50%, 스윙 상한 내 10종목 이상)

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

1005 lines
42 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
시세 데이터 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"}
def _suggest_targets(current_price: int, atr: float | None) -> dict:
"""AI 분석 화면용 목표가·손절가 (CLAUDE.md §5.2 스윙 기준).
· 손절 = max(진입가 − ATR_STOP_MULTIPLIER×ATR, 진입가×(1−STOP_LOSS_PERCENT))
→ 둘 중 '타이트한 쪽'(가격이 높은 쪽). ATR 없으면 퍼센트 기준만 사용.
· 목표 = 진입가 + 2R (R = 진입가 − 손절가) → +2R 도달 시 50% 분할 매도 지점
적응형 모드가 활성화되어 있으면 그 손절률·ATR 배수를 따른다.
"""
from app.core.config import settings as _cfg
out = {
"target_price": 0,
"stop_loss_price": 0,
"target_pct": 0.0,
"stop_loss_pct": 0.0,
"risk_reward": 0.0,
"target_note": "",
}
if current_price <= 0:
return out
stop_pct = abs(float(getattr(_cfg, "STOP_LOSS_PERCENT", 7.0)))
atr_mult = float(getattr(_cfg, "ATR_STOP_MULTIPLIER", 2.5))
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp = get_current_adaptive_params()
if not _adp.fallback_used:
stop_pct = abs(float(_adp.stop_loss_pct))
atr_mult = float(_adp.atr_stop_multiplier)
except Exception:
pass
pct_stop = current_price * (1 - stop_pct / 100)
if atr and atr > 0:
atr_stop = current_price - atr_mult * atr
stop = max(atr_stop, pct_stop) # 타이트한 쪽 (CLAUDE.md 8.1-5: 항상 min 아닌 max=높은 가격)
basis = "ATR" if atr_stop > pct_stop else f"-{stop_pct:.0f}%"
note = f"손절 {basis} 기준(타이트한 쪽), 목표 +2R"
else:
stop = pct_stop
note = f"손절 -{stop_pct:.0f}% 기준(ATR 없음), 목표 +2R"
risk = current_price - stop
if risk <= 0:
return out
target = current_price + 2 * risk
out.update({
"target_price": int(round(target)),
"stop_loss_price": int(round(stop)),
"target_pct": round((target / current_price - 1) * 100, 2),
"stop_loss_pct": round((stop / current_price - 1) * 100, 2),
"risk_reward": 2.0,
"target_note": note,
})
return out
def _suggest_buy_amount(
current_price: int,
recommendation: str,
confidence: float,
stock_code: str,
) -> dict:
"""AI 분석 화면에 표시할 매수 추천 금액·수량 산출.
실제 자동매매 사이징과 같은 기준을 쓴다(CLAUDE.md §5.1):
· 종목당 상한 = 총자산 × MAX_POSITION_SIZE (적응형 모드면 그 값)
· 현금 여력 = 예수금 − 최소 현금보유(총자산 × (1 − MAX_TOTAL_CAPITAL_USAGE))
· 스윙 합산 상한(SWING_MAX_CAPITAL_RATIO) 잔여 한도
· 신뢰도 보정 = 예산 × (confidence / 0.7), 최대 1.2배
BUY 가 아니거나 잔고 정보를 모르면 금액 0 과 사유를 반환한다.
"""
from app.core.config import settings as _cfg
out = {
"suggested_amount": 0,
"suggested_quantity": 0,
"position_ratio": 0.0,
"sizing_note": "",
}
if current_price <= 0:
out["sizing_note"] = "현재가 없음"
return out
if recommendation != "BUY":
out["sizing_note"] = f"{recommendation} 신호 — 신규 매수 제안 없음"
return out
try:
from app.api.endpoints.portfolio import portfolio_cache
summary = portfolio_cache.get_summary_copy() or {}
total_assets = float(summary.get("total_assets", 0) or 0)
available_cash = float(summary.get("available_cash", 0) or 0)
stock_value = float(summary.get("total_value", 0) or 0)
except Exception:
total_assets = available_cash = stock_value = 0.0
if total_assets <= 0 or available_cash <= 0:
out["sizing_note"] = "잔고 조회 전 — 금액 계산 불가"
return out
position_cap = float(getattr(_cfg, "MAX_POSITION_SIZE", 0.06))
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp = get_current_adaptive_params()
if not _adp.fallback_used:
position_cap = float(_adp.position_size)
except Exception:
pass
budget = total_assets * position_cap
# 신뢰도 보정 (기준 0.7)
if confidence > 0:
budget *= min(1.2, max(0.8, confidence / 0.7)) # 하한 0.8 (과도 축소 방지)
# 현금 여력
reserve = total_assets * (1 - float(getattr(_cfg, "MAX_TOTAL_CAPITAL_USAGE", 0.95)))
budget = min(budget, max(0.0, available_cash - reserve))
# 스윙 합산 상한 잔여
swing_cap = float(getattr(_cfg, "SWING_MAX_CAPITAL_RATIO", 1.0) or 1.0)
if 0 < swing_cap < 1:
budget = min(budget, max(0.0, total_assets * swing_cap - stock_value))
qty = int(budget // current_price)
if qty <= 0:
out["sizing_note"] = "가용 예산이 1주 미만 — 매수 제안 없음"
return out
amount = qty * current_price
out.update({
"suggested_amount": amount,
"suggested_quantity": qty,
"position_ratio": round(amount / total_assets * 100, 2),
"sizing_note": (
f"총자산 {total_assets:,.0f}원 × 종목당 {position_cap * 100:.0f}%"
f"(신뢰도 {confidence * 100:.0f}% 반영) 기준"
),
})
return out
@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 = {}
_atr_for_targets = None
if analysis_record and getattr(analysis_record, "atr_14", None):
try:
_atr_for_targets = float(analysis_record.atr_14)
except Exception:
_atr_for_targets = None
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 = "기술적 지표 데이터 없음"
# 목표가·손절가용 ATR — 분석 레코드에 없으면 DB 일봉으로 계산
if _atr_for_targets is None:
try:
from app.services.mean_reversion_strategy import calculate_atr
_db_chart = await _fetch_chart_from_db(stock_code, 30)
if len(_db_chart) >= 15:
_atr_for_targets = calculate_atr(
[c["high"] for c in _db_chart],
[c["low"] for c in _db_chart],
[c["close"] for c in _db_chart],
)
except Exception as _atr_err:
logger.debug(f"[AI분석] ATR 계산 실패 ({stock_code}): {_atr_err}")
# 추세 최종 결정 (이동평균선 없으면 가격 변동으로)
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,
# 매수 추천 금액 (실제 자동매매 사이징과 동일 기준)
**_suggest_buy_amount(current_price, recommendation, confidence, stock_code),
# 목표가·손절가 (CLAUDE.md §5.2)
**_suggest_targets(current_price, _atr_for_targets),
"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)}")