""" 시세 데이터 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 time import random import re 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 쓰기 없음] REST /chart 와 WebSocket get_chart(백그라운드 태스크, 인메모리 캐시 미스 시) 양쪽에서 호출된다. SELECT 1회만 수행하고 add/commit 이 없다. KIS 수신 루프·주문 경로에서는 호출되지 않는다. [M-5] OHLC 중 0/결측이 있는 행(거래정지·수집 결함)은 제외 — 네이버 경로와 동일 기준. """ try: from app.core.database import AsyncSessionLocal from app.models.models import StockDailyPrice # DB-READ-EXCEPTION: 차트 일봉 SELECT 전용(commit·add 없음). WS get_chart 는 메시지 루프가 아닌 # 백그라운드 태스크에서 캐시 미스일 때만 도달한다. 쓰기는 이 경로에 추가하지 말 것. 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 = [] skipped = 0 for row in reversed(rows): o = float(row.open_price or 0) h = float(row.high_price or 0) l = float(row.low_price or 0) c = float(row.close_price or 0) if o <= 0 or h <= 0 or l <= 0 or c <= 0: skipped += 1 continue chart_data.append({ "date": row.trade_date.strftime("%Y%m%d"), "open": o, "high": h, "low": l, "close": c, "volume": int(row.volume or 0), }) if skipped: logger.info(f"차트 DB 0/결측 OHLC 행 제외 ({stock_code}): {skipped}행") return chart_data except Exception as e: logger.warning(f"DB 차트 조회 실패 ({stock_code}): {e}") return [] _CHART_LIVE_TTL = 30 # 당일 캔들을 합성/병합한 결과의 캐시 TTL(초) def _chart_session_state(now_kst: datetime) -> tuple: """(expected_last_date, in_session) — 일봉에 있어야 할 가장 최근 거래일과 장중 여부. expected_last_date: 오늘이 거래일이고 09:00 이후면 오늘, 아니면 직전 거래일(YYYYMMDD). """ from app.core.market_schedule import is_krx_holiday d = now_kst.date() is_trading_day = d.weekday() < 5 and not is_krx_holiday(d) started = now_kst.time() >= datetime.strptime("09:00", "%H:%M").time() in_session = is_trading_day and started and now_kst.time() < datetime.strptime("15:30", "%H:%M").time() if is_trading_day and started: return d.strftime("%Y%m%d"), in_session d -= timedelta(days=1) while d.weekday() >= 5 or is_krx_holiday(d): d -= timedelta(days=1) return d.strftime("%Y%m%d"), False def _naver_rows_to_bars(naver_data: list) -> list: rows = [] for item in naver_data or []: 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 rows.append({"date": d, "open": o, "high": h, "low": l, "close": c, "volume": int(item.get("acml_vol") or 0)}) return rows _DAYBAR_KEY_PREFIX = "daybar:" _DAYBAR_MIN_TTL = 60 # 저장 TTL 하한(초) _CHART_POST_CLOSE_END = "15:40" # 장 마감 직후(단일가 체결 전 값 가능) 짧은 캐시 구간 끝 def _daybar_ttl_seconds(now_kst: datetime) -> int: """daybar 키 TTL — 당일 15:40(KST)까지 남은 초(하한 _DAYBAR_MIN_TTL).""" end = datetime.strptime(_CHART_POST_CLOSE_END, "%H:%M").time() end_dt = now_kst.replace(hour=end.hour, minute=end.minute, second=0, microsecond=0) return max(_DAYBAR_MIN_TTL, int((end_dt - now_kst).total_seconds())) def _is_post_close_window(now_kst: datetime) -> bool: """거래일 15:30~15:40 — 네이버 당일봉이 단일가 체결 전 값일 수 있어 길게 캐시하지 않는다.""" from app.core.market_schedule import is_krx_holiday d = now_kst.date() if d.weekday() >= 5 or is_krx_holiday(d): return False t = now_kst.time() return (datetime.strptime("15:30", "%H:%M").time() <= t < datetime.strptime(_CHART_POST_CLOSE_END, "%H:%M").time()) def _parse_bar(raw, expected: str) -> dict | None: """OHLC 가 모두 양수인 당일 봉 dict 만 통과(날짜는 expected 와 같아야 함).""" if not isinstance(raw, dict): return None try: price = float(raw.get("price", raw.get("close")) or 0) op = float(raw.get("open") or 0) hi = float(raw.get("high") or 0) lo = float(raw.get("low") or 0) vol = int(raw.get("volume") or 0) except (TypeError, ValueError): return None if price <= 0 or op <= 0 or hi <= 0 or lo <= 0: return None return {"date": expected, "open": op, "high": max(hi, price, op), "low": min(lo, price, op), "close": price, "volume": vol} async def _today_bar_from_tick(stock_code: str, expected: str) -> dict | None: """Redis tick:{code}(KIS 실시간 체결, 시가/고가/저가 포함)으로 당일 봉 합성. tick 은 TTL 10초라 저유동 종목은 자주 비므로, 합성한 당일봉을 daybar:{code}(TTL 15:40 까지)에 누적(고가=max, 저가=min)해 두고 tick 이 없을 때 그 값을 쓴다. 둘 다 없거나 불완전하면 None. """ from app.core.redis import get_json, setex_json tick = None try: tick = await get_json(f"tick:{stock_code}") except Exception as e: warn_throttled(logger, "market_data:tick_bar", "tick 캐시 조회 실패 (%s): %s", stock_code, e) saved = None try: raw_saved = await get_json(f"{_DAYBAR_KEY_PREFIX}{stock_code}") if isinstance(raw_saved, dict) and str(raw_saved.get("date")) == expected: # 전일 잔존 값 무시 saved = _parse_bar(raw_saved, expected) except Exception as e: warn_throttled(logger, "market_data:daybar_get", "daybar 조회 실패 (%s): %s", stock_code, e) bar = _parse_bar(tick, expected) if isinstance(tick, dict) else None if bar is None: return saved if saved is not None: # 시가는 tick(KIS 당일 시가) 우선, 고저는 누적 극값 유지 bar["high"] = max(bar["high"], saved["high"]) bar["low"] = min(bar["low"], saved["low"]) try: await setex_json(f"{_DAYBAR_KEY_PREFIX}{stock_code}", _daybar_ttl_seconds(datetime.now(_KST)), bar) except Exception as e: warn_throttled(logger, "market_data:daybar_set", "daybar 저장 실패 (%s): %s", stock_code, e) return bar def _missing_trading_days_v2(bars: list, now_kst: datetime) -> list: """bars 첫 날짜~마지막 날짜 사이, 최근 10 캘린더일 창 안에서 빠진 거래일(YYYYMMDD).""" from app.core.market_schedule import is_krx_holiday try: have = {str(b.get("date"))[:8] for b in bars} first = datetime.strptime(min(have), "%Y%m%d").date() last = datetime.strptime(max(have), "%Y%m%d").date() except (ValueError, TypeError): return [] d = max(first, now_kst.date() - timedelta(days=10)) missing = [] while d < last: ds = d.strftime("%Y%m%d") if d.weekday() < 5 and not is_krx_holiday(d) and ds not in have: missing.append(ds) d += timedelta(days=1) return missing _CHART_SPLIT_MISMATCH_RATIO = 0.20 # 분할·수정주가 판정 임계(종가 비율 편차) async def _merge_recent_daily_bars(stock_code: str, bars: list, now_kst: datetime | None = None) -> tuple: """DB 일봉이 최신 거래일보다 뒤처졌을 때 누락 봉을 채운다. (bars, live_merged) 반환. 원인(2026-10-08): DB(stock_daily_prices) 갱신이 하루 이상 늦어도 _CHART_STALE_DAYS(7일) 미만이면 폴백이 없고, 장중 당일 봉은 어디서도 합성되지 않아 차트가 멈춰 보였다. 1) 누락 거래일: 네이버 최근 일봉으로 보충 (읽기 전용 HTTP, DB 쓰기 없음) 2) 장중: 당일 봉을 Redis tick(시가/고가/저가/현재가/누적거래량)으로만 합성 (KIS REST 호출 없음) 예외는 경고만 남기고 원본 bars 를 반환한다(차트는 계속 나와야 한다). """ now_kst = now_kst or datetime.now(_KST) expected, in_session = _chart_session_state(now_kst) out = list(bars) last = str(out[-1].get("date", ""))[:8] if out else "" live = False # 최근 10일 창 안의 중간 결손 거래일(예: DB 일괄 수집 누락) 탐지 — last 이전 결손도 백필 대상 gaps = _missing_trading_days_v2(out, now_kst) if out else [] if not last or last < expected or gaps: try: from app.services.naver_finance import get_naver_daily_chart naver_rows = _naver_rows_to_bars(await get_naver_daily_chart(stock_code, days=10)) have = {str(b.get("date"))[:8]: b for b in out} # 장중 네이버의 당일 진행 봉은 부분봉이다 → 버린다(당일봉은 tick 합성만 인정) if in_session: naver_rows = [r for r in naver_rows if r["date"] != expected] # 원주가/수정주가 혼재 방지: 겹치는 날짜 종가가 분할 수준(>20%)으로 어긋날 때만 병합 안 함. # 네이버-KRX 일상적 차이(최대 ~1.3%)는 DB 봉을 유지하고 누락 날짜만 보충한다. mismatch = any( float(have[r["date"]].get("close") or 0) > 0 and abs(r["close"] / float(have[r["date"]]["close"]) - 1) > _CHART_SPLIT_MISMATCH_RATIO for r in naver_rows if r["date"] in have ) if mismatch: logger.warning(f"차트 보충 생략({stock_code}): DB/네이버 종가 불일치(분할·수정주가 추정)") else: first = str(out[0].get("date"))[:8] if out else "" added = [r for r in naver_rows if r["date"] not in have and r["date"] > first] if added: out.extend(added) out.sort(key=lambda b: b["date"]) last = out[-1]["date"] logger.info(f"차트 누락 봉 보충({stock_code}): {[a['date'] for a in added]}") except Exception as e: logger.warning(f"차트 누락 봉 보충 실패 ({stock_code}): {e}") if in_session: # [M-7] 실시간 체결 캐시(Redis tick:{code}, TTL 10초)로 합성 tick_bar = await _today_bar_from_tick(stock_code, expected) if tick_bar is not None: if out and str(out[-1].get("date"))[:8] == expected: out[-1] = tick_bar else: out.append(tick_bar) return out, True # [R1 하드닝] 차트 경로는 KIS REST 를 호출하지 않는다(주문·청산과 전역 쓰로틀 공유 방지). # tick 이 없으면(WS 등록 한도 밖 종목 등) 당일봉 없이 DB/네이버 봉만 반환한다. return out, live _CHART_MAX_LIMIT = 240 # 네이버 폴백 조회 기간(캘린더일) — 주/월봉은 집계 전 일봉이 충분해야 한다(L-4). # 캘린더 ×7 / ×31 ≈ 거래일 ×5 / ×22. _NAVER_DAYS_PER_BAR = {"D": 2, "W": 7, "M": 31} _NAVER_MAX_DAYS = 7500 _OVERSEAS_TICKER_RE = re.compile(r"[A-Za-z][A-Za-z.\-]{0,9}") def _is_overseas_ticker(stock_code: str) -> bool: """미국 티커 여부. 국내 단축코드(6자리, 숫자 포함 — 예: 005930, 0004V0)는 False.""" c = (stock_code or "").strip() return bool(c) and not any(ch.isdigit() for ch in c) and bool(_OVERSEAS_TICKER_RE.fullmatch(c)) def parse_chart_limit_v2(raw, default: int = 200) -> int: """[L-3] 클라이언트가 보낸 limit 을 안전하게 정수화(1~240). 변환 불가면 default.""" try: value = int(raw) except (TypeError, ValueError): return default return max(1, min(value, _CHART_MAX_LIMIT)) def is_chart_cache_fresh_v2(stock_code: str, period: str = "D", limit: int = 200) -> bool: """[M-7] 차트 캐시 엔트리가 자신의 ttl(장중 합성 30초 / 그 외 10분) 안에 있는지.""" cached = chart_cache.get(f"{stock_code}__{period}__{min(limit, _CHART_MAX_LIMIT)}") if not cached: return False age = (datetime.now() - cached["timestamp"]).total_seconds() return age < cached.get("ttl", _CHART_CACHE_TTL) def _chart_cache_put(cache_key: str, data: list, ttl: int, current_time: datetime) -> None: # 캐시 크기 제한: 초과 시 만료 항목 → 가장 오래된 항목 순으로 제거 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() >= v.get("ttl", _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": data, "ttl": ttl} def _overseas_rows_to_bars(result) -> list: """KIS 해외 일봉(HHDFS76240000) output2 → 차트 봉 (oldest first). 0/결측 OHLC 제외.""" items = result.get("output2", []) if isinstance(result, dict) else (result or []) bars = [] for item in items or []: try: o = float(item.get("open") or 0) h = float(item.get("high") or 0) l = float(item.get("low") or 0) c = float(item.get("clos") or 0) d = str(item.get("xymd") or "") vol = int(float(item.get("tvol") or 0)) except (TypeError, ValueError, AttributeError): continue if len(d) != 8 or o <= 0 or h <= 0 or l <= 0 or c <= 0: continue bars.append({"date": d, "open": o, "high": h, "low": l, "close": c, "volume": vol}) bars.sort(key=lambda b: b["date"]) return bars async def _get_overseas_chart_impl(stock_code: str, period: str, limit: int) -> dict: """[H-6] 미국 티커 차트 — 기존 해외 차트 경로(KIS HHDFS76240000, Redis 5분 캐시) 사용.""" cache_key = f"{stock_code}__{period}__{limit}" current_time = datetime.now() try: from app.services.exchange_detector import detect_exchange result = await korea_investment_api.get_overseas_daily_chart( stock_code, detect_exchange(stock_code), period ) bars = _overseas_rows_to_bars(result)[-limit:] except Exception as e: warn_throttled(logger, "market_data:overseas_chart", "해외 차트 조회 실패 (%s): %s", stock_code, e) bars = [] if not bars: return {"data": [], "is_sample": False, "reason": "overseas_chart_unavailable", "source": None} _chart_cache_put(cache_key, bars, _CHART_CACHE_TTL, current_time) return {"data": list(bars), "is_sample": False, "reason": None, "source": "overseas"} async def get_chart_data_cached_v2(stock_code: str, period: str, limit: int) -> dict: """차트 데이터 + 메타 반환 — REST /chart 와 WebSocket get_chart 가 공유. 반환: {"data": [...], "is_sample": False, "reason": None|"no_data"|"overseas_chart_unavailable", "source": "cache"|"db"|"naver"|"overseas"|None} 조회 우선순위 (국내): 1. 인메모리 캐시 (엔트리별 ttl — 장중 합성 30초 / 그 외 10분) 2. DB (stock_daily_prices) — 0/결측 OHLC 제외 3. 네이버 일봉 폴백 (DB 없음/오래됨) + 누락 거래일 보충·장중 당일봉 합성(_merge_recent_daily_bars) [H-6] 데이터가 없으면 난수 샘플을 만들지 않고 빈 배열 + reason 을 돌려준다. (과거 _generate_sample_chart 난수 차트가 실데이터처럼 표시됐다.) """ if period not in ("D", "W", "M"): period = "D" limit = max(1, min(int(limit), _CHART_MAX_LIMIT)) 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() < cached.get("ttl", _CHART_CACHE_TTL): return {"data": list(cached["data"]), "is_sample": False, "reason": None, "source": "cache"} del chart_cache[cache_key] if _is_overseas_ticker(stock_code): return await _get_overseas_chart_impl(stock_code, period, limit) # 2차: DB 조회 — 주/월봉은 일봉 데이터에서 집계 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)) source = "db" # 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_days = min(limit * _NAVER_DAYS_PER_BAR[period], _NAVER_MAX_DAYS) naver_rows = _naver_rows_to_bars(await get_naver_daily_chart(stock_code, days=naver_days)) if naver_rows: # 일봉은 limit 개, 주/월봉은 집계 전이므로 전부 유지(집계 후 limit 으로 자른다) db_data = naver_rows[-limit:] if period == "D" else naver_rows source = "naver" logger.info(f"차트 네이버 폴백: {stock_code} ({len(db_data)}행)") except Exception as e: logger.warning(f"네이버 차트 폴백 실패 ({stock_code}): {e}") if not db_data: return {"data": [], "is_sample": False, "reason": "no_data", "source": None} # 최신 거래일 봉 보충 + 장중 당일 봉 합성 (일봉 원본에 먼저 적용 → 주/월봉에도 반영) db_data, _live = await _merge_recent_daily_bars(stock_code, db_data) if period == "D": db_data = db_data[-limit:] else: db_data = _aggregate_weekly_monthly(db_data, period)[-limit:] # 장중이면 tick 유무와 무관하게 짧은 TTL — 구독 워밍업이 첫 tick 보다 먼저 끝나 # 당일봉 없는 결과가 10분 캐시되는 것을 막는다. _now_kst = datetime.now(_KST) _, _in_session = _chart_session_state(_now_kst) _short = _live or _in_session or _is_post_close_window(_now_kst) _chart_cache_put(cache_key, db_data, _CHART_LIVE_TTL if _short else _CHART_CACHE_TTL, current_time) return {"data": list(db_data), "is_sample": False, "reason": None, "source": source} async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> list: """캐시된 차트 데이터(list) 반환 — 호환 래퍼. 메타가 필요하면 get_chart_data_cached_v2. 데이터가 없으면 빈 리스트(샘플 난수 차트를 반환하지 않는다 — H-6). """ return (await get_chart_data_cached_v2(stock_code, period, limit))["data"] async def build_ws_chart_response_v2(stock_code: str, period, limit_raw) -> dict: """WebSocket `chart_data` 응답 생성. 예외는 빈 배열 + reason/error 로 변환한다. 필드: type, stock_code, period(요청값 그대로 — 프론트가 이것으로 응답을 매칭), data, is_sample(항상 False), reason(None|no_data|overseas_chart_unavailable|chart_fetch_failed), error(None|예외 종류명) """ limit = parse_chart_limit_v2(limit_raw) try: res = await get_chart_data_cached_v2(stock_code, period, limit) return {"type": "chart_data", "stock_code": stock_code, "period": period, "data": res["data"], "is_sample": False, "reason": res["reason"], "error": None} except Exception as e: logger.error(f"get_chart 오류 ({stock_code}): {type(e).__name__}: {e}") return {"type": "chart_data", "stock_code": stock_code, "period": period, "data": [], "is_sample": False, "reason": "chart_fetch_failed", "error": type(e).__name__} _INTRADAY_CACHE: dict = {} # key -> (monotonic, response) — 빈 결과 포함 60초 _INTRADAY_INFLIGHT: dict = {} # key -> asyncio.Task — 같은 종목·분 동시 요청 공유 _INTRADAY_CACHE_TTL = 60.0 async def _get_intraday_chart_v2(stock_code: str, period: str) -> dict: """당일 분봉 차트(1m|5m) — 60초 캐시(빈 결과 포함) + 같은 종목·분 in-flight 공유.""" key = (stock_code, period, datetime.now().strftime("%Y%m%d%H%M")) now = time.monotonic() for k in [k for k, (t, _) in _INTRADAY_CACHE.items() if now - t > _INTRADAY_CACHE_TTL]: _INTRADAY_CACHE.pop(k, None) hit = _INTRADAY_CACHE.get(key) if hit: return hit[1] task = _INTRADAY_INFLIGHT.get(key) if task is None: task = asyncio.ensure_future(_build_intraday_chart_impl(stock_code, period)) _INTRADAY_INFLIGHT[key] = task task.add_done_callback(lambda _t, _k=key: _INTRADAY_INFLIGHT.pop(_k, None)) res = await asyncio.shield(task) if res.get("reason") != "chart_fetch_failed": _INTRADAY_CACHE[key] = (time.monotonic(), res) return res async def _build_intraday_chart_impl(stock_code: str, period: str) -> dict: """KIS get_minute_chart_full_day 재사용(당일 봉만), 5m 은 1m 집계.""" from app.services.tv_view_core import minute_rows_to_candles from app.core.timeutil import today_kst base = {"stock_code": stock_code, "period": period, "is_sample": False, "realtime": False, "latest": None} if _is_overseas_ticker(stock_code): return {**base, "data": [], "reason": "overseas_chart_unavailable"} try: rows = await korea_investment_api.get_minute_chart_full_day(stock_code) data = minute_rows_to_candles(rows, today_kst(), 5 if period == "5m" else 1) return {**base, "data": data, "reason": None if data else "no_data"} except Exception as e: logger.error(f"분봉 차트 조회 오류 ({stock_code}): {type(e).__name__}: {e}") return {**base, "data": [], "reason": "chart_fetch_failed", "error": type(e).__name__} @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)") ): """주식 차트 데이터 조회 엔드포인트 Args: stock_code: 종목 코드 (국내 6자리 또는 미국 티커) period: D(일봉), W(주봉), M(월봉) limit: 조회할 데이터 개수 (일봉 100개는 약 3개월치 데이터) 데이터가 없으면 data=[] + reason. is_sample 은 항상 False(샘플 난수 차트를 만들지 않는다). period=1m|5m 은 당일 분봉(epoch 초 time) — D/W/M 동작은 그대로다. """ if period in ("1m", "5m"): return await _get_intraday_chart_v2(stock_code, period) try: res = await get_chart_data_cached_v2(stock_code, period, parse_chart_limit_v2(limit, 100)) return { "stock_code": stock_code, "period": period, "data": res["data"], "is_sample": False, "reason": res["reason"], "realtime": False, "latest": None, } except Exception as e: logger.error(f"차트 조회 오류 ({stock_code}): {type(e).__name__}: {e}") return { "stock_code": stock_code, "period": period, "data": [], "is_sample": False, "reason": "chart_fetch_failed", "error": type(e).__name__, "realtime": False, "latest": None, } @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] # ── 종목명 해석 ───────────────────────────────────────────────────────────── # KIS inquire-price(FHKST01010100)/호가 응답에는 종목명(hts_kor_isnm)이 없거나 비어 있어 # 화면에 "009420 009420" 으로 보였다(2026-10-08). 폴백 순서: # KIS 응답 필드 → 프로세스 캐시 → DB(StockAnalysis → Portfolio → StockDailyPrice) → 네이버 basic API _STOCK_NAME_MEM: Dict[str, str] = {} _STOCK_NAME_MEM_MAX = 2000 _STOCK_NAME_MISS: Dict[str, float] = {} # code -> monotonic ts (이름 못 찾음 네거티브 캐시) _STOCK_NAME_MISS_TTL = 600.0 # 초 def _name_from_kis_output(*outputs) -> str: for o in outputs: if not isinstance(o, dict): continue for k in ("hts_kor_isnm", "prdt_abrv_name", "stck_prdt_name", "prdt_name"): v = str(o.get(k) or "").strip() if v: return v return "" async def _resolve_stock_name(stock_code: str, kis_name: str = "") -> str: """종목명 해석. 못 찾으면 "" (코드로 대체하지 않는다 — 표시는 프론트 몫).""" name = (kis_name or "").strip() if name: _STOCK_NAME_MEM[stock_code] = name return name cached = _STOCK_NAME_MEM.get(stock_code) if cached: return cached _miss_ts = _STOCK_NAME_MISS.get(stock_code) if _miss_ts is not None: if (time.monotonic() - _miss_ts) < _STOCK_NAME_MISS_TTL: return "" _STOCK_NAME_MISS.pop(stock_code, None) try: from app.core.database import AsyncSessionLocal from app.models.models import StockAnalysis, Portfolio, StockDailyPrice # DB-READ-EXCEPTION: READ-ONLY SELECT (REST 엔드포인트, commit 없음) async with AsyncSessionLocal() as _db: for model, order in ( (StockAnalysis, StockAnalysis.analysis_date.desc()), (Portfolio, None), (StockDailyPrice, StockDailyPrice.trade_date.desc()), ): q = select(model.stock_name).where( model.stock_code == stock_code, model.stock_name.isnot(None), model.stock_name != "", ) if order is not None: q = q.order_by(order) row = (await _db.execute(q.limit(1))).scalar_one_or_none() if row and str(row).strip() and str(row).strip() != stock_code: name = str(row).strip() break except Exception as e: warn_throttled(logger, "market_data:name_db", "종목명 DB 조회 실패 (%s): %s", stock_code, e) if not name: try: from app.services.naver_finance import get_naver_stock_name name = await get_naver_stock_name(stock_code) except Exception as e: warn_throttled(logger, "market_data:name_naver", "종목명 네이버 조회 실패 (%s): %s", stock_code, e) if name: if len(_STOCK_NAME_MEM) >= _STOCK_NAME_MEM_MAX: _STOCK_NAME_MEM.pop(next(iter(_STOCK_NAME_MEM)), None) _STOCK_NAME_MEM[stock_code] = name _STOCK_NAME_MISS.pop(stock_code, None) else: # 이름 없음도 짧게 캐시 — 호가 폴링마다 DB 3회 + 네이버 1회 반복 방지 if len(_STOCK_NAME_MISS) >= _STOCK_NAME_MEM_MAX: _STOCK_NAME_MISS.pop(next(iter(_STOCK_NAME_MISS)), None) _STOCK_NAME_MISS[stock_code] = time.monotonic() return name # 호가 응답이 4.5~13초였던 원인(2026-10-08): 호가 1건이 KIS 전역 쓰로틀 락·세마포어에서 # 현재가/잔고/스케줄러 호출 뒤에 줄을 서고, 화면 폴링마다 같은 종목을 중복 조회했다. # → 종목별 짧은 TTL 캐시 + 동시 요청 합치기(in-flight). 실패 응답은 캐시하지 않는다. _ORDERBOOK_TTL = 1.5 # 초 _ORDERBOOK_CACHE_MAX = 200 _orderbook_cache: Dict[str, tuple] = {} # code -> (monotonic ts, payload) _orderbook_inflight: Dict[str, "asyncio.Future"] = {} @router.get("/orderbook/{stock_code}") async def get_orderbook(stock_code: str): """호가 정보 조회 엔드포인트 (매수/매도 10단계 호가) — 1.5초 TTL 캐시 + 동시요청 합치기.""" import time as _time now = _time.monotonic() hit = _orderbook_cache.get(stock_code) if hit and now - hit[0] < _ORDERBOOK_TTL: return hit[1] fut = _orderbook_inflight.get(stock_code) if fut is not None: return await asyncio.shield(fut) fut = asyncio.get_running_loop().create_future() _orderbook_inflight[stock_code] = fut try: result = await _get_orderbook_impl(stock_code) if isinstance(result, dict) and result.get("asks") and not result.get("error"): if len(_orderbook_cache) >= _ORDERBOOK_CACHE_MAX: _orderbook_cache.pop(min(_orderbook_cache, key=lambda k: _orderbook_cache[k][0]), None) _orderbook_cache[stock_code] = (_time.monotonic(), result) fut.set_result(result) return result except BaseException as e: if not fut.done(): fut.set_exception(e) fut.exception() # 대기자가 없을 때 'never retrieved' 경고 방지 raise finally: _orderbook_inflight.pop(stock_code, None) async def _get_orderbook_impl(stock_code: str): try: logger.debug(f"Fetching orderbook for stock: {stock_code}") # 종목명 해석은 KIS 호출과 병렬로 (추가 지연 방지) _name_task = asyncio.create_task(_resolve_stock_name(stock_code)) try: # 한국투자증권 API를 통해 호가 데이터 조회 data = await korea_investment_api.get_orderbook(stock_code) except BaseException: _name_task.cancel() raise # 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": await _resolve_stock_name( stock_code, _name_from_kis_output(output1, output2) or (await _name_task) ), "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. 종합 결과 반환 # 제목/추천/목표가는 최종 결론(recommendation)과 반드시 일치해야 한다. # (2026-10-08: HOLD 13% 인데 '매수 추천'·목표가·손절가가 함께 표시된 모순) _targets = _suggest_targets(current_price, _atr_for_targets) if recommendation != "BUY": # 목표가·손절가는 '신규 매수 진입' 기준 값이므로 BUY 결론에서만 의미가 있다. _targets = { "target_price": None, "stop_loss_price": None, "target_pct": None, "stop_loss_pct": None, "risk_reward": None, "target_note": f"{recommendation} 결론 — 신규 진입 목표가·손절가 제시 안 함", } _headline = {"BUY": "매수 추천", "SELL": "매도 추천", "HOLD": "관망"}.get(recommendation, "관망") _stock_name = await _resolve_stock_name(stock_code, _name_from_kis_output(output)) return { "stock_code": stock_code, "stock_name": _stock_name, "action": recommendation, "headline": _headline, "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) **_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)}")