from fastapi import APIRouter, HTTPException, Query, Depends from typing import List, Optional from pydantic import BaseModel from datetime import datetime, date from decimal import Decimal import asyncio import logging from app.services.korea_investment_api import korea_investment_api from app.core.database import get_db from app.models.models import TradeHistory, Portfolio, StockAnalysis from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, and_ router = APIRouter() logger = logging.getLogger(__name__) class RecommendationCriteria(BaseModel): """추천 종목 조건""" use_macd: bool = False use_rsi: bool = False use_bollinger: bool = False use_moving_average: bool = False use_stochastic: bool = False use_adx: bool = False use_obv: bool = False class StockRecommendation(BaseModel): """추천 종목 정보""" stock_code: str stock_name: str current_price: float change_rate: float signals: List[str] score: int price_date: str = "" # 가격 기준일 (YYYYMMDD 형식) # 추가 지표 (배치 저장 및 API 응답에 사용) volume: Optional[int] = None rsi: Optional[float] = None ma5: Optional[float] = None ma20: Optional[float] = None ma60: Optional[float] = None stochastic_k: Optional[float] = None stochastic_d: Optional[float] = None bollinger_upper: Optional[float] = None bollinger_lower: Optional[float] = None bollinger_middle: Optional[float] = None # 공식 기반 신호 분석 함수 (이동평균 정배열/역배열, 눌림목, 손절/익절) def analyze_custom_signals(chart_data): """커스텀 매매 공식 기반 신호 분석""" closes = [float(item.get("stck_clpr", 0)) for item in chart_data] if len(closes) < 120: return None ma20 = sum(closes[-20:]) / 20 ma60 = sum(closes[-60:]) / 60 ma120 = sum(closes[-120:]) / 120 close = closes[-1] # 정배열 매수 if ma20 > ma60 > ma120 and close > ma20: return {"signal": "buy", "description": "정배열 상승 매수타점"} # 역배열 매도 elif ma20 < ma60 < ma120 and close < ma20: return {"signal": "sell", "description": "역배열 하락 매도타점"} # 눌림목 진입 recent_lows = [float(item.get("stck_lwpr", 0)) for item in chart_data[-5:]] if any(low < ma20 for low in recent_lows) and close > ma20: return {"signal": "buy", "description": "눌림목 진입 매수타점"} # 손절/익절 stop_loss_line = ma60 take_profit_line = ma20 * 1.05 if close < stop_loss_line: return {"signal": "exit", "description": "손절라인 이탈"} if close > take_profit_line: return {"signal": "take_profit", "description": "익절라인 돌파"} return None def analyze_macd(chart_data: list) -> dict: """MACD 분석 (골든크로스/데드크로스)""" if len(chart_data) < 26: return {"signal": None, "description": "데이터 부족"} # 간단한 MACD 계산 (12일, 26일, 9일) prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-26:]] if len(prices) < 26: return {"signal": None, "description": "데이터 부족"} # EMA 계산 def calculate_ema(prices, period): ema = [sum(prices[:period]) / period] multiplier = 2 / (period + 1) for price in prices[period:]: ema.append((price - ema[-1]) * multiplier + ema[-1]) return ema ema12 = calculate_ema(prices, 12) ema26 = calculate_ema(prices, 26) if len(ema12) < 2 or len(ema26) < 2: return {"signal": None, "description": "데이터 부족"} macd = [ema12[i] - ema26[i] for i in range(len(ema26))] signal = calculate_ema(macd, 9) if len(signal) < 2: return {"signal": None, "description": "데이터 부족"} # 골든크로스: MACD가 시그널선을 상향 돌파 if macd[-2] <= signal[-2] and macd[-1] > signal[-1]: return {"signal": "buy", "description": "MACD 골든크로스"} # 데드크로스: MACD가 시그널선을 하향 돌파 elif macd[-2] >= signal[-2] and macd[-1] < signal[-1]: return {"signal": "sell", "description": "MACD 데드크로스"} return {"signal": None, "description": "신호 없음"} def analyze_rsi(chart_data: list) -> dict: """RSI 분석 (과매수/과매도)""" if len(chart_data) < 14: return {"signal": None, "description": "데이터 부족"} prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-15:]] # RSI 계산 gains = [] losses = [] for i in range(1, len(prices)): change = prices[i] - prices[i-1] if change > 0: gains.append(change) losses.append(0) else: gains.append(0) losses.append(abs(change)) avg_gain = sum(gains[-14:]) / 14 avg_loss = sum(losses[-14:]) / 14 if avg_loss == 0: rsi = 100 else: rs = avg_gain / avg_loss rsi = 100 - (100 / (1 + rs)) # 조건 완화: 40 이하 매수, 60 이상 매도 if rsi < 40: return {"signal": "buy", "description": f"RSI 과매도 ({rsi:.1f})"} elif rsi > 60: return {"signal": "sell", "description": f"RSI 과매수 ({rsi:.1f})"} elif 45 <= rsi <= 55: # 중립 구간도 약한 매수 신호로 return {"signal": "buy", "description": f"RSI 중립-상승 ({rsi:.1f})"} return {"signal": None, "description": f"RSI 중립 ({rsi:.1f})"} def analyze_bollinger(chart_data: list) -> dict: """볼린저밴드 분석""" if len(chart_data) < 20: return {"signal": None, "description": "데이터 부족"} prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-20:]] # 20일 이동평균 ma20 = sum(prices) / len(prices) # 표준편차 variance = sum((p - ma20) ** 2 for p in prices) / len(prices) std_dev = variance ** 0.5 upper_band = ma20 + (2 * std_dev) lower_band = ma20 - (2 * std_dev) current_price = prices[-1] if current_price < lower_band: return {"signal": "buy", "description": "볼린저 하한선 터치"} elif current_price > upper_band: return {"signal": "sell", "description": "볼린저 상한선 돌파"} return {"signal": None, "description": "볼린저 중립"} def analyze_moving_average(chart_data: list) -> dict: """이동평균선 분석 (5, 20, 60일)""" if len(chart_data) < 60: return {"signal": None, "description": "데이터 부족"} prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-60:]] ma5 = sum(prices[-5:]) / 5 ma20 = sum(prices[-20:]) / 20 ma60 = sum(prices[-60:]) / 60 current_price = prices[-1] # 조건 완화: 5일선이 20일선 위에만 있어도 매수 if ma5 > ma20 and current_price > ma5: return {"signal": "buy", "description": "5일선 > 20일선"} # 20일선이 60일선 위에 있으면 추가 점수 elif ma20 > ma60 and current_price > ma20: return {"signal": "buy", "description": "20일선 > 60일선"} # 역배열 elif ma5 < ma20 < ma60 and current_price < ma5: return {"signal": "sell", "description": "이동평균 역배열"} return {"signal": None, "description": "이동평균 중립"} def analyze_stochastic(chart_data: list) -> dict: """스토캐스틱 분석""" if len(chart_data) < 14: return {"signal": None, "description": "데이터 부족"} recent_data = chart_data[-14:] highs = [float(item.get("stck_hgpr", 0)) for item in recent_data] lows = [float(item.get("stck_lwpr", 0)) for item in recent_data] closes = [float(item.get("stck_clpr", 0)) for item in recent_data] highest_high = max(highs) lowest_low = min(lows) current_close = closes[-1] if highest_high == lowest_low: return {"signal": None, "description": "데이터 부족"} k = ((current_close - lowest_low) / (highest_high - lowest_low)) * 100 # 조건 완화: 30 이하 매수, 70 이상 매도 if k < 30: return {"signal": "buy", "description": f"스토캐스틱 과매도 (%K: {k:.1f})"} elif k > 70: return {"signal": "sell", "description": f"스토캐스틱 과매수 (%K: {k:.1f})"} return {"signal": None, "description": f"스토캐스틱 중립 (%K: {k:.1f})"} def analyze_adx(chart_data: list) -> dict: """ADX 분석 (추세 강도)""" if len(chart_data) < 14: return {"signal": None, "description": "데이터 부족"} # 간단한 ADX 추정 (변동성 기반) recent_data = chart_data[-14:] changes = [] for item in recent_data: high = float(item.get("stck_hgpr", 0)) low = float(item.get("stck_lwpr", 0)) changes.append(high - low) avg_change = sum(changes) / len(changes) current_change = changes[-1] # 추세 강도 추정 if current_change > avg_change * 1.5: return {"signal": "buy", "description": "ADX 강한 추세"} elif current_change < avg_change * 0.5: return {"signal": "sell", "description": "ADX 약한 추세"} return {"signal": None, "description": "ADX 중립"} def analyze_obv(chart_data: list) -> dict: """거래량(OBV) 분석""" if len(chart_data) < 2: return {"signal": None, "description": "데이터 부족"} volumes = [int(item.get("acml_vol", 0)) for item in chart_data[-5:]] closes = [float(item.get("stck_clpr", 0)) for item in chart_data[-5:]] if len(volumes) < 2 or len(closes) < 2: return {"signal": None, "description": "데이터 부족"} # 거래량 증가와 가격 상승 if volumes[-1] > volumes[-2] * 1.5 and closes[-1] > closes[-2]: return {"signal": "buy", "description": "거래량 증가와 상승"} # 거래량 감소와 가격 하락 elif volumes[-1] < volumes[-2] * 0.5 and closes[-1] < closes[-2]: return {"signal": "sell", "description": "거래량 감소와 하락"} return {"signal": None, "description": "거래량 중립"} async def analyze_stock(stock_code: str, stock_name: str, criteria: RecommendationCriteria) -> Optional[StockRecommendation]: """개별 종목 분석""" try: logger.info(f"Analyzing stock: {stock_code}") # 차트 데이터 조회 (현재가 포함) chart_response = await korea_investment_api.get_daily_chart(stock_code, "D") if "output" not in chart_response or not chart_response["output"]: logger.warning(f"No chart data for {stock_code}") return None chart_data = chart_response["output"] logger.info(f"Got {len(chart_data)} chart data points for {stock_code}") # 최신 데이터에서 현재가 및 종목명 추출 latest = chart_data[0] if chart_data else None if not latest: return None # 종목명이 없으면 차트 데이터에서 추출 시도 if not stock_name: stock_name = latest.get("hts_kor_isnm", stock_code) current_price = float(latest.get("stck_clpr", 0)) price_date = latest.get("stck_bsop_date", "") # 가격 기준일 volume = int(latest.get("acml_vol", 0) or 0) if current_price == 0: logger.warning(f"Zero price for {stock_code}") return None # 등락률 계산 if len(chart_data) >= 2: prev_price = float(chart_data[1].get("stck_clpr", current_price)) change_rate = ((current_price - prev_price) / prev_price) * 100 if prev_price > 0 else 0 else: change_rate = 0 # 기본 수치형 지표 계산 (배치 저장용) closes = [float(item.get("stck_clpr", 0)) for item in chart_data] 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 # RSI 계산 (단순 방식) rsi = None if len(closes) >= 15: prices_for_rsi = closes[-15:] gains = [] losses = [] for i in range(1, len(prices_for_rsi)): diff = prices_for_rsi[i] - prices_for_rsi[i-1] gains.append(max(diff, 0)) losses.append(max(-diff, 0)) avg_gain = sum(gains[-14:]) / 14 avg_loss = sum(losses[-14:]) / 14 if avg_loss == 0: rsi = 100.0 else: rs = avg_gain / avg_loss rsi = 100 - (100 / (1 + rs)) # Stochastic %K, %D 간단 계산 stochastic_k = None stochastic_d = None if len(chart_data) >= 14: recent = chart_data[-14:] highs = [float(item.get("stck_hgpr", 0)) for item in recent] lows = [float(item.get("stck_lwpr", 0)) for item in recent] closes_for_stoch = [float(item.get("stck_clpr", 0)) for item in recent] highest_high = max(highs) lowest_low = min(lows) if highest_high != lowest_low: k = ((closes_for_stoch[-1] - lowest_low) / (highest_high - lowest_low)) * 100 stochastic_k = k # %D는 간단히 최근 3일 %K 평균 stochastic_d_vals = [] for j in range(len(recent)-3, len(recent)): slice_high = max([float(item.get("stck_hgpr", 0)) for item in recent[j-3 if j-3>=0 else 0:j+1]]) slice_low = min([float(item.get("stck_lwpr", 0)) for item in recent[j-3 if j-3>=0 else 0:j+1]]) if slice_high != slice_low: slice_k = ((float(recent[j].get("stck_clpr", 0)) - slice_low) / (slice_high - slice_low)) * 100 stochastic_d_vals.append(slice_k) if stochastic_d_vals: stochastic_d = sum(stochastic_d_vals) / len(stochastic_d_vals) # Bollinger bands bollinger_upper = bollinger_lower = bollinger_middle = None if len(closes) >= 20: window = closes[-20:] middle = sum(window) / len(window) variance = sum((p - middle) ** 2 for p in window) / len(window) stddev = variance ** 0.5 bollinger_middle = middle bollinger_upper = middle + 2 * stddev bollinger_lower = middle - 2 * stddev # 지표 분석 signals = [] score = 0 # 커스텀 공식 기반 신호 분석 적용 (정배열, 역배열, 눌림목, 손절/익절) custom_result = analyze_custom_signals(chart_data) if custom_result: if custom_result["signal"] == "buy": signals.append(custom_result["description"]) score += 3 # 높은 가중치 elif custom_result["signal"] == "sell": # 매도 신호는 추천에서 제외하거나 음수 점수 signals.append(custom_result["description"]) score -= 2 elif custom_result["signal"] == "exit": # 손절 신호는 추천에서 제외 signals.append(custom_result["description"]) score -= 5 elif custom_result["signal"] == "take_profit": # 익절 신호는 양수 점수 signals.append(custom_result["description"]) score += 2 if criteria.use_macd: result = analyze_macd(chart_data) if result["signal"] == "buy": signals.append(result["description"]) score += 2 if criteria.use_rsi: result = analyze_rsi(chart_data) if result["signal"] == "buy": signals.append(result["description"]) score += 2 if criteria.use_bollinger: result = analyze_bollinger(chart_data) if result["signal"] == "buy": signals.append(result["description"]) score += 1 if criteria.use_moving_average: result = analyze_moving_average(chart_data) if result["signal"] == "buy": signals.append(result["description"]) score += 2 if criteria.use_stochastic: result = analyze_stochastic(chart_data) if result["signal"] == "buy": signals.append(result["description"]) score += 1 if criteria.use_adx: result = analyze_adx(chart_data) if result["signal"] == "buy": signals.append(result["description"]) score += 1 if criteria.use_obv: result = analyze_obv(chart_data) if result["signal"] == "buy": signals.append(result["description"]) score += 1 logger.info(f"{stock_code} ({stock_name}): score={score}, signals={signals}") # 신호가 있거나 점수가 0 이상이면 추천 (규칙 기반에서는 매칭된 종목 모두 표시) if len(signals) > 0 or score > 0: # 신호가 있으면 최소 1점 보장 final_score = max(1, score) if len(signals) > 0 else score return StockRecommendation( stock_code=stock_code, stock_name=stock_name, current_price=current_price, change_rate=change_rate, signals=signals if signals else ["조건 분석 중"], score=final_score, price_date=price_date, volume=volume, rsi=rsi, ma5=ma5, ma20=ma20, ma60=ma60, stochastic_k=stochastic_k, stochastic_d=stochastic_d, bollinger_upper=bollinger_upper, bollinger_lower=bollinger_lower, bollinger_middle=bollinger_middle ) logger.debug(f"{stock_code}: No recommendation (score={score})") return None except Exception as e: logger.error(f"Error analyzing {stock_code}: {str(e)}") return None async def analyze_stock_with_indicators(stock_code: str, stock_name: str) -> Optional[StockRecommendation]: """호출 편의성: 모든 지표를 켜서 분석한 결과(Pydantic 모델)를 반환합니다. 이 함수는 배치 스크립트에서 사용됩니다. """ criteria = RecommendationCriteria( use_macd=True, use_rsi=True, use_bollinger=True, use_moving_average=True, use_stochastic=True, use_adx=True, use_obv=True ) return await analyze_stock(stock_code, stock_name, criteria) async def get_all_tradable_stocks() -> List[tuple]: """KIS API로 전체 거래 가능 종목 조회""" try: logger.info("Fetching all tradable stocks from KIS API...") # 이전 API 호출과의 충돌을 피하기 위해 대기 await asyncio.sleep(3.0) # KOSPI 거래대금 상위 종목 조회 kospi_result = await korea_investment_api.get_stock_list("J") logger.info(f"KOSPI result: {kospi_result}") # Rate limit을 위한 대기 (초당 1건 제한) - 안전하게 3초 대기 await asyncio.sleep(3.0) # KOSDAQ 거래대금 상위 종목 조회 kosdaq_result = await korea_investment_api.get_stock_list("Q") logger.info(f"KOSDAQ result: {kosdaq_result}") stocks = [] # KOSPI 종목 추출 if "output" in kospi_result: for item in kospi_result["output"]: stock_code = item.get("mksc_shrn_iscd", "") stock_name = item.get("hts_kor_isnm", "") if stock_code and stock_name: stocks.append((stock_code, stock_name)) # KOSDAQ 종목 추가 if "output" in kosdaq_result: for item in kosdaq_result["output"]: stock_code = item.get("mksc_shrn_iscd", "") stock_name = item.get("hts_kor_isnm", "") if stock_code and stock_name: stocks.append((stock_code, stock_name)) logger.info(f"Found {len(stocks)} tradable stocks from KIS API") # API에서 종목을 못 가져온 경우 기본 종목 리스트 사용 if not stocks: logger.warning("No stocks from KIS API, using default list") stocks = [ ("005930", "삼성전자"), ("000660", "SK하이닉스"), ("035420", "네이버"), ("051910", "LG화학"), ("005380", "현대차"), ("006400", "삼성SDI"), ("035720", "카카오"), ("000270", "기아"), ("068270", "셀트리온"), ("207940", "삼성바이오로직스"), ] return stocks except Exception as e: logger.error(f"Error fetching stock list from KIS API: {e}", exc_info=True) # API 실패 시 기본 종목 리스트 반환 logger.warning("Using default stock list due to API error") return [ ("005930", "삼성전자"), ("000660", "SK하이닉스"), ("035420", "네이버"), ("051910", "LG화학"), ("005380", "현대차"), ("006400", "삼성SDI"), ("035720", "카카오"), ("000270", "기아"), ("068270", "셀트리온"), ("207940", "삼성바이오로직스"), ] @router.get("/recommendations") async def get_recommendations( use_macd: bool = Query(False, description="MACD 지표 사용"), use_rsi: bool = Query(False, description="RSI 지표 사용"), use_bollinger: bool = Query(False, description="볼린저밴드 사용"), use_moving_average: bool = Query(False, description="이동평균선 사용"), use_stochastic: bool = Query(False, description="스토캐스틱 사용"), use_adx: bool = Query(False, description="ADX 사용"), use_obv: bool = Query(False, description="거래량(OBV) 사용"), max_stocks: int = Query(100, description="분석할 최대 종목 수 (기본 100개)"), ): """기술적 지표 기반 추천 종목 조회 선택한 기술적 지표 조건에 부합하는 종목을 KOSPI 시가총액 상위 종목 중에서 찾아 추천합니다. """ criteria = RecommendationCriteria( use_macd=use_macd, use_rsi=use_rsi, use_bollinger=use_bollinger, use_moving_average=use_moving_average, use_stochastic=use_stochastic, use_adx=use_adx, use_obv=use_obv ) # 모든 지표가 꺼져있으면 기본값 사용 if not any([use_macd, use_rsi, use_bollinger, use_moving_average, use_stochastic, use_adx, use_obv]): criteria.use_rsi = True criteria.use_moving_average = True # 분석할 종목 리스트 가져오기 (KIS API에서 실시간 조회) all_stocks = await get_all_tradable_stocks() if not all_stocks: logger.warning("No stocks found from KIS API") return { "total": 0, "analyzed_stocks": 0, "criteria": criteria.dict(), "recommendations": [], "message": "거래 가능한 종목을 조회할 수 없습니다" } stocks_to_analyze = all_stocks[:max_stocks] logger.info(f"Analyzing {len(stocks_to_analyze)} stocks with criteria: {criteria.dict()}") # Rate Limit 방지: 배치 처리 (초당 최대 5개) batch_size = 5 delay_between_batches = 1.2 # 1.2초 대기 all_results = [] for i in range(0, len(stocks_to_analyze), batch_size): batch = stocks_to_analyze[i:i + batch_size] batch_num = i // batch_size + 1 total_batches = (len(stocks_to_analyze) + batch_size - 1) // batch_size logger.info(f"Processing batch {batch_num}/{total_batches} (stocks {i+1}-{min(i+batch_size, len(stocks_to_analyze))})") # 배치 단위로 병렬 처리 tasks = [analyze_stock(code, name, criteria) for code, name in batch] batch_results = await asyncio.gather(*tasks, return_exceptions=True) all_results.extend(batch_results) # 다음 배치 전 대기 (마지막 배치는 제외) if i + batch_size < len(stocks_to_analyze): await asyncio.sleep(delay_between_batches) # 예외 로깅 errors = [r for r in all_results if isinstance(r, Exception)] if errors: logger.error(f"Encountered {len(errors)} errors during analysis") # None 및 예외 제거 후 점수순 정렬 recommendations = [r for r in all_results if r is not None and not isinstance(r, Exception)] recommendations.sort(key=lambda x: x.score, reverse=True) logger.info(f"Found {len(recommendations)} recommendations out of {len(stocks_to_analyze)} analyzed stocks") if not recommendations: logger.warning("No recommendations found! Criteria might be too strict or API failed") return { "total": len(recommendations), "analyzed_stocks": len(stocks_to_analyze), "criteria": criteria.dict(), "recommendations": recommendations[:20] # 상위 20개만 반환 } @router.get("/rule-based") async def get_rule_based_recommendations( realtime: bool = Query(False, description="실시간 분석 여부 (느림, 30초)"), db: AsyncSession = Depends(get_db) ): """ 매매 규칙 기반 자동 추천 - realtime=false (기본): DB에서 조회 (빠름, 0.1초) - 매일 아침 9시 데이터 - realtime=true: KIS API 실시간 분석 (느림, 30초) 활성화된 모든 매매 규칙을 확인하고, 조건에 맞는 종목을 자동으로 추천합니다. """ # 실시간 모드: 기존 방식 (KIS API 직접 호출) if realtime: logger.info("🔴 Real-time mode: Fetching live data from KIS API (slow)") return await get_rule_based_recommendations_realtime() # DB 모드: 빠른 조회 logger.info("🟢 DB mode: Fetching from cached analysis (fast)") return await get_rule_based_recommendations_from_db(db) async def get_rule_based_recommendations_from_db(db: AsyncSession): """DB에서 사전 분석된 데이터 조회 (빠름 - 0.1초)""" logger.info("Rule-based recommendations from DB") # 샘플 규칙 생성 (실제로는 DB에서 조회) from collections import namedtuple Rule = namedtuple('Rule', ['id', 'name', 'description', 'rule_type', 'priority', 'conditions', 'actions', 'is_active']) active_rules = [ Rule( id=1, name="RSI 과매도 반등", description="RSI 40 이하 과매도 종목", rule_type="BUY", priority=1, conditions={"rsi_threshold": 40}, actions={"take_profit": 5.0, "stop_loss": 3.0, "position_sizing": {"target_ratio": 0.15}}, is_active=True ), Rule( id=2, name="볼린저밴드 하단", description="볼린저밴드 하단 근처 반등", rule_type="BUY", priority=3, conditions={"bb_threshold": 0.2}, actions={"take_profit": 7.0, "stop_loss": 4.0, "position_sizing": {"target_ratio": 0.15}}, is_active=True ) ] logger.info(f"Found {len(active_rules)} active trading rules") # 오늘 날짜의 분석 데이터 조회 today = date.today() result = await db.execute( select(StockAnalysis) .where(StockAnalysis.analysis_date >= today) .order_by(StockAnalysis.score.desc()) ) all_analyzed_stocks = result.scalars().all() if not all_analyzed_stocks: logger.warning(f"No analysis data found for {today}. Run batch_daily_analysis.py first!") # ✅ 안전장치: DB에 데이터가 없으면 실시간 분석으로 자동 폴백 try: logger.info("Falling back to realtime analysis due to empty DB cache...") fallback = await get_rule_based_recommendations_realtime() # 메시지 보강 base_msg = fallback.get("message") or "" fallback["message"] = ( f"⚠️ 배치 캐시 없음 → 실시간 분석으로 대체 ({today}). " + base_msg ) return fallback except Exception as e: logger.error(f"Realtime fallback failed: {e}") return { "total": 0, "active_rules": len(active_rules), "analyzed_stocks": 0, "recommendations": [], "recommendations_by_rule": [], "rules_applied": [], "message": f"배치 분석 데이터가 없습니다 ({today}). 실시간 분석 폴백도 실패: {e}" } logger.info(f"Found {len(all_analyzed_stocks)} stocks in DB for {today}") # 규칙별로 매칭되는 종목 찾기 rule_matches = [] recommendations_by_rule = [] for rule in active_rules: logger.info(f"Applying rule: {rule.name}") conditions = rule.conditions actions = rule.actions # DB에서 조회한 종목 중 규칙에 맞는 종목 필터링 rule_matched_stocks = [] for stock in all_analyzed_stocks: matches = True # RSI 조건 체크 if "rsi_threshold" in conditions: rsi_threshold = conditions["rsi_threshold"] if not stock.rsi or stock.rsi > rsi_threshold: matches = False # 볼린저밴드 하단 체크 if "bb_threshold" in conditions: if not stock.bollinger_lower or not stock.current_price: matches = False elif stock.current_price > stock.bollinger_lower * 1.05: # 하단 5% 이내 matches = False if matches: rule_matched_stocks.append(stock) # 점수순 정렬 rule_matched_stocks.sort(key=lambda x: x.score, reverse=True) # 규칙별로 종목 저장 rule_stocks = [] for stock in rule_matched_stocks[:10]: # 규칙당 상위 10개 stock_dict = { "stock_code": stock.stock_code, "stock_name": stock.stock_name, "current_price": stock.current_price, "change_rate": float(stock.change_rate) if stock.change_rate else 0, "volume": stock.volume, "rsi": float(stock.rsi) if stock.rsi else None, "ma5": float(stock.ma5) if stock.ma5 else None, "ma20": float(stock.ma20) if stock.ma20 else None, "stochastic_k": float(stock.stochastic_k) if stock.stochastic_k else None, "signals": stock.signals or [], "score": stock.score, "price_date": stock.analysis_date.strftime("%Y%m%d"), "rule_id": rule.id, "rule_name": rule.name, "rule_description": rule.description, "rule_priority": rule.priority, "position_ratio": actions.get("position_sizing", {}).get("target_ratio", 0.15), "take_profit": actions.get("take_profit", 5.0), "stop_loss": actions.get("stop_loss", 3.0) } rule_stocks.append(stock_dict) rule_matches.append(stock_dict) # 규칙별 그룹 추가 if rule_stocks: recommendations_by_rule.append({ "rule_id": rule.id, "rule_name": rule.name, "rule_description": rule.description, "rule_type": rule.rule_type, "priority": rule.priority, "matched_count": len(rule_matched_stocks), "stocks": rule_stocks }) logger.info(f"Rule '{rule.name}' matched {len(rule_matched_stocks)} stocks, showing top {len(rule_stocks)}") # 우선순위와 점수로 정렬 rule_matches.sort(key=lambda x: (x["rule_priority"], -x["score"])) return { "total": len(rule_matches), "active_rules": len(active_rules), "analyzed_stocks": len(all_analyzed_stocks), "recommendations": rule_matches[:20], "recommendations_by_rule": recommendations_by_rule, "rules_applied": [{"id": r.id, "name": r.name, "priority": r.priority} for r in active_rules], "message": f"✅ DB 캐시 데이터 사용 ({today} 기준)" } async def get_rule_based_recommendations_realtime(): """ 실시간 KIS API 분석 (느림 - 30초) 기존 방식 유지 """ # TODO: 데이터베이스 연결 후 실제 규칙 조회 # 현재는 샘플 규칙으로 동작 logger.info("Rule-based recommendations requested (realtime mode)") # 샘플 규칙 생성 (실제로는 DB에서 조회) from collections import namedtuple Rule = namedtuple('Rule', ['id', 'name', 'description', 'rule_type', 'priority', 'conditions', 'actions', 'is_active']) active_rules = [ Rule( id=1, name="RSI 과매도 반등", description="RSI 40 이하 과매도 종목", rule_type="BUY", priority=1, conditions={"rsi_threshold": 40}, actions={"take_profit": 5.0, "stop_loss": 3.0, "position_sizing": {"target_ratio": 0.15}}, is_active=True ), Rule( id=2, name="골든크로스 추세", description="5일선이 20일선 돌파", rule_type="BUY", priority=2, conditions={"sma_short": 5, "sma_long": 20}, actions={"take_profit": 10.0, "stop_loss": 5.0, "position_sizing": {"target_ratio": 0.2}}, is_active=True ), Rule( id=3, name="볼린저밴드 하단", description="볼린저밴드 하단 근처 반등", rule_type="BUY", priority=3, conditions={"bb_threshold": 0.2}, actions={"take_profit": 7.0, "stop_loss": 4.0, "position_sizing": {"target_ratio": 0.15}}, is_active=True ), ] # active_rules가 비어있으면 기본 지표로 분석 if not active_rules: logger.info("No rules in database, using default indicators") # 규칙이 없으면 기본 RSI + 이동평균으로 분석 if not active_rules: logger.info("Using default criteria: RSI + Moving Average") criteria = RecommendationCriteria( use_rsi=True, use_moving_average=True ) # 분석할 종목 리스트 all_stocks = await get_all_tradable_stocks() stocks_to_analyze = all_stocks[:50] # 50개 종목만 # 배치 처리 batch_size = 5 delay_between_batches = 1.2 batch_results = [] for i in range(0, len(stocks_to_analyze), batch_size): batch = stocks_to_analyze[i:i + batch_size] tasks = [analyze_stock(code, name, criteria) for code, name in batch] results = await asyncio.gather(*tasks, return_exceptions=True) batch_results.extend(results) if i + batch_size < len(stocks_to_analyze): await asyncio.sleep(delay_between_batches) # 유효한 추천만 필터링 valid_recommendations = [ r for r in batch_results if r is not None and not isinstance(r, Exception) ] valid_recommendations.sort(key=lambda x: x.score, reverse=True) # 기본 규칙으로 그룹핑 default_stocks = [] for rec in valid_recommendations[:10]: stock_dict = { "stock_code": rec.stock_code, "stock_name": rec.stock_name, "current_price": rec.current_price, "change_rate": rec.change_rate, "signals": rec.signals, "score": rec.score, "price_date": rec.price_date, "rule_id": 0, "rule_name": "기본 추천", "rule_description": "RSI + 이동평균 기반 추천", "rule_priority": 1, "position_ratio": 0.15, "take_profit": 5.0, "stop_loss": 3.0 } default_stocks.append(stock_dict) return { "total": len(default_stocks), "active_rules": 0, "analyzed_stocks": len(stocks_to_analyze), "recommendations": default_stocks, "recommendations_by_rule": [{ "rule_id": 0, "rule_name": "기본 추천 (RSI + 이동평균)", "rule_description": "RSI 40 이하 과매도 + 이동평균 정배열 종목", "rule_type": "BUY", "priority": 1, "matched_count": len(valid_recommendations), "stocks": default_stocks }] if default_stocks else [], "rules_applied": [], "message": "기본 지표로 분석되었습니다 (규칙 미설정)" } logger.info(f"Found {len(active_rules)} active trading rules") # 분석할 종목 리스트 (타임아웃 방지를 위해 20개로 제한) all_stocks = await get_all_tradable_stocks() stocks_to_analyze = all_stocks[:20] # 상위 20개 종목으로 제한 # ✅ 최적화: 모든 종목을 한 번만 분석 (모든 지표 포함) logger.info(f"Analyzing {len(stocks_to_analyze)} stocks once with all indicators...") criteria = RecommendationCriteria( use_rsi=True, use_moving_average=True, use_macd=True, use_bollinger=True, use_stochastic=True ) # 배치 처리로 종목 분석 (Rate Limiting) batch_size = 1 # KIS API rate limit: 초당 1건 delay_between_batches = 1.5 # 배치 간 1.5초 대기 all_analyzed_stocks = [] for i in range(0, len(stocks_to_analyze), batch_size): batch = stocks_to_analyze[i:i + batch_size] tasks = [analyze_stock(code, name, criteria) for code, name in batch] results = await asyncio.gather(*tasks, return_exceptions=True) all_analyzed_stocks.extend(results) # 다음 배치 전 대기 (Rate Limiting) if i + batch_size < len(stocks_to_analyze): await asyncio.sleep(delay_between_batches) # 유효한 분석 결과만 필터링 valid_analyzed_stocks = [ r for r in all_analyzed_stocks if r is not None and not isinstance(r, Exception) ] logger.info(f"Successfully analyzed {len(valid_analyzed_stocks)} stocks") # 규칙별로 매칭되는 종목 찾기 (이미 분석된 데이터 재사용) rule_matches = [] recommendations_by_rule = [] for rule in active_rules: logger.info(f"Applying rule: {rule.name}") # 규칙 조건 파싱 conditions = rule.conditions actions = rule.actions # 이미 분석된 종목들 중에서 규칙에 맞는 종목 필터링 rule_matched_stocks = [] for stock in valid_analyzed_stocks: # 규칙 조건 체크 matches = True # RSI 조건 체크 if "rsi_threshold" in conditions: rsi_threshold = conditions["rsi_threshold"] if not any(f"RSI" in signal for signal in stock.signals): matches = False if matches: rule_matched_stocks.append(stock) # 점수순 정렬 rule_matched_stocks.sort(key=lambda x: x.score, reverse=True) # 규칙별로 종목 저장 rule_stocks = [] for rec in rule_matched_stocks[:10]: # 규칙당 상위 10개 stock_with_rule = { **rec.model_dump(), "rule_id": rule.id, "rule_name": rule.name, "rule_description": rule.description, "rule_priority": rule.priority, "position_ratio": actions.get("position_sizing", {}).get("target_ratio", 0.15), "take_profit": actions.get("take_profit", 5.0), "stop_loss": actions.get("stop_loss", 3.0) } rule_stocks.append(stock_with_rule) rule_matches.append(stock_with_rule) # 규칙별 그룹 추가 if rule_stocks: recommendations_by_rule.append({ "rule_id": rule.id, "rule_name": rule.name, "rule_description": rule.description, "rule_type": rule.rule_type, "priority": rule.priority, "matched_count": len(rule_matched_stocks), "stocks": rule_stocks }) logger.info(f"Rule '{rule.name}' matched {len(rule_matched_stocks)} stocks, showing top {len(rule_stocks)}") # 우선순위와 점수로 정렬 rule_matches.sort(key=lambda x: (x["rule_priority"], -x["score"])) # 실시간 현재가 업데이트 (상위 20개만) # 토큰 재요청을 줄이기 위해 공유 인스턴스를 사용합니다. from app.services.korea_investment_api import korea_investment_api as kis_api top_recommendations = rule_matches[:20] for rec in top_recommendations: try: await asyncio.sleep(0.2) # Rate limit 고려 price_data = await kis_api.get_current_price(rec["stock_code"]) if price_data and price_data.get("output"): output = price_data["output"] rec["current_price"] = int(output.get("stck_prpr", rec["current_price"])) rec["change_rate"] = float(output.get("prdy_ctrt", rec.get("change_rate", 0))) logger.info(f"Updated real-time price for {rec['stock_code']}: {rec['current_price']}원") except Exception as e: logger.warning(f"Failed to get real-time price for {rec['stock_code']}: {e}") # recommendations_by_rule의 종목들도 실시간 가격 업데이트 for rule_group in recommendations_by_rule: for stock in rule_group["stocks"]: # top_recommendations에서 업데이트된 가격 찾기 updated = next((r for r in top_recommendations if r["stock_code"] == stock["stock_code"]), None) if updated: stock["current_price"] = updated["current_price"] stock["change_rate"] = updated["change_rate"] return { "total": len(rule_matches), "active_rules": len(active_rules), "analyzed_stocks": len(stocks_to_analyze), "recommendations": top_recommendations, # 전체 통합 리스트 (실시간 가격 포함) "recommendations_by_rule": recommendations_by_rule, # 규칙별 그룹핑 (실시간 가격 포함) "rules_applied": [{"id": r.id, "name": r.name, "priority": r.priority} for r in active_rules] } # 자동 매매 실행 함수 async def execute_auto_trade(stock: dict, db: AsyncSession): """ 추천 종목에 대해 자동 매매 실행 - 매수 신호: 정배열, RSI < 30 등 - 매도 신호: 역배열, RSI > 70, 손익률 기준 """ try: stock_code = stock.get("stock_code") stock_name = stock.get("stock_name") current_price = float(stock.get("current_price", 0)) signals = stock.get("signals", []) if not stock_code or current_price <= 0: logger.debug(f"Invalid stock data for auto trade: {stock}") return {"action": "SKIP", "reason": "invalid_data"} # 이미 보유 중인지 확인 result = await db.execute( select(Portfolio).where( Portfolio.stock_code == stock_code, Portfolio.quantity > 0 ) ) holding = result.scalar_one_or_none() # 신호 문자열 기반 매수/매도 판단 (실제 분석 함수의 문구와 일치시킴) text_signals = " ".join(signals) # 점수 숫자형 보정 try: score_val = float(stock.get("score", 0) or 0) except Exception: score_val = 0.0 buy_keywords = [ "정배열 상승 매수타점", "눌림목", # 눌림목 진입 매수타점 "RSI 과매도", "RSI 중립-상승", "스토캐스틱 과매도", "5일선 > 20일선", "20일선 > 60일선", "골든크로스", "볼린저 하한선 터치", "ADX 강한 추세", "거래량 증가와 상승", ] should_buy = any(k in text_signals for k in buy_keywords) sell_keywords = [ "역배열 하락 매도타점", "RSI 과매수", "데드크로스", "이동평균 역배열", "볼린저 상한선 돌파", "손절라인 이탈", ] should_sell = any(k in text_signals for k in sell_keywords) # 매수 로직 (신호 또는 점수 기반) # 조건 완화: 점수 기준 1점 이상으로 매수 허용 (신호가 약해도 소액 진입) if (should_buy or (score_val >= 1)) and not holding: # 투자 가능 금액의 10% 매수 (예수금 1000만원 기준 100만원) available_cash = 10000000 # 예수금 (실제로는 API에서 조회해야 함) invest_amount = available_cash * 0.10 # 10% 투자 quantity = int(invest_amount / current_price) if quantity > 0: logger.info(f"🟢 매수 신호 감지: {stock_name}({stock_code}) - {signals}") # KIS API로 매수 주문 try: order_result = await korea_investment_api.place_order( stock_code=stock_code, order_type="BUY", quantity=quantity, price=None # 시장가 주문 ) # 거래 이력 저장 trade = TradeHistory( rule_id=stock.get("rule_id"), stock_code=stock_code, stock_name=stock_name, order_type="BUY", order_price=Decimal(str(current_price)), order_quantity=quantity, executed_price=Decimal(str(current_price)), executed_quantity=quantity, status="EXECUTED" if order_result.get("rt_cd") == "0" else "FAILED", order_id=order_result.get("odno"), message=f"자동매수: {' | '.join(signals[:2])}", created_at=datetime.now() ) db.add(trade) await db.commit() success = order_result.get("rt_cd") == "0" logger.info(f"✅ 매수 주문 완료: {stock_name} {quantity}주 @ {current_price}원 (success={success})") # Optimistic local 포트폴리오 업데이트 if success: if holding: # 가중 평균 재계산 new_total_qty = holding.quantity + quantity prev_cost = float(holding.average_price or 0) * holding.quantity new_cost = prev_cost + current_price * quantity holding.quantity = new_total_qty holding.average_price = Decimal(str(new_cost / new_total_qty)) holding.current_price = Decimal(str(current_price)) else: new_holding = Portfolio( stock_code=stock_code, stock_name=stock_name, quantity=quantity, average_price=Decimal(str(current_price)), current_price=Decimal(str(current_price)), profit_loss=Decimal("0"), profit_loss_rate=Decimal("0") ) db.add(new_holding) await db.commit() try: from app.api.endpoints.portfolio import portfolio_cache portfolio_cache.mark_changed() except Exception: logger.debug("Portfolio cache not available for mark_changed") return {"action": "BUY", "stock": stock_name, "quantity": quantity, "price": current_price, "success": success} except Exception as e: logger.error(f"❌ 매수 주문 실패: {stock_name} - {e}") return {"action": "BUY_FAILED", "stock": stock_name, "error": str(e)} # 매도 로직 elif should_sell and holding: quantity = holding.quantity profit_loss_rate = float(holding.profit_loss_rate or 0) # 익절/손절 조건 확인 take_profit = stock.get("take_profit", 5.0) # 5% 익절 stop_loss = stock.get("stop_loss", -3.0) # 3% 손절 should_execute_sell = ( profit_loss_rate >= take_profit or # 익절 profit_loss_rate <= stop_loss or # 손절 should_sell # 신호 기반 매도 ) if should_execute_sell: logger.info(f"🔴 매도 신호 감지: {stock_name}({stock_code}) - 손익률: {profit_loss_rate:.2f}% - {signals}") try: order_result = await korea_investment_api.place_order( stock_code=stock_code, order_type="SELL", quantity=quantity, price=None # 시장가 주문 ) # 거래 이력 저장 trade = TradeHistory( rule_id=stock.get("rule_id"), stock_code=stock_code, stock_name=stock_name, order_type="SELL", order_price=Decimal(str(current_price)), order_quantity=quantity, executed_price=Decimal(str(current_price)), executed_quantity=quantity, status="EXECUTED" if order_result.get("rt_cd") == "0" else "FAILED", order_id=order_result.get("odno"), message=f"자동매도: 손익률 {profit_loss_rate:.2f}% | {' | '.join(signals[:2])}", created_at=datetime.now() ) db.add(trade) await db.commit() success = order_result.get("rt_cd") == "0" logger.info(f"✅ 매도 주문 완료: {stock_name} {quantity}주 @ {current_price}원 (success={success})") if success: # 포트폴리오에서 수량 0 처리 holding.current_price = Decimal(str(current_price)) holding.quantity = 0 # 손익 계산 (단순: (현재가 - 평균단가)*원래수량) if holding.average_price: raw_pl = (current_price - float(holding.average_price)) * quantity holding.profit_loss = Decimal(str(raw_pl)) rate = (raw_pl / (float(holding.average_price) * quantity) * 100) if holding.average_price and quantity > 0 else 0 holding.profit_loss_rate = Decimal(str(rate)) await db.commit() try: from app.api.endpoints.portfolio import portfolio_cache portfolio_cache.mark_changed() except Exception: logger.debug("Portfolio cache not available for mark_changed") return {"action": "SELL", "stock": stock_name, "quantity": quantity, "price": current_price, "profit_loss": profit_loss_rate, "success": success} except Exception as e: logger.error(f"❌ 매도 주문 실패: {stock_name} - {e}") return {"action": "SELL_FAILED", "stock": stock_name, "error": str(e)} # 아무 것도 실행 안됨 logger.debug( f"HOLD decision for {stock_code}: buy={should_buy}, sell={should_sell}, holding={bool(holding)}, score={score_val}, signals={signals}" ) return {"action": "HOLD", "stock": stock_name} except Exception as e: logger.error(f"❌ 자동 매매 오류: {e}") return {"action": "ERROR", "error": str(e)} @router.post("/auto-trade") async def execute_auto_trading(db: AsyncSession = Depends(get_db)): """ 규칙 기반 추천 종목에 대해 자동 매매 실행 """ try: # 규칙 기반 추천 조회 (실시간 기준으로 매매 판단 일치화) # DB 캐시의 신호 문자열 불일치로 매수가 안 걸리는 문제를 방지하기 위해 # 실시간 분석 결과를 우선 사용합니다. recommendations_response = await get_rule_based_recommendations(realtime=True) if not recommendations_response.get("recommendations_by_rule"): return {"message": "추천 종목이 없습니다", "trades": []} # 각 규칙별 추천 종목에 대해 자동 매매 실행 trade_results = [] total_processed = 0 max_trades = 5 # 한 번에 최대 5개만 처리 for rule_group in recommendations_response["recommendations_by_rule"]: if total_processed >= max_trades: break for stock in rule_group["stocks"][:2]: # 규칙당 상위 2개만 if total_processed >= max_trades: break try: result = await execute_auto_trade(stock, db) total_processed += 1 if result["action"] in ["BUY", "SELL"]: trade_results.append(result) # Rate Limiting: KIS API 호출 간 0.5초 대기 await asyncio.sleep(0.5) except Exception as e: logger.error(f"Error executing auto-trade for {stock.get('stock_code')}: {e}") continue return { "message": f"자동 매매 완료: {len(trade_results)}건 실행 / {total_processed}개 분석", "trades": trade_results, "total_analyzed": total_processed } except Exception as e: logger.error(f"❌ 자동 매매 실행 오류: {e}") raise HTTPException(status_code=500, detail=str(e))