하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소. 주문·리스크 - MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문) - KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류) - 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백 - 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락 - 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드) - 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단 데이트레이딩 - 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한 - 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정 스윙 - 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT) - 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed - 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일 - 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON 인프라·보안 - 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹 - KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환 - db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시 - KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패 하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준 CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신 backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1027 lines
44 KiB
Python
1027 lines
44 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.log_throttle import warn_throttled
|
||
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 as _adp_err:
|
||
# [§4 2026-10-02] 폴백(config 값) 유지, 단 조용히 넘기지 않는다.
|
||
warn_throttled(
|
||
logger, "market_data:target_stop_overlay",
|
||
"[AI분석] 적응형 손절/ATR 배수 조회 실패 — config 값 사용: %s", _adp_err,
|
||
)
|
||
stop_pct = abs(float(getattr(_cfg, "STOP_LOSS_PERCENT", 7.0)))
|
||
atr_mult = float(getattr(_cfg, "ATR_STOP_MULTIPLIER", 2.5))
|
||
|
||
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
|
||
|
||
# [2026-09-28] MAX_POSITION_SIZE 는 CLAUDE.md §5.1 종목당 **절대 상한**이다.
|
||
# 적응형 모드(Normal 6% / Aggressive 7%)를 그대로 쓰면 이 화면이 한도를 넘는
|
||
# 금액을 제안하고, 운영자가 그대로 수동매수하면 상한이 깨진다.
|
||
# 실행 경로(trade_executor.py:898, 981-987)와 동일하게 min() 으로 클램프한다.
|
||
_hard_cap = float(getattr(_cfg, "MAX_POSITION_SIZE", 0.04))
|
||
position_cap = _hard_cap
|
||
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 as _adp_err:
|
||
warn_throttled(
|
||
logger, "market_data:position_cap_overlay",
|
||
"[AI분석] 적응형 position_size 조회 실패 — MAX_POSITION_SIZE 사용: %s", _adp_err,
|
||
)
|
||
position_cap = _hard_cap
|
||
# [§4 2026-10-02] 절대 상한 클램프는 try 밖에서 무조건 적용.
|
||
position_cap = max(0.0, min(position_cap, _hard_cap))
|
||
|
||
budget = total_assets * position_cap
|
||
# 신뢰도 보정 (기준 0.7)
|
||
if confidence > 0:
|
||
budget *= min(1.2, max(0.8, confidence / 0.7)) # 하한 0.8 (과도 축소 방지)
|
||
# 신뢰도 상향 보정(최대 ×1.2)이 절대 상한을 넘기지 못하게 재클램프.
|
||
# 실행 경로도 패턴메모리 조정 뒤 같은 상한을 다시 적용하고(trade_executor.py:981-987),
|
||
# manual_buy.py:120 의 자동 수량도 총자산×MAX_POSITION_SIZE 예산을 쓴다.
|
||
budget = min(budget, total_assets * _hard_cap)
|
||
# 현금 여력
|
||
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.warning(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.warning(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)}")
|