- 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>
1522 lines
68 KiB
Python
1522 lines
68 KiB
Python
"""
|
||
시세 데이터 API 엔드포인트
|
||
|
||
- GET /price/{stock_code}: 실시간 현재가 조회
|
||
- GET /chart/{stock_code}: 차트 데이터 (일봉/주봉/월봉)
|
||
- GET /orderbook/{stock_code}: 호가 정보 조회
|
||
- GET /ai-analysis/{stock_code}: AI 기반 종목 분석
|
||
"""
|
||
# 필요한 라이브러리 및 모듈 임포트
|
||
from datetime import datetime, timedelta
|
||
import asyncio
|
||
import 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)}")
|