Files
auto-stock/backend/app/api/endpoints/recommendations.py
2026-02-23 08:55:11 +09:00

2645 lines
118 KiB
Python

from fastapi import APIRouter, HTTPException, Query, Depends
from typing import List, Optional
from pydantic import BaseModel
from datetime import datetime, date, timedelta
from decimal import Decimal
import asyncio
import logging
import httpx
import pytz
from app.services.korea_investment_api import korea_investment_api
from app.core.database import get_db, AsyncSessionLocal
from app.models.models import TradeHistory, Portfolio, StockAnalysis
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, desc
from app.core.config import settings
from app.core.market_schedule import is_market_open
from app.services.mean_reversion_strategy import (
MarketRegime, detect_market_regime, check_buy_signal,
calculate_atr, calculate_adx, calculate_rsi, calculate_bollinger, is_reversal_candle,
calculate_position_size_v2,
)
from app.services.mdd_manager import mdd_manager
from app.services.loss_tracker import loss_tracker
router = APIRouter()
logger = logging.getLogger(__name__)
# 한국 시간대
KST = pytz.timezone('Asia/Seoul')
def _get_kst_now():
"""현재 한국 시간 반환 (timezone-naive datetime)"""
return datetime.now(KST).replace(tzinfo=None)
# Trend filter defaults (configurable via settings)
TREND_FILTER_ENABLED = getattr(settings, "TREND_FILTER_ENABLED", True)
TREND_MA_SHORT = int(getattr(settings, "TREND_MA_SHORT", 20))
TREND_MA_LONG = int(getattr(settings, "TREND_MA_LONG", 60))
TREND_TOLERANCE_PCT = float(getattr(settings, "TREND_TOLERANCE_PCT", 0.005))
# 급등 과열 필터 defaults
OVERHEAT_FILTER_ENABLED = getattr(settings, "OVERHEAT_FILTER_ENABLED", True)
OVERHEAT_3DAY_MAX = float(getattr(settings, "OVERHEAT_3DAY_MAX", 15.0))
OVERHEAT_5DAY_MAX = float(getattr(settings, "OVERHEAT_5DAY_MAX", 20.0))
OVERHEAT_10DAY_MAX = float(getattr(settings, "OVERHEAT_10DAY_MAX", 30.0))
OVERHEAT_DISPARITY_MAX = float(getattr(settings, "OVERHEAT_DISPARITY_MAX", 108.0))
def _ma(values: list, window: int) -> Optional[float]:
try:
if len(values) < window:
return None
return sum(values[-window:]) / window
except Exception:
return None
def _passes_uptrend(closes: list, ma_short: int = TREND_MA_SHORT, ma_long: int = TREND_MA_LONG, tol: float = TREND_TOLERANCE_PCT) -> Optional[bool]:
if not closes or len(closes) < max(ma_short, ma_long):
return None
ma_s = _ma(closes, ma_short)
ma_l = _ma(closes, ma_long)
close = closes[-1]
if ma_s is None or ma_l is None:
return None
# 허용 오차 적용: 거의 골든크로스/이탈 직후도 허용
return (ma_s >= ma_l * (1 - tol)) and (close >= ma_s * (1 - tol))
def analyze_overheat(closes: list, ma20: Optional[float]) -> dict:
"""급등 과열 필터 - 단기 급등 종목 매수 차단
3일/5일/10일 상승률과 20일선 이격도를 종합 판단하여
고점 추격 매수를 원천 차단합니다.
Returns:
{"blocked": bool, "reason": str, "details": dict}
"""
details = {}
reasons = []
if len(closes) >= 4:
gain_3d = ((closes[-1] - closes[-4]) / closes[-4]) * 100 if closes[-4] > 0 else 0
details["gain_3d"] = round(gain_3d, 1)
if gain_3d > OVERHEAT_3DAY_MAX:
reasons.append(f"3일 +{gain_3d:.1f}% (>{OVERHEAT_3DAY_MAX}%)")
if len(closes) >= 6:
gain_5d = ((closes[-1] - closes[-6]) / closes[-6]) * 100 if closes[-6] > 0 else 0
details["gain_5d"] = round(gain_5d, 1)
if gain_5d > OVERHEAT_5DAY_MAX:
reasons.append(f"5일 +{gain_5d:.1f}% (>{OVERHEAT_5DAY_MAX}%)")
if len(closes) >= 11:
gain_10d = ((closes[-1] - closes[-11]) / closes[-11]) * 100 if closes[-11] > 0 else 0
details["gain_10d"] = round(gain_10d, 1)
if gain_10d > OVERHEAT_10DAY_MAX:
reasons.append(f"10일 +{gain_10d:.1f}% (>{OVERHEAT_10DAY_MAX}%)")
if ma20 and ma20 > 0:
disparity = (closes[-1] / ma20) * 100
details["disparity_20"] = round(disparity, 1)
if disparity > OVERHEAT_DISPARITY_MAX:
reasons.append(f"이격도 {disparity:.1f}% (>{OVERHEAT_DISPARITY_MAX}%)")
blocked = len(reasons) > 0
reason = "급등과열: " + ", ".join(reasons) if blocked else ""
return {"blocked": blocked, "reason": reason, "details": details}
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
use_volatility_breakout: bool = False # 변동성 돌파 전략
use_overheat_filter: bool = True # 급등 과열 필터 (고점 추격 방지)
min_volume_ratio: float = 1.0 # 평균 대비 거래량 배수 (1.0 = 필터 없음)
max_disparity: float = 200.0 # 최대 이격도 (20일선 대비 %) - 완화
for_batch: bool = False # 배치 분석 모드: 필터 완화, score=0도 반환
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
# ML 앙상블 스코어
ml_score: Optional[int] = None # 앙상블 스코어 (0~100)
ml_confidence: Optional[float] = None # 신뢰도 (0~1)
ml_signal: Optional[str] = None # BUY/HOLD/SELL
# ATR (배치 저장용 — 이중 API 호출 방지)
atr_14: Optional[float] = None
# ADX (추세 강도 — 평균회귀 필터용)
adx_14: 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]
# 🔥 이격도 계산 (추격 매수 방지) - 더 엄격하게
disparity = (close / ma20) * 100
# 🚫 급등 종목 필터: 최근 3일 상승률 체크
if len(closes) >= 4:
price_3days_ago = closes[-4]
recent_gain = ((close - price_3days_ago) / price_3days_ago) * 100 if price_3days_ago > 0 else 0
if recent_gain > 10: # 3일간 10% 이상 급등 시 매수 안함
return {"signal": None, "description": f"급등 종목 추격 금지 (3일 +{recent_gain:.1f}%)"}
# 정배열 매수 - 이격도 조건 강화 (105% -> 102%)
if ma20 > ma60 > ma120 and close > ma20:
if disparity <= 102: # 더 엄격하게
return {"signal": "buy", "description": "정배열 눌림목 매수 (이격도 안정)"}
else:
return {"signal": None, "description": f"정배열이나 이격 과열 ({disparity:.1f}%)"}
# 역배열 매도
elif ma20 < ma60 < ma120 and close < ma20:
return {"signal": "sell", "description": "역배열 하락 매도타점"}
# 눌림목 진입 - 이격도 조건 강화 (103% -> 101%)
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:
if disparity <= 101: # 더 엄격하게
return {"signal": "buy", "description": "눌림목 진입 매수타점"}
else:
return {"signal": None, "description": f"눌림목이나 이격 과열 ({disparity:.1f}%)"}
# 손절/익절
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) < 15:
return {"signal": None, "description": "데이터 부족"}
# 현재 RSI 계산
prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-15:]]
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))
# 이전 RSI 계산 (추세 확인용)
if len(chart_data) >= 16:
prev_prices = [float(item.get("stck_clpr", 0)) for item in chart_data[-16:-1]]
prev_gains = []
prev_losses = []
for i in range(1, len(prev_prices)):
change = prev_prices[i] - prev_prices[i-1]
if change > 0:
prev_gains.append(change)
prev_losses.append(0)
else:
prev_gains.append(0)
prev_losses.append(abs(change))
prev_avg_gain = sum(prev_gains[-14:]) / 14
prev_avg_loss = sum(prev_losses[-14:]) / 14
if prev_avg_loss == 0:
rsi_prev = 100
else:
prev_rs = prev_avg_gain / prev_avg_loss
rsi_prev = 100 - (100 / (1 + prev_rs))
else:
rsi_prev = rsi
# 🔥 Falling Knife 방지: 바닥 탈출 확인
if rsi < 30:
# 30 미만은 관망 (하락세가 강함 - 떨어지는 칼날)
return {"signal": None, "description": f"RSI 과매도 심화 ({rsi:.1f}) - 반등 대기"}
elif 30 <= rsi <= 45 and rsi > rsi_prev:
# 바닥을 찍고 올라오는 구간 + 상승 중
return {"signal": "buy", "description": f"RSI 바닥 탈출 반등 ({rsi:.1f})"}
elif rsi > 70:
return {"signal": "sell", "description": f"RSI 과매수 ({rsi:.1f})"}
elif 45 <= rsi <= 60:
# 중립 상승 구간
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_volatility_breakout(chart_data: list, k: float = 0.5) -> dict:
"""🔥 변동성 돌파 전략 (래리 윌리엄스) - 승률이 매우 높은 단타 전략
공식: 현재가 > 시가 + (전일 고가 - 전일 저가) * K
"""
if len(chart_data) < 2:
return {"signal": None, "description": "데이터 부족"}
today = chart_data[-1] # 최신 데이터 (시간순 정렬 기준 마지막)
yesterday = chart_data[-2] # 전일 데이터
# 오늘 데이터
current_price = float(today.get("stck_clpr", 0)) # 현재가
open_price = float(today.get("stck_oprc", 0)) # 시가
# 전일 데이터
prev_high = float(yesterday.get("stck_hgpr", 0))
prev_low = float(yesterday.get("stck_lwpr", 0))
prev_range = prev_high - prev_low
if prev_range == 0 or open_price == 0:
return {"signal": None, "description": "데이터 부족"}
# 목표 돌파가 = 시가 + (전일 변동폭 * K)
target_price = open_price + (prev_range * k)
if current_price >= target_price:
return {"signal": "buy", "description": f"변동성 돌파 발생 (목표가: {target_price:.0f})"}
return {"signal": None, "description": "변동성 돌파 미달"}
async def check_market_regime() -> dict:
"""시장 레짐 판별 (CLAUDE.md 섹션 7.1: 200일 MA + 기울기)
상승장(Bull): 코스피 > MA200 AND MA200 기울기 양(+)
하락장(Bear): 그 외
하위 분류 (기존 호환):
- strong_bearish: 코스피 < MA60 또는 5일 연속 하락
- bearish: 코스피 < MA20
- bullish: 코스피 > MA200 + 기울기 상승
"""
try:
# 장외 시간에는 모의투자 API가 차트 데이터를 반환하지 않음 → neutral 반환
if not is_market_open():
return {"regime": "neutral", "regime_200d": "bear", "message": "장외 시간 - 시장 레짐 판별 스킵", "position_multiplier": 0.5}
# KODEX 200 (069500) = KOSPI 200 추종 ETF 사용 (지수코드 0001은 daily-price API 미지원)
kospi_chart = await korea_investment_api.get_daily_chart("069500", "D", limit=240)
if "output2" not in kospi_chart or not kospi_chart["output2"]:
logger.warning("KOSPI 지수 데이터 없음 - 시장 필터 비활성화")
return {"regime": "neutral", "message": "시장 지수 확인 불가", "position_multiplier": 0.5}
chart_data = kospi_chart["output2"]
closes = [float(item.get("stck_clpr", 0)) for item in chart_data]
if len(closes) < 60:
return {"regime": "neutral", "message": "데이터 부족", "position_multiplier": 0.5}
current_price = closes[0]
ma20 = sum(closes[:20]) / 20
ma60 = sum(closes[:60]) / 60
# 200일 MA 기반 레짐 판별 (섹션 7.1)
if len(closes) >= 205:
regime_enum = detect_market_regime(list(reversed(closes)))
is_bull_200 = regime_enum == MarketRegime.BULL
ma200 = sum(closes[:200]) / 200
else:
is_bull_200 = current_price > ma20 # 200일 데이터 부족 시 MA20 폴백
ma200 = ma60
# 5일 연속 하락 체크
consecutive_down = 0
for i in range(len(closes) - 1):
if closes[i] < closes[i + 1]:
consecutive_down += 1
else:
break
drop_from_ma20 = ((current_price - ma20) / ma20) * 100
# 강한 하락장: 코스피 < MA60 또는 5일 연속 하락
if current_price < ma60 or consecutive_down >= 5:
return {
"regime": "strong_bearish",
"regime_200d": "bear",
"message": f"강한 하락장 (코스피: {current_price:.0f}, MA200: {ma200:.0f}, {consecutive_down}일 연속하락)",
"position_multiplier": 0.1,
"min_score": settings.BEAR_MARKET_MIN_SCORE + 3,
"max_trades": 1,
"consecutive_down": consecutive_down,
"drop_from_ma20": round(drop_from_ma20, 1),
}
# 약세장: 코스피 < MA20
if current_price < ma20:
return {
"regime": "bearish",
"regime_200d": "bear",
"message": f"하락장 (코스피: {current_price:.0f} < MA20: {ma20:.0f}, MA200: {ma200:.0f})",
"position_multiplier": settings.BEAR_MARKET_POSITION_RATIO,
"min_score": settings.BEAR_MARKET_MIN_SCORE,
"max_trades": settings.BEAR_MARKET_MAX_TRADES,
"consecutive_down": consecutive_down,
"drop_from_ma20": round(drop_from_ma20, 1),
}
# 상승장: 200일 MA 기반
return {
"regime": "bullish",
"regime_200d": "bull" if is_bull_200 else "bear",
"message": f"상승장 (코스피: {current_price:.0f}, MA200: {ma200:.0f}, Bull={is_bull_200})",
"position_multiplier": 1.0,
"min_score": 1,
"max_trades": settings.MAX_DAILY_TRADES,
"consecutive_down": consecutive_down,
"drop_from_ma20": round(drop_from_ma20, 1),
}
except Exception as e:
logger.error(f"시장 지수 확인 실패: {e}")
return {"regime": "neutral", "message": "시장 지수 확인 실패", "position_multiplier": 0.5}
def analyze_adx(chart_data: list) -> dict:
"""ADX 분석 — Wilder's ADX(14) 기반 추세 강도 측정
평균회귀 전략에서의 해석:
- ADX < 20: 횡보장 → 평균회귀 유리 (BUY 신호)
- ADX 20~30: 중간 추세 → 중립
- ADX > 30: 강한 추세 → 평균회귀 위험 (SELL/SKIP 신호)
chart_data는 시간 순(오래된 → 최신) 정렬을 가정.
"""
if len(chart_data) < 29: # period * 2 + 1
return {"signal": None, "description": "데이터 부족", "adx": None}
highs = [float(item.get("stck_hgpr", 0)) for item in chart_data]
lows = [float(item.get("stck_lwpr", 0)) for item in chart_data]
closes = [float(item.get("stck_clpr", 0)) for item in chart_data]
adx = calculate_adx(highs, lows, closes)
if adx is None:
return {"signal": None, "description": "ADX 계산 불가", "adx": None}
if adx < 20:
return {"signal": "buy", "description": f"ADX({adx:.0f}<20) 횡보장 — 평균회귀 유리", "adx": adx}
if adx > 30:
return {"signal": "sell", "description": f"ADX({adx:.0f}>30) 강추세 — 평균회귀 위험", "adx": adx}
return {"signal": None, "description": f"ADX({adx:.0f}) 중립", "adx": 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 "output2" not in chart_response or not chart_response["output2"]:
logger.warning(f"No chart data for {stock_code}")
return None
# KIS API는 최신순(newest-first)으로 반환 → 시간순(chronological)으로 뒤집어 분석
chart_data = list(reversed(chart_response["output2"]))
logger.info(f"Got {len(chart_data)} chart data points for {stock_code}")
# 최신 데이터에서 현재가 및 종목명 추출 (시간순 기준 마지막 = 최신)
latest = chart_data[-1] 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[-2].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]
volumes = [int(item.get("acml_vol", 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
# 🔥 1. 거래량 분석 (Volume Analysis) - 필수 조건
avg_vol_20 = sum(volumes[-20:]) / 20 if len(volumes) >= 20 else 0
volume_ratio = (volume / avg_vol_20) if avg_vol_20 > 0 else 0
# 거래량이 너무 적으면 추천 제외 (유동성 부족) - 배치 모드에서는 제외 안 함
if not criteria.for_batch and avg_vol_20 < 50000 and current_price < 50000:
logger.debug(f"{stock_code}: 거래량 부족으로 제외 (평균 거래량: {avg_vol_20:.0f})")
return None
# 🔥 2. 이격도 계산 (Disparity Filter) - 추격 매수 방지
disparity = (current_price / ma20) * 100 if ma20 else 100
# Trend filter check (non-blocking here; used for scoring/tagging)
trend_ok = None
if TREND_FILTER_ENABLED:
trend_ok = _passes_uptrend(closes, TREND_MA_SHORT, TREND_MA_LONG)
# 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
# 🔥 3. 이격도 과열 체크 (max_disparity 조건)
if disparity > criteria.max_disparity:
score -= 5
signals.append(f"이격도 과열({disparity:.1f}% > {criteria.max_disparity}%) - 매수금지")
logger.debug(f"{stock_code}: 이격도 과열로 점수 감점")
# 🔥 3.5. 급등 과열 필터 (고점 추격 매수 차단)
if criteria.use_overheat_filter and OVERHEAT_FILTER_ENABLED:
overheat = analyze_overheat(closes, ma20)
if overheat["blocked"]:
score -= 10
signals.append(overheat["reason"])
logger.info(f"{stock_code}: 급등 과열 차단 - {overheat['reason']} {overheat['details']}")
# 🔥 4. 변동성 돌파 전략 (고승률)
if criteria.use_volatility_breakout:
vb_result = analyze_volatility_breakout(chart_data)
if vb_result and vb_result["signal"] == "buy":
signals.append(vb_result["description"])
score += 4 # 매우 강력한 신호
# 커스텀 공식 기반 신호 분석 적용 (정배열, 역배열, 눌림목, 손절/익절)
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":
# 🔥 거래량이 동반되어야 진짜 반등
if volume_ratio >= 1.2:
signals.append(result["description"] + " + 거래량증가")
score += 3
else:
signals.append(result["description"] + " (거래량부족)")
score += 1 # 점수 낮게 책정
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
# 🔥 5. 거래량 급증 보너스 (매수 신호 강도 증가)
if volume_ratio >= 1.5 and change_rate > 0:
signals.append(f"거래량 실린 상승 ({volume_ratio:.1f}배)")
score += 3
elif volume_ratio < 0.8:
# 거래량이 평소보다 적으면 신뢰도 감소
score -= 1
logger.debug(f"{stock_code}: 거래량 부족 (평소의 {volume_ratio:.1f}배)")
# Tag trend state to signals and adjust score slightly
if TREND_FILTER_ENABLED and trend_ok is not None:
if trend_ok:
signals.append(f"추세상승(>{TREND_MA_SHORT}MA & {TREND_MA_SHORT}MA>{TREND_MA_LONG}MA, tol={int(TREND_TOLERANCE_PCT*10000)/100:.2f}%)")
score += 1
else:
signals.append(f"추세약함({TREND_MA_SHORT}MA≤{TREND_MA_LONG}MA 또는 종가≤{TREND_MA_SHORT}MA)")
# 🔥 6. 최종 거래량 필터 (min_volume_ratio)
if criteria.min_volume_ratio > 0 and volume_ratio < criteria.min_volume_ratio:
logger.debug(f"{stock_code}: 거래량 필터 미달 ({volume_ratio:.2f} < {criteria.min_volume_ratio})")
return None
logger.info(f"{stock_code} ({stock_name}): score={score}, signals={signals}, volume_ratio={volume_ratio:.2f}, disparity={disparity:.1f}%")
# 🤖 ML 앙상블 스코어 계산
ml_score_val = None
ml_confidence_val = None
ml_signal_val = None
if settings.ML_ENSEMBLE_ENABLED:
try:
from app.services.ml_ensemble import get_ensemble
ensemble = get_ensemble()
if ensemble.is_trained:
ml_result = ensemble.predict(chart_data)
if "error" not in ml_result:
ml_score_val = ml_result["ensemble_score"]
ml_confidence_val = ml_result["confidence"]
ml_signal_val = ml_result["signal"]
# 룰베이스 + ML 가중 결합
rule_score_normalized = (score / 15.0) * 100.0 # 0~15 → 0~100
combined_score = int(
rule_score_normalized * settings.ML_ENSEMBLE_RULE_WEIGHT +
ml_score_val * settings.ML_ENSEMBLE_ML_WEIGHT
)
# 0~100 → 0~15 스케일로 재변환 (기존 호환)
score = max(0, int(combined_score / 100.0 * 15.0))
signals.append(f"ML앙상블: {ml_signal_val}({ml_score_val}점, 신뢰도{ml_confidence_val:.0%})")
logger.info(f" ML 앙상블: score={ml_score_val}, confidence={ml_confidence_val:.2f}, signal={ml_signal_val}")
except Exception as e:
logger.debug(f"ML 앙상블 스코어 계산 실패 (정상 - 모델 미학습): {e}")
# ATR / ADX 계산 (이미 가져온 차트 데이터 재사용 — 이중 API 호출 방지)
atr_14_val = None
adx_14_val = None
try:
if len(chart_data) >= 15:
_h = [float(d.get("stck_hgpr", 0)) for d in chart_data]
_l = [float(d.get("stck_lwpr", 0)) for d in chart_data]
_c = [float(d.get("stck_clpr", 0)) for d in chart_data]
atr_14_val = calculate_atr(_h, _l, _c)
if len(chart_data) >= 29: # Wilder ADX 최소 데이터 (period * 2 + 1)
adx_14_val = calculate_adx(_h, _l, _c)
except Exception:
pass
# 신호가 있거나 점수가 0 이상이면 추천 (배치 모드에서는 score=0도 반환)
if criteria.for_batch or 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,
ml_score=ml_score_val,
ml_confidence=ml_confidence_val,
ml_signal=ml_signal_val,
atr_14=atr_14_val,
adx_14=adx_14_val,
)
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_for_batch(stock_code: str, stock_name: str) -> Optional[StockRecommendation]:
"""배치 DB 저장용: 거래량/점수 필터 없이 지표만 계산하여 전 종목 저장.
모든 지표를 계산하되, 매매 필터(min_volume_ratio, 거래량 부족, score=0)를 적용하지 않습니다.
score=0이어도 반환하여 배치에서 DB에 저장됩니다.
"""
criteria = RecommendationCriteria(
use_macd=True,
use_rsi=True,
use_bollinger=True,
use_moving_average=True,
use_stochastic=True,
use_adx=True,
use_obv=True,
use_volatility_breakout=True,
min_volume_ratio=0, # 거래량 필터 없음
max_disparity=200.0, # 이격도 필터 없음
for_batch=True, # score=0 · 저유동성 종목도 반환
)
return await analyze_stock(stock_code, stock_name, criteria)
async def analyze_stock_with_indicators(stock_code: str, stock_name: str) -> Optional[StockRecommendation]:
"""호출 편의성: 모든 지표를 켜서 분석한 결과(Pydantic 모델)를 반환합니다.
이 함수는 배치 스크립트에서 사용됩니다.
🔥 승률 향상 기능 포함:
- 변동성 돌파 전략
- 거래량 필터 (평균 대비 1.2배)
- 이격도 필터 (110% 제한)
"""
criteria = RecommendationCriteria(
use_macd=True,
use_rsi=True,
use_bollinger=True,
use_moving_average=True,
use_stochastic=True,
use_adx=True,
use_obv=True,
use_volatility_breakout=True, # 🔥 변동성 돌파 추가
min_volume_ratio=1.2, # 🔥 거래량 필터 (평균의 1.2배 이상)
max_disparity=110.0 # 🔥 이격도 필터 (20일선 대비 110% 이하)
)
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 거래대금 상위 종목 조회 ("Q" = KOSDAQ, KIS API 규격)
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, returning empty list")
return stocks
except Exception as e:
logger.error(f"Error fetching stock list from KIS API: {e}", exc_info=True)
logger.warning("Returning empty list due to API error")
return []
@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) 사용"),
use_volatility_breakout: bool = Query(False, description="변동성 돌파 전략 사용"),
min_volume_ratio: float = Query(1.0, description="최소 거래량 비율 (평균 대비)"),
max_disparity: float = Query(200.0, description="최대 이격도 (20일선 대비 %)"),
max_stocks: int = Query(100, description="분석할 최대 종목 수 (기본 100개)"),
):
"""기술적 지표 기반 추천 종목 조회
선택한 기술적 지표 조건에 부합하는 종목을 KOSPI 시가총액 상위 종목 중에서 찾아 추천합니다.
🔥 승률 향상 기능:
- 변동성 돌파: 래리 윌리엄스 전략 (고승률)
- 거래량 필터: 평균 대비 거래량 확인
- 이격도 필터: 20일선과의 거리 제한 (추격 매수 방지)
- 시장 지수 필터: 코스피 지수 확인 (하락장 보수적 대응)
"""
# 🔥 시장 지수 확인 (가장 중요한 승률 결정 요소)
market_regime = await check_market_regime()
logger.info(f"📊 시장 상태: {market_regime['message']}")
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,
use_volatility_breakout=use_volatility_breakout,
min_volume_ratio=min_volume_ratio,
max_disparity=max_disparity
)
# 모든 지표가 꺼져있으면 기본값 사용
if not any([use_macd, use_rsi, use_bollinger, use_moving_average, use_stochastic, use_adx, use_obv, use_volatility_breakout]):
criteria.use_rsi = True
criteria.use_moving_average = True
criteria.use_volatility_breakout = 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 및 예외 제거 후 3000원 이하 종목 필터링, 점수순 정렬
regime_min_score = market_regime.get("min_score", 1)
recommendations = [
r for r in all_results
if r is not None
and not isinstance(r, Exception)
and r.current_price >= 3000 # 🚫 동전주 제외
and r.score >= regime_min_score # 🐻 하락장 최소 점수 필터
]
recommendations.sort(key=lambda x: x.score, reverse=True)
logger.info(f"Found {len(recommendations)} recommendations out of {len(stocks_to_analyze)} analyzed stocks (min_score={regime_min_score})")
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(),
"market_regime": market_regime, # 🔥 시장 상태 정보 추가
"recommendations": recommendations[:20] # 상위 20개만 반환
}
@router.get("/rule-based")
async def get_rule_based_recommendations(
realtime: bool = Query(False, description="실시간 분석 여부 (느림, 30초)"),
auto_trade: bool = Query(True, description="자동매매 실행 여부"),
db: AsyncSession = Depends(get_db)
):
"""
매매 규칙 기반 자동 추천
- realtime=false (기본): DB에서 조회 (빠름, 0.1초) - 매일 아침 9시 데이터
- realtime=true: KIS API 실시간 분석 (느림, 30초)
- auto_trade=true (기본): 조건에 맞는 종목을 자동으로 매매
활성화된 모든 매매 규칙을 확인하고, 조건에 맞는 종목을 자동으로 추천합니다.
"""
# 실시간 모드: 기존 방식 (KIS API 직접 호출)
if realtime:
logger.info("🔴 Real-time mode: Fetching live data from KIS API (slow)")
result = await get_rule_based_recommendations_realtime()
else:
# DB 모드: 빠른 조회
logger.info("🟢 DB mode: Fetching from cached analysis (fast)")
result = await get_rule_based_recommendations_from_db(db)
# 자동매매 실행 (백그라운드)
# 주의: 엔드포인트 반환 후 FastAPI가 db 세션을 닫으므로,
# 백그라운드 태스크는 독립적인 새 세션을 열어서 사용해야 한다.
if auto_trade and result.get("recommendations_by_rule"):
rec_snapshot = result
async def _bg_trade():
async with AsyncSessionLocal() as bg_db:
await execute_auto_trade_background(rec_snapshot, bg_db)
asyncio.create_task(_bg_trade())
logger.info("🤖 Auto-trade task created in background")
return result
def is_mean_reversion_candidate(stock, conditions: dict, market_regime_str: str) -> bool:
"""RSI+볼린저 평균회귀 매수 조건 확인 (CLAUDE.md 섹션 7.2)
Bull: RSI ≤ 25 + BB 하단 -3% ~ +1.5%
(거래량 200% OR 조건은 real-time check_buy_signal에서 처리)
Bear: RSI ≤ 35 + BB 하단 이하
"""
rsi_val = float(stock.rsi) if stock.rsi else None
close = int(stock.current_price) if stock.current_price else 0
bb_lower = float(stock.bollinger_lower) if stock.bollinger_lower else None
bb_upper = float(stock.bollinger_upper) if stock.bollinger_upper else None
bb_middle = float(stock.bollinger_middle) if stock.bollinger_middle else None
if not all([rsi_val, close, bb_lower, bb_upper, bb_middle]):
return False
# 가격 필터 (섹션 6.4)
if close < settings.MIN_PRICE_KRW:
return False
regime = MarketRegime.BULL if market_regime_str == "bull" else MarketRegime.BEAR
# ADX 필터: 강한 추세 중 평균회귀 진입 차단 (ADX > 30이면 횡보 아님)
adx_val = float(stock.adx_14) if getattr(stock, "adx_14", None) else None
adx_strong_trend = adx_val is not None and adx_val > 30
if regime == MarketRegime.BULL:
rsi_threshold = conditions.get("rsi_bull_max", 25)
bb_lower_pct = conditions.get("bb_lower_pct", 3.0) / 100 # -3%
bb_upper_pct = conditions.get("bb_upper_pct", 1.5) / 100 # +1.5%
in_bb_zone = bb_lower * (1 - bb_lower_pct) <= close <= bb_lower * (1 + bb_upper_pct)
if adx_strong_trend:
return False # 강추세 중 평균회귀 차단
return rsi_val <= rsi_threshold and in_bb_zone
else:
rsi_threshold = conditions.get("rsi_bear_max", 35)
if adx_strong_trend:
return False # 강추세 중 평균회귀 차단
return rsi_val <= rsi_threshold and close <= bb_lower
async def get_rule_based_recommendations_from_db(db: AsyncSession):
"""DB에서 사전 분석된 데이터 조회 (빠름 - 0.1초)"""
logger.info("Rule-based recommendations from DB")
# JSON 파일에서 규칙 로드
import json
from pathlib import Path
from collections import namedtuple
Rule = namedtuple('Rule', ['id', 'name', 'description', 'rule_type', 'priority', 'conditions', 'actions', 'is_active'])
try:
config_path = Path(__file__).parent.parent.parent.parent / "trading_rules_config.json"
with open(config_path, 'r', encoding='utf-8') as f:
config = json.load(f)
rules_data = config.get("rules", [])
active_rules = []
for idx, rule_data in enumerate(rules_data, start=1):
if rule_data.get("is_active", False):
active_rules.append(Rule(
id=idx,
name=rule_data["name"],
description=rule_data["description"],
rule_type=rule_data["rule_type"].upper(),
priority=rule_data.get("priority", idx),
conditions=rule_data.get("conditions", {}),
actions=rule_data.get("actions", {}),
is_active=True
))
logger.info(f"Loaded {len(active_rules)} active trading rules from config file")
except Exception as e:
logger.error(f"Failed to load trading rules config: {e}")
# 폴백: 기본 규칙 사용
active_rules = [
Rule(
id=1,
name="RSI 과매도 반등",
description="RSI 30 이하 과매도 종목",
rule_type="BUY",
priority=1,
conditions={"rsi_threshold": 30},
actions={
"take_profit": 5.0,
"stop_loss": 3.0,
"position_sizing": {"target_ratio": 0.15}
},
is_active=True
)
]
logger.warning(f"Using fallback rule: {len(active_rules)} rule(s)")
logger.info(f"Found {len(active_rules)} active trading rules")
# 오늘 날짜의 분석 데이터 조회 (중복 종목은 score 높은 것 우선)
today = date.today()
result = await db.execute(
select(StockAnalysis)
.where(StockAnalysis.analysis_date >= today)
.order_by(StockAnalysis.score.desc())
)
raw_stocks = result.scalars().all()
# 종목코드 기준 중복 제거 (score 내림차순 정렬 후 첫 번째만)
seen = set()
all_analyzed_stocks = []
for s in raw_stocks:
if s.stock_code not in seen:
seen.add(s.stock_code)
all_analyzed_stocks.append(s)
# DB에 데이터가 없으면 빈 결과 반환
if not all_analyzed_stocks or len(all_analyzed_stocks) < 1:
logger.warning(f"No analysis data found ({len(all_analyzed_stocks) if all_analyzed_stocks else 0} stocks). Run batch analysis first.")
return {
"total": 0,
"active_rules": len(active_rules),
"analyzed_stocks": 0,
"analyzed_stock_list": [],
"recommendations": [],
"recommendations_by_rule": [],
"rules_applied": [],
"message": "⚠️ 배치 분석 데이터 없음. 'make batch-test' 명령어로 분석 실행 필요."
}
logger.info(f"Found {len(all_analyzed_stocks)} stocks in DB for {today}")
# 규칙별로 매칭되는 종목 찾기
rule_matches = []
recommendations_by_rule = []
stock_to_rules = {} # 종목코드 -> 매칭된 규칙 리스트 매핑
# 시장 레짐 판별 (섹션 7.1: 200일 MA 기반)
market_regime_info = await check_market_regime()
current_market_regime = market_regime_info.get("regime_200d", "bear")
for rule in active_rules:
logger.info(f"Applying rule: {rule.name}")
conditions = rule.conditions
actions = rule.actions
rule_matched_stocks = []
for stock in all_analyzed_stocks:
matches = True
# === 전략 타입별 분기 ===
# 1) 평균회귀 전략 (RSI + 볼린저 반등)
if rule.rule_type == "MEAN_REVERSION":
matches = is_mean_reversion_candidate(stock, conditions, current_market_regime)
# 2) 추세/골든크로스 전략 (나중에 별도 함수로 분리 예정)
elif rule.rule_type == "TREND":
# 여기서는 당장 비활성화하거나, 매우 보수적으로만 사용
# matches = is_trend_candidate(stock, conditions)
matches = False # 일단 평균회귀 안정화 전까지 비활성화
# 3) 박스권 돌파 / 모멘텀 전략 (위험 축)
elif rule.rule_type in ("BREAKOUT", "MOMENTUM"):
# matches = is_breakout_or_momentum_candidate(stock, conditions)
matches = False # 초기에는 끄고, 나중에 별도 엔진으로
# 기타 타입은 현재 스킵
else:
matches = False
if not matches:
continue
# 여기까지 왔으면 rule_matched_stocks에 추가
rule_matched_stocks.append(stock)
# 점수순 정렬
rule_matched_stocks.sort(key=lambda x: x.score, reverse=True)
# 이하 로직은 그대로 (Top N, rule_matches, stock_to_rules, 실시간 가격 업데이트 등)
rule_stocks = []
for stock in rule_matched_stocks[:10]:
stock_dict = {
"stock_code": stock.stock_code,
"stock_name": stock.stock_name,
"current_price": int(stock.current_price) if stock.current_price else 0,
"change_rate": round(float(stock.change_rate), 2) if stock.change_rate else 0,
"volume": stock.volume,
"rsi": round(float(stock.rsi), 2) if stock.rsi else None,
"adx_14": round(float(stock.adx_14), 2) if stock.adx_14 else None,
"ma5": int(stock.ma5) if stock.ma5 else None,
"ma20": int(stock.ma20) if stock.ma20 else None,
"stochastic_k": round(float(stock.stochastic_k), 2) if stock.stochastic_k else None,
"signals": stock.signals or [],
"score": stock.score,
"ml_score": stock.ml_score,
"ml_confidence": round(float(stock.ml_confidence), 4) if stock.ml_confidence else None,
"ml_signal": stock.ml_signal,
"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,
"take_profit": actions.get("take_profit", 3.0),
"stop_loss": actions.get("stop_loss", -1.5),
"max_add_buys": actions.get("max_add_buys", 3),
"add_buy_cooldown_min": actions.get("add_buy_cooldown_min", 15),
}
rule_stocks.append(stock_dict)
rule_matches.append(stock_dict)
if stock.stock_code not in stock_to_rules:
stock_to_rules[stock.stock_code] = []
rule_exists = any(r["rule_id"] == rule.id for r in stock_to_rules[stock.stock_code])
if not rule_exists:
stock_to_rules[stock.stock_code].append({
"rule_id": rule.id,
"rule_name": rule.name,
"priority": rule.priority,
})
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)}")
# 규칙 매칭 결과가 없으면 분석 종목 상위 20개를 폴백으로 추가
if not rule_matches and all_analyzed_stocks:
logger.info("No rule matches found. Using top scored analyzed stocks as fallback recommendations.")
already_added = set()
fallback_stocks = []
for stock in all_analyzed_stocks[:20]:
if stock.stock_code in already_added:
continue
already_added.add(stock.stock_code)
stock_dict = {
"stock_code": stock.stock_code,
"stock_name": stock.stock_name,
"current_price": int(stock.current_price) if stock.current_price else 0,
"change_rate": round(float(stock.change_rate), 2) if stock.change_rate else 0,
"volume": stock.volume,
"rsi": round(float(stock.rsi), 2) if stock.rsi else None,
"adx_14": round(float(stock.adx_14), 2) if stock.adx_14 else None,
"ma5": int(stock.ma5) if stock.ma5 else None,
"ma20": int(stock.ma20) if stock.ma20 else None,
"stochastic_k": round(float(stock.stochastic_k), 2) if stock.stochastic_k else None,
"signals": stock.signals or [],
"score": stock.score,
"ml_score": stock.ml_score,
"ml_confidence": round(float(stock.ml_confidence), 4) if stock.ml_confidence else None,
"ml_signal": stock.ml_signal,
"price_date": stock.analysis_date.strftime("%Y%m%d"),
"rule_id": 0,
"rule_name": "고점수 분석 종목",
"rule_description": "매매 규칙 조건 미충족 - 분석 점수 기준 상위 종목",
"rule_priority": 99,
"take_profit": 3.0,
"stop_loss": -1.5,
"max_add_buys": 2,
"add_buy_cooldown_min": 30,
}
fallback_stocks.append(stock_dict)
rule_matches.append(stock_dict)
if fallback_stocks:
recommendations_by_rule.append({
"rule_id": 0,
"rule_name": "고점수 분석 종목",
"rule_description": "현재 매매 규칙 조건 미충족 - 분석 점수 기준 상위 종목",
"rule_type": "ANALYSIS",
"priority": 99,
"matched_count": len(fallback_stocks),
"stocks": fallback_stocks,
})
logger.info(f"Added {len(fallback_stocks)} fallback stocks from analyzed_stock_list")
return {
"total": len(rule_matches),
"active_rules": len(active_rules),
"analyzed_stocks": len(all_analyzed_stocks),
"analyzed_stock_list": [
{
"stock_code": s.stock_code,
"stock_name": s.stock_name,
"current_price": int(s.current_price) if s.current_price else 0,
"change_rate": round(float(s.change_rate), 2) if s.change_rate else 0,
"rsi": round(float(s.rsi), 2) if s.rsi else None,
"stochastic_k": round(float(s.stochastic_k), 2) if s.stochastic_k else None,
"signals": s.signals or [],
"score": s.score or 0,
}
for s in all_analyzed_stocks
],
"recommendations": rule_matches,
"recommendations_by_rule": recommendations_by_rule,
"rules_applied": [{"id": r.id, "name": r.name, "priority": r.priority} for r in active_rules],
}
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": 3.0,
"stop_loss": -1.5,
"position_sizing": {"target_ratio": 0.10},
"max_position_pct": 0.12,
"max_add_buys": 3,
"add_buy_cooldown_min": 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": 3.0,
"stop_loss": -1.5,
"position_sizing": {"target_ratio": 0.10},
"max_position_pct": 0.12,
"max_add_buys": 3,
"add_buy_cooldown_min": 15
},
is_active=True
),
Rule(
id=3,
name="볼린저밴드 하단",
description="볼린저밴드 하단 근처 반등",
rule_type="BUY",
priority=3,
conditions={"bb_threshold": 0.2},
actions={
"take_profit": 3.0,
"stop_loss": -1.5,
"position_sizing": {"target_ratio": 0.10},
"max_position_pct": 0.12,
"max_add_buys": 3,
"add_buy_cooldown_min": 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[:20] # 20개 종목으로 단축 (속도 개선)
# 배치 처리 (속도 개선)
batch_size = 5 # 배치 크기
delay_between_batches = 0.3 # 딜레이 추가 감소
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")
# 분석할 종목 리스트 (타임아웃 방지를 위해 10개로 제한)
all_stocks = await get_all_tradable_stocks()
stocks_to_analyze = all_stocks[:10] # 상위 10개 종목으로 제한
# ✅ 최적화: 모든 종목을 한 번만 분석 (모든 지표 포함)
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:
# ETF/레버리지/인버스/KODEX/선물 종목 제외
etf_keywords = ["ETF", "etf", "레버리지", "인버스", "KODEX", "선물"]
if any(k in stock.stock_name for k in etf_keywords):
continue
# 규칙 조건 체크
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
# Trend filter: only allow long entries in uptrend
if TREND_FILTER_ENABLED:
try:
t_ok = False
if stock.ma20 is not None and stock.ma60 is not None:
t_ok = (stock.ma20 > stock.ma60) and (stock.current_price > stock.ma20)
else:
# Fallback compute from price history is skipped here (already done in analyze_stock)
t_ok = False
if not t_ok:
matches = False
except Exception:
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.10),
"take_profit": actions.get("take_profit", 3.0),
"stop_loss": actions.get("stop_loss", -1.5),
"max_position_pct": actions.get("max_position_pct", 0.12),
"max_add_buys": actions.get("max_add_buys", 3),
"add_buy_cooldown_min": actions.get("add_buy_cooldown_min", 15)
}
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"]
# 실시간 분석 결과를 DB에 저장
from app.models.models import StockAnalysis
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import AsyncSessionLocal
import datetime
db = AsyncSessionLocal()
today = datetime.datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
for rec in top_recommendations:
# 중복 저장 방지: 오늘자+종목코드 기준
exists = await db.execute(
StockAnalysis.__table__.select().where(
StockAnalysis.stock_code == rec["stock_code"],
StockAnalysis.analysis_date == today
)
)
if not exists.first():
stock_obj = StockAnalysis(
stock_code=rec["stock_code"],
stock_name=rec["stock_name"],
analysis_date=today,
current_price=rec["current_price"],
change_rate=rec.get("change_rate", 0),
volume=rec.get("volume", 0),
rsi=rec.get("rsi", None),
ma5=rec.get("ma5", None),
ma20=rec.get("ma20", None),
ma60=rec.get("ma60", None),
stochastic_k=rec.get("stochastic_k", None),
stochastic_d=rec.get("stochastic_d", None),
bollinger_upper=rec.get("bollinger_upper", None),
bollinger_lower=rec.get("bollinger_lower", None),
bollinger_middle=rec.get("bollinger_middle", None),
signals=rec.get("signals", []),
score=rec.get("score", 0),
ml_score=rec.get("ml_score"),
ml_confidence=rec.get("ml_confidence"),
ml_signal=rec.get("ml_signal"),
)
db.add(stock_obj)
try:
await db.commit()
finally:
await db.close()
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 _mcp_validate_buy(stock: dict) -> tuple:
"""MCP 서버에 AI 매수 검증 요청
Returns:
(approved: bool, reason: str)
- 오류/타임아웃 시 (True, "mcp_unavailable") — fail-open (매매 차단 안 함)
"""
if not settings.MCP_VALIDATION_ENABLED:
return True, "mcp_disabled"
try:
payload = {
"analysis_type": "RULE_VALIDATION",
"data": {
"rule": {
"id": stock.get("rule_id"),
"name": stock.get("rule_name", "auto_trade"),
},
"market_data": {
"stock_code": stock.get("stock_code"),
"stock_name": stock.get("stock_name"),
"current_price": stock.get("current_price"),
"signals": stock.get("signals", []),
"score": stock.get("score"),
"ml_signal": stock.get("ml_signal"),
"ml_confidence": stock.get("ml_confidence"),
"ma20": stock.get("ma20"),
"ma60": stock.get("ma60"),
}
}
}
async with httpx.AsyncClient(timeout=10.0) as client:
resp = await client.post(
f"{settings.MCP_SERVER_URL}/analyze",
json=payload
)
resp.raise_for_status()
data = resp.json()
result = data.get("result", {})
decision = result.get("decision", "HOLD")
confidence = float(result.get("confidence", 0.0))
logger.info(
f"🤖 MCP 검증: {stock.get('stock_name')} → "
f"decision={decision}, confidence={confidence:.2f}"
)
if decision == "BUY" and confidence >= settings.MCP_CONFIDENCE_THRESHOLD:
return True, f"mcp_approved(conf={confidence:.2f})"
else:
return False, f"mcp_rejected(decision={decision}, conf={confidence:.2f})"
except Exception as e:
logger.warning(f"⚠️ MCP 검증 실패 (스킵하고 계속): {e}")
return True, "mcp_unavailable" # 장애 시 매매 차단 안 함
async def execute_auto_trade_background(recommendations_response: dict, db: AsyncSession):
"""백그라운드에서 자동매매 실행"""
try:
trade_results = []
total_processed = 0
skipped_held = 0 # 이미 보유 중인 종목 수
# 🐻 하락장 방어: 시장 레짐에 따라 매매 건수 제한
market_regime = None
if settings.BEAR_MARKET_FILTER_ENABLED:
market_regime = await check_market_regime()
logger.info(f"🐻 자동매매 시장 상태: {market_regime['message']}")
regime_max_trades = market_regime.get("max_trades", 20) if market_regime else 20
position_multiplier = market_regime.get("position_multiplier", 1.0) if market_regime else 1.0
max_trades = min(20, regime_max_trades)
# 현재 보유 중인 종목 조회
from app.models.models import Portfolio
result = await db.execute(
select(Portfolio).where(Portfolio.quantity > 0)
)
held_stocks = {p.stock_code for p in result.scalars().all()}
if held_stocks:
logger.info(f"현재 보유 중: {len(held_stocks)}개 종목")
# ✅ 중복 제거: 모든 규칙 그룹의 종목을 합쳐서 중복 제거
unique_stocks = {}
for rule_group in recommendations_response["recommendations_by_rule"]:
for stock in rule_group["stocks"]:
stock_code = stock.get("stock_code")
# 우선순위가 높은 규칙의 종목을 우선 (priority가 낮을수록 우선순위 높음)
if stock_code not in unique_stocks:
unique_stocks[stock_code] = stock
elif stock.get("rule_priority", 999) < unique_stocks[stock_code].get("rule_priority", 999):
unique_stocks[stock_code] = stock
# 우선순위와 점수로 정렬
sorted_stocks = sorted(
unique_stocks.values(),
key=lambda s: (s.get("rule_priority", 999), -s.get("score", 0))
)
logger.info(f"📊 자동매매 대상: 총 {len(sorted_stocks)}개 종목 (중복 제거 완료)")
for stock in sorted_stocks[:max_trades]:
# 🔄 이미 보유 중인 종목도 추가 매수 허용 (일일 2회 제한 내에서)
# if stock.get("stock_code") in held_stocks:
# skipped_held += 1
# logger.debug(f"⏭️ {stock.get('stock_name')} - 이미 보유중, 건너뜀")
# continue
try:
# 시장 레짐 정보 전달 (포지션 사이징에서 regime 복원에 사용)
stock["_position_multiplier"] = position_multiplier
stock["_regime_str"] = market_regime.get("regime_200d", "bear") if market_regime else "bear"
result = await execute_auto_trade(stock, db)
total_processed += 1
if result["action"] in ["BUY", "SELL"]:
trade_results.append(result)
logger.info(f"✅ {result['action']} {result['stock']} - {result.get('quantity')}주")
# 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
logger.info(f"🎯 자동매매 완료: {len(trade_results)}건 실행 / {total_processed}개 분석 / {skipped_held}개 보유중")
except Exception as e:
logger.error(f"❌ Background auto-trade error: {e}")
async def _background_sync_portfolio():
"""백그라운드 포트폴리오 동기화 (새 세션 사용)"""
try:
from app.api.endpoints.portfolio import sync_portfolio
async with AsyncSessionLocal() as session:
await sync_portfolio(session, force=True)
except Exception as e:
logger.error(f"Background portfolio sync failed: {e}")
async def _delayed_sync_portfolio(delay_seconds: int = 3):
"""지연 포트폴리오 동기화 (KIS API 업데이트 대기)"""
try:
await asyncio.sleep(delay_seconds)
from app.api.endpoints.portfolio import sync_portfolio
from app.core.database import AsyncSessionLocal
async with AsyncSessionLocal() as session:
try:
await sync_portfolio(session, force=True)
logger.info(f"🔄 Delayed portfolio sync completed after {delay_seconds}s")
except Exception as inner_e:
logger.error(f"Sync execution failed: {inner_e}")
except Exception as e:
logger.error(f"Delayed portfolio sync failed: {e}")
# 자동 매매 실행 함수
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"}
# 국내/해외 구분 (6자리 숫자면 국내, 알파벳 포함이면 해외)
is_domestic = stock_code.isdigit() and len(stock_code) == 6
exchange_code = stock.get("exchange_code", "NASD") # 기본값: 나스닥
# 가격 필터 (CLAUDE.md 섹션 6.4)
if is_domestic:
if current_price < settings.MIN_PRICE_KRW:
logger.info(f"가격 필터: {stock_name} ({current_price:,.0f}원 < {settings.MIN_PRICE_KRW:,}원)")
return {"action": "SKIP", "reason": "penny_stock", "stock": stock_name}
else:
if current_price < settings.MIN_PRICE_USD:
logger.info(f"가격 필터: {stock_name} (${current_price:.2f} < ${settings.MIN_PRICE_USD:.2f})")
return {"action": "SKIP", "reason": "penny_stock", "stock": stock_name}
# MDD 체크 (섹션 6.3) — async lock으로 thread-safe
if not await mdd_manager.can_open_new_position():
logger.info(f"MDD 제한: 신규 진입 중단 상태 - {stock_name} 스킵")
return {"action": "SKIP", "reason": "mdd_pause", "stock": stock_name}
# 연속 손실 금지 체크 (섹션 7.4)
if loss_tracker.is_banned(stock_code):
ban_info = loss_tracker.get_ban_info(stock_code)
logger.info(f"연속 손실 금지: {stock_name} ({ban_info})")
return {"action": "SKIP", "reason": "consecutive_loss_ban", "stock": stock_name}
# 보유 종목 수 체크 (섹션 6.1: 최대 20종목)
held_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
held_count = len(held_result.scalars().all())
if held_count >= settings.MAX_HOLDINGS_COUNT:
logger.info(f"보유 종목 한도: {held_count}/{settings.MAX_HOLDINGS_COUNT} - {stock_name} 스킵")
return {"action": "SKIP", "reason": "max_holdings_reached", "stock": stock_name}
# 0. 매도 후 재진입 금지 (Cooldown) - 최근 24시간 내 매도 이력 확인
# 같은 날 손절친 종목을 다시 사는 것을 방지
from app.models.models import TradeHistory
cutoff_time = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0)
recent_sell = await db.execute(
select(TradeHistory).where(
TradeHistory.stock_code == stock_code,
TradeHistory.order_type == "SELL",
TradeHistory.status == "EXECUTED",
TradeHistory.executed_at >= cutoff_time
)
)
if recent_sell.first():
logger.info(f"🚫 Cooldown active: {stock_name}({stock_code}) was sold today. Skipping BUY.")
return {"action": "SKIP", "reason": "cooldown_sell_today", "stock": stock_name}
# 이미 보유 중인지 확인
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 바닥 탈출 반등 (XX)"
"RSI 안정 상승", # "RSI 안정 상승 (XX)"
"스토캐스틱 과매도", # "스토캐스틱 과매도 (%K: XX)"
"5일선 > 20일선",
"20일선 > 60일선",
"골든크로스", # "MACD 골든크로스"
"볼린저 하한선 터치",
"횡보장 — 평균회귀 유리", # "ADX(XX<20) 횡보장 — 평균회귀 유리"
"거래량 증가와 상승",
]
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)
# 🤖 ML 앙상블 기반 매수/매도 판단 (설정 활성화 시)
ml_score_trade = stock.get("ml_score")
ml_confidence_trade = stock.get("ml_confidence")
ml_signal_trade = stock.get("ml_signal")
if settings.ML_ENSEMBLE_ENABLED and ml_score_trade is not None:
# ML 신호가 BUY이고 신뢰도/스코어 충분하면 매수
if (ml_signal_trade == "BUY"
and ml_score_trade >= settings.ML_ENSEMBLE_BUY_THRESHOLD
and (ml_confidence_trade or 0) >= settings.ML_ENSEMBLE_CONFIDENCE_MIN):
should_buy = True
logger.info(f"🤖 ML 매수 신호: {stock_name} (score={ml_score_trade}, conf={ml_confidence_trade:.2f})")
# ML 신호가 SELL이면 매도 강화
elif ml_signal_trade == "SELL" and (ml_confidence_trade or 0) >= 0.5:
should_sell = True
logger.info(f"🤖 ML 매도 신호: {stock_name} (score={ml_score_trade}, conf={ml_confidence_trade:.2f})")
# ML 신뢰도가 낮으면 매수 억제
elif ml_signal_trade == "HOLD" and (ml_confidence_trade or 0) < settings.ML_ENSEMBLE_CONFIDENCE_MIN:
should_buy = False
logger.info(f"🤖 ML 관망 신호로 매수 억제: {stock_name}")
# ✅ 규칙 기반 추천 종목도 안전장치 적용 (이격도 과열, 급등 체크)
has_rule = stock.get("rule_id") is not None or stock.get("rule_name") is not None
if has_rule:
# 이격도 과열 시 매수 금지
if "이격도 과열" in text_signals or "매수금지" in text_signals:
logger.info(f"🚫 규칙 추천이지만 이격도 과열로 매수 금지: {stock_name}")
return {"action": "HOLD", "stock": stock_name, "reason": "disparity_overheated"}
# 급등 종목 추격 금지 체크
if "급등 종목 추격 금지" in text_signals or "급등과열" in text_signals:
logger.info(f"🚫 규칙 추천이지만 급등/과열 금지: {stock_name}")
return {"action": "HOLD", "stock": stock_name, "reason": "chase_buy_prevention"}
should_buy = True
logger.info(f"📋 규칙 기반 추천: {stock_name} - {stock.get('rule_name', '매수규칙')}")
# 매수 로직 (신호 또는 점수 기반)
# 추가매수 허용: 보유 중이어도 비중 한도 내에서 추매 가능
if (should_buy or (score_val >= 1)):
# 🚫 추격매수 방지: 급등 종목 필터
try:
if is_domestic:
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=10)
chart_data = chart_resp.get("output2", [])
else:
chart_resp = await korea_investment_api.get_overseas_daily_chart(stock_code, exchange_code, "D")
chart_data = chart_resp.get("output2", [])
if len(chart_data) >= 4:
# 국내/해외 데이터 키 차이 처리
if is_domestic:
closes = [float(item.get("stck_clpr", 0)) for item in chart_data[-4:]]
else:
closes = [float(item.get("clos", 0)) for item in chart_data[-4:]]
if len(closes) >= 4:
price_3days_ago = closes[0]
recent_gain = ((current_price - price_3days_ago) / price_3days_ago) * 100 if price_3days_ago > 0 else 0
if recent_gain > 10: # 3일간 10% 이상 급등
logger.info(f"🚫 급등 종목 추격 금지: {stock_name} (3일 +{recent_gain:.1f}%)")
return {"action": "HOLD", "stock": stock_name, "reason": "chase_buy_prevention"}
except Exception as e:
logger.warning(f"⚠️ 급등 체크 실패, 계속 진행: {e}")
# Enforce trend filter for buys
if TREND_FILTER_ENABLED:
try:
ma20 = stock.get("ma20")
ma60 = stock.get("ma60")
# If moving averages not provided, fetch quick chart to compute
if ma20 is None or ma60 is None:
if is_domestic:
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D")
closes_local = [float(it.get("stck_clpr", 0)) for it in (chart_resp.get("output") or [])]
else:
chart_resp = await korea_investment_api.get_overseas_daily_chart(stock_code, exchange_code, "D")
closes_local = [float(it.get("clos", 0)) for it in (chart_resp.get("output2") or [])]
if len(closes_local) >= max(TREND_MA_SHORT, TREND_MA_LONG):
ma20 = _ma(closes_local, TREND_MA_SHORT)
ma60 = _ma(closes_local, TREND_MA_LONG)
# 허용 오차 적용
if not (ma20 and ma60 and (float(ma20) >= float(ma60) * (1 - TREND_TOLERANCE_PCT)) and (current_price >= float(ma20) * (1 - TREND_TOLERANCE_PCT))):
logger.info(f"🚫 Trend filter rejected BUY: {stock_name} (price={current_price}, ma20={ma20}, ma60={ma60}, tol={TREND_TOLERANCE_PCT})")
return {"action": "HOLD", "stock": stock_name, "reason": "trend_filter"}
except Exception as e:
logger.info(f"⚠️ Trend filter check failed, skipping buy as safe default: {e}")
return {"action": "HOLD", "stock": stock_name, "reason": "trend_filter_error"}
# 포트폴리오 요약 정보
try:
from app.api.endpoints.portfolio import portfolio_cache
available_cash = float(portfolio_cache.summary.get("available_cash", 0)) if portfolio_cache.summary else 0.0
total_assets = float(portfolio_cache.summary.get("total_assets", 0)) if portfolio_cache.summary else 0.0
except Exception:
available_cash, total_assets = 0.0, 0.0
# 🔥 하루 매매 횟수 제한 (당일 0시부터 카운트)
daily_cutoff = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0)
daily_buy_check = await db.execute(
select(TradeHistory)
.where(
TradeHistory.stock_code == stock_code,
TradeHistory.order_type == "BUY",
TradeHistory.status.in_(['EXECUTED', 'COMPLETED']), # COMPLETED도 포함
TradeHistory.created_at >= daily_cutoff,
)
)
daily_buys = daily_buy_check.scalars().all()
daily_buy_count = len(daily_buys)
# 🚫 하루 최대 2회 매매 제한 (신규 + 추가)
max_daily_trades = 2
if daily_buy_count >= max_daily_trades:
logger.info(f"🚫 일일 매매 한도 초과: {stock_name} 당일 {daily_buy_count}/{max_daily_trades}회")
return {"action": "HOLD", "stock": stock_name, "reason": "daily_trade_limit"}
cooldown_min = int(stock.get("add_buy_cooldown_min", 30) or 30) # 추가매수 쿨다운(분)
# 🔥 쿨다운 체크 (보유 여부와 관계없이 항상 체크)
# 포트폴리오 동기화가 느릴 수 있으므로, DB 거래 내역 기준으로 체크
cooldown_check = await db.execute(
select(TradeHistory)
.where(
TradeHistory.stock_code == stock_code,
TradeHistory.order_type == "BUY",
TradeHistory.status.in_(['EXECUTED', 'COMPLETED']),
TradeHistory.created_at >= daily_cutoff,
)
.order_by(desc(TradeHistory.created_at))
)
recent_buys = cooldown_check.scalars().all()
last_buy_at = recent_buys[0].created_at if recent_buys else None
# 쿨다운 체크
if last_buy_at and (_get_kst_now() - last_buy_at).total_seconds() < cooldown_min * 60:
logger.info(f"⏳ 추가매수 대기: {stock_name} {cooldown_min}분 쿨다운 미경과")
return {"action": "HOLD", "stock": stock_name, "reason": "cooldown"}
# 목표/최대 비중 기반 투자금 계산 (이전에 최근 매수 기록 체크)
# 보유중이 아니더라도(아직 싱크 안됨) 최근 매수 시도가 있으면 스킵
recent_buy_cutoff = _get_kst_now() - timedelta(minutes=5)
check_recent = await db.execute(
select(TradeHistory).where(
TradeHistory.stock_code == stock_code,
TradeHistory.order_type == "BUY",
TradeHistory.created_at >= recent_buy_cutoff,
TradeHistory.status.in_(['EXECUTED', 'COMPLETED', 'PENDING'])
)
)
if check_recent.first():
logger.info(f"⏳ 최근 5분 내 매수 이력 존재: {stock_name} - 스킵")
return {"action": "SKIP", "stock": stock_name, "reason": "recent_buy_5min"}
# ✅ MCP AI 검증 (BUY 최종 확인 - 모든 사전 필터 통과 후)
mcp_ok, mcp_reason = await _mcp_validate_buy(stock)
if not mcp_ok:
logger.info(f"🚫 MCP AI 매수 거부: {stock_name} ({mcp_reason})")
return {"action": "HOLD", "stock": stock_name, "reason": mcp_reason}
# 동적 포지션 사이징 (CLAUDE.md 섹션 6.1: 종목당 최대 4%, regime/RSI/ADX 반영)
current_value = float(holding.quantity * current_price) if holding else 0.0
regime_str = stock.get("_regime_str", "bear")
regime = MarketRegime.BULL if regime_str == "bull" else MarketRegime.BEAR
invest_amount = calculate_position_size_v2(
total_assets=total_assets,
available_cash=available_cash,
current_strategy_allocation=current_value,
regime=regime,
rsi=stock.get("rsi"),
adx=stock.get("adx_14"),
)
if invest_amount <= 0 and total_assets <= 0:
# 총자산 미확정 시 보수적으로 현금의 4%
invest_amount = available_cash * 0.04
quantity = int(invest_amount / current_price)
logger.info(f"💰 {stock_name}({stock_code}): 예수금={available_cash:,.0f}원, 투자금액={invest_amount:,.0f}원, 수량={quantity}주, 가격={current_price:,.0f}원")
if quantity > 0:
logger.info(f"🟢 매수 신호 감지: {stock_name}({stock_code}) - {signals}")
# KIS API로 매수 주문 (지정가: 현재가로 주문)
try:
if is_domestic:
order_result = await korea_investment_api.place_order(
stock_code=stock_code,
order_type="BUY",
quantity=quantity,
price=int(current_price) # 지정가 주문 (현재가)
)
else:
order_result = await korea_investment_api.place_overseas_order(
symbol=stock_code,
order_type="BUY",
quantity=quantity,
price=float(current_price), # 해외 주식은 달러 단위
exchange_code=exchange_code
)
# 주문 결과 상세 로깅
logger.info(f"📝 매수 주문 결과: {stock_name}({stock_code}) - rt_cd={order_result.get('rt_cd')}, msg={order_result.get('msg1')}, odno={order_result.get('odno')}")
logger.debug(f"Full order result: {order_result}")
# 중복 거래 방지: 최근 10초 이내 동일 종목 동일 타입 주문 확인
recent_cutoff = _get_kst_now() - timedelta(seconds=10)
duplicate_check = await db.execute(
select(TradeHistory).where(
TradeHistory.stock_code == stock_code,
TradeHistory.order_type == "BUY",
TradeHistory.created_at >= recent_cutoff
)
)
recent_duplicate = duplicate_check.scalar_one_or_none()
if recent_duplicate:
logger.warning(f"⚠️ 중복 매수 방지: {stock_name} - 10초 이내 동일 주문 존재")
return {"action": "SKIP", "reason": "duplicate_order", "stock": stock_name}
# 거래 이력 저장 (상세 정보 포함)
total_amount = Decimal(str(current_price)) * quantity
entry_reason = f"매수 신호: {', '.join(signals[:3])}. 점수: {score_val}점"
success = order_result.get("rt_cd") == "0"
error_msg = order_result.get("msg1", "") if not success else ""
# 주문 상태 결정
if success:
status = "COMPLETED" # 체결 완료
elif order_result.get("rt_cd") == "1":
status = "PENDING" # 주문 접수됨, 체결 대기
else:
status = "FAILED" # 주문 실패
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)) if success else None,
executed_quantity=quantity if success else 0,
total_amount=total_amount,
status=status,
order_id=order_result.get("odno"),
message=f"자동매수: {'추가매수' if holding else '신규'} | {' | '.join(signals[:2])} | {error_msg if not success else '체결완료'}",
entry_reason=entry_reason,
signals=signals,
created_at=_get_kst_now(),
executed_at=_get_kst_now() if success else None
)
db.add(trade)
await db.commit()
if success:
logger.info(f"✅ 매수 체결 완료: {stock_name} {quantity}주 @ {current_price}원")
elif status == "PENDING":
logger.info(f"⏳ 매수 주문 접수: {stock_name} {quantity}주 @ {current_price}원 - 체결 대기중")
else:
logger.warning(f"❌ 매수 주문 실패: {stock_name} {quantity}주 - {error_msg}")
# 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
# 캐시 완전 초기화 - 다음 조회시 강제로 KIS API 호출
portfolio_cache.holdings = []
portfolio_cache.summary = None
portfolio_cache.last_synced = None
portfolio_cache.last_trade_id = None
portfolio_cache.change_flag = True
logger.info(f"🔄 Portfolio cache CLEARED after BUY {stock_name}({stock_code})")
except Exception as e:
logger.error(f"Portfolio cache clear failed after BUY: {e}")
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)}
else:
logger.debug(f"⚠️ {stock_name}({stock_code}): 매수 수량 0 (가격={current_price:,.0f}원, 예수금={available_cash:,.0f}원)")
return {"action": "SKIP", "reason": "quantity_zero", "stock": stock_name}
# 매도 로직
elif should_sell and holding:
quantity = holding.quantity
profit_loss_rate = float(holding.profit_loss_rate or 0)
# 익절/손절 조건 확인
take_profit = stock.get("take_profit", 3.0) # 3% 익절(권장)
stop_loss = stock.get("stop_loss", -1.5) # -1.5% 손절(권장)
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:
if is_domestic:
order_result = await korea_investment_api.place_order(
stock_code=stock_code,
order_type="SELL",
quantity=quantity,
price=int(current_price) # 지정가 주문 (현재가)
)
else:
order_result = await korea_investment_api.place_overseas_order(
symbol=stock_code,
order_type="SELL",
quantity=quantity,
price=float(current_price), # 해외 주식은 달러 단위
exchange_code=exchange_code
)
# 주문 결과 상세 로깅
logger.info(f"📝 매도 주문 결과: {stock_name}({stock_code}) - rt_cd={order_result.get('rt_cd')}, msg={order_result.get('msg1')}, odno={order_result.get('odno')}")
logger.debug(f"Full order result: {order_result}")
# 중복 거래 방지: 최근 10초 이내 동일 종목 동일 타입 주문 확인
recent_cutoff = _get_kst_now() - timedelta(seconds=10)
duplicate_check = await db.execute(
select(TradeHistory).where(
TradeHistory.stock_code == stock_code,
TradeHistory.order_type == "SELL",
TradeHistory.created_at >= recent_cutoff
)
)
recent_duplicate = duplicate_check.scalar_one_or_none()
if recent_duplicate:
logger.warning(f"⚠️ 중복 매도 방지: {stock_name} - 10초 이내 동일 주문 존재")
return {"action": "SKIP", "reason": "duplicate_order", "stock": stock_name}
# 거래 이력 저장 (상세 정보 포함)
total_amount = Decimal(str(current_price)) * quantity
avg_price = float(holding.average_price or current_price)
profit_loss = (current_price - avg_price) * quantity
# 매도 이유 상세 작성
exit_reasons = []
if profit_loss_rate >= take_profit:
exit_reasons.append(f"익절 달성 ({profit_loss_rate:.2f}% >= {take_profit}%)")
if profit_loss_rate <= stop_loss:
exit_reasons.append(f"손절 실행 ({profit_loss_rate:.2f}% <= {stop_loss}%)")
if should_sell:
exit_reasons.append(f"매도 신호: {', '.join(signals[:2])}")
exit_reason = ". ".join(exit_reasons) + f". 평균단가: {avg_price:,.0f}원, 매도가: {current_price:,.0f}원, 손익: {profit_loss:,.0f}원"
success = order_result.get("rt_cd") == "0"
error_msg = order_result.get("msg1", "") if not success else ""
# 주문 상태 결정
if success:
status = "COMPLETED" # 체결 완료
elif order_result.get("rt_cd") == "1":
status = "PENDING" # 주문 접수됨, 체결 대기
else:
status = "FAILED" # 주문 실패
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)) if success else None,
executed_quantity=quantity if success else 0,
total_amount=total_amount,
status=status,
order_id=order_result.get("odno"),
message=f"자동매도: 손익률 {profit_loss_rate:.2f}% | {' | '.join(signals[:2])} | {error_msg if not success else '체결완료'}",
exit_reason=exit_reason,
signals=signals,
net_profit_loss=Decimal(str(profit_loss)) if success else None,
profit_loss_rate=Decimal(str(profit_loss_rate)) if success else None,
created_at=_get_kst_now(),
executed_at=_get_kst_now() if success else None
)
db.add(trade)
await db.commit()
if success:
logger.info(f"✅ 매도 주문 성공: {stock_name} {quantity}주 @ {current_price}원")
else:
logger.warning(f"❌ 매도 주문 실패: {stock_name} {quantity}주 - {error_msg}")
if success:
# 포트폴리오에서 완전 삭제
await db.delete(holding)
await db.commit()
logger.info(f"🗑️ Deleted {stock_name}({stock_code}) from portfolio DB")
# 포트폴리오 캐시 완전 초기화
try:
from app.api.endpoints.portfolio import portfolio_cache
# 캐시 완전 초기화 - 다음 조회시 강제로 KIS API 호출
portfolio_cache.holdings = []
portfolio_cache.summary = None
portfolio_cache.last_synced = None
portfolio_cache.last_trade_id = None
portfolio_cache.change_flag = True
logger.info(f"🔄 Portfolio cache CLEARED after SELL {stock_name}({stock_code})")
except Exception as e:
logger.error(f"Portfolio cache clear failed after SELL: {e}")
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.info(
f"⏸️ HOLD: {stock_name}({stock_code}) - 매수신호={should_buy}, 매도신호={should_sell}, 보유중={bool(holding)}, 점수={score_val}"
)
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))
@router.get("/ai/{stock_code}")
async def get_ai_recommendation(stock_code: str):
"""MCP 서버를 통한 AI 종목 분석 및 뉴스 조회"""
try:
# 1. 시장 데이터 조회 (Backend에서 직접 KIS API 호출)
price_data = {}
try:
price_data = await korea_investment_api.get_current_price(stock_code)
except Exception as e:
logger.warning(f"Failed to fetch price data locally: {e}")
# 2. MCP 서버에 뉴스 조회 요청
news_data = []
try:
async with httpx.AsyncClient() as client:
mcp_url = f"{settings.MCP_SERVER_URL}/tools/call"
payload = {
"tool_name": "get_news",
"parameters": {"stock_code": stock_code, "limit": 3}
}
response = await client.post(mcp_url, json=payload, timeout=10.0)
if response.status_code == 200:
result = response.json()
if result.get("status") == "success":
# MCP might wrap it in 'result' -> 'news' or just 'news'
data = result.get("result", {})
if "news" in data:
news_data = data["news"]
else:
news_data = data # Fallback if direct list
except Exception as e:
logger.warning(f"Failed to fetch news via MCP: {e}")
# 3. AI 분석 요청 (MCP Analyze)
analysis_result = "AI 분석을 수행할 수 없습니다."
try:
async with httpx.AsyncClient() as client:
analyze_url = f"{settings.MCP_SERVER_URL}/analyze"
# 시장 데이터 단순화
market_summary = {}
if "output" in price_data:
output = price_data["output"]
market_summary = {
"current_price": output.get("stck_prpr"),
"change_rate": output.get("prdy_ctrt"),
"volume": output.get("acml_vol"),
"high": output.get("stck_hgpr"),
"low": output.get("stck_lwpr")
}
payload = {
"analysis_type": "MARKET_ANALYSIS",
"data": {
"stock_code": stock_code,
"market_data": market_summary,
"news": news_data
}
}
response = await client.post(analyze_url, json=payload, timeout=45.0) # Analysis takes time
if response.status_code == 200:
analysis_res = response.json()
# If successful, returns {"analysis_type":..., "result": "Markdown text/JSON"}
if "result" in analysis_res:
analysis_result = analysis_res["result"]
except Exception as e:
logger.error(f"Failed to fetch AI analysis: {e}")
return {
"stock_code": stock_code,
"analysis": analysis_result,
"news": news_data,
"market_data": price_data.get("output", {})
}
except Exception as e:
logger.error(f"AI Recommendation error: {e}")
raise HTTPException(status_code=500, detail=str(e))