Files
auto-stock/backend/app/api/endpoints/market_data.py
shuim a45007cbb2 feat: TradingView 스타일 대시보드 + 차트 오버레이 API, Crisis 손절 소급 차단 플래그(기본 OFF)
- frontend: 대시보드를 TradingView 레이아웃으로 재구성(components/tv/*) — 캔들+MA/BB/거래량,
  평단·손절·목표·트레일링 가격선, 체결 마커, RSI 기준선은 실효 설정값, 하단 탭(보유·체결·DT 카드·
  비용 후 손익곡선/낙폭·저널), 디자인 토큰·한국식 색·최소 11px, 모바일 가로 스크롤 제거
- backend(GET 전용, require_api_key): /positions/overlay, /risk/equity-history,
  /strategy-config/display, /trades 응답 필드(side·strategy_type·commission·net_profit_loss),
  /risk/mdd-status thresholds, /market-data/chart 1m|5m(캐시·in-flight 공유)
- mean_reversion_strategy: RSI 청산 임계를 resolve_swing_exit_rsi_v2 로 추출(동작 동일) —
  청산과 화면 표시가 같은 값을 쓴다
- §8.5-39: SWING_STOP_IGNORE_CRISIS_OVERLAY(기본 False) — 반사실 재생에서 Crisis 조기 손절이
  오히려 손실을 줄여 기본 OFF 유지, 정식 백테스트 전 켜지 말 것
- 유닛 스위트 2816 passed / 0 failed, tsc·build 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 18:47:00 +09:00

1522 lines
68 KiB
Python
Raw Permalink 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 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)}")