1379 lines
55 KiB
Python
1379 lines
55 KiB
Python
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))
|