퀀트 리서치(공정 유니버스+walk-forward)로 검증된 안전 게이트 2종과 매수 경로 현금 해결 방어 코드 추가. 모두 기존 시그니처 유지(§4). - 지수 서킷브레이커: KODEX200 종가 < MA20이면 신규 스윙 진입 전면 중단 (하락장 상관붕괴=2024H2형 방어). execute_swing_entries가 기존 check_market_regime의 drop_from_ma20을 재사용(추가 API 없음). 백테스트 루프에도 동일 적용. 검증: 최근1년 Sharpe 0.82→1.13, CAGR 7.2→8.6%, MDD 개선. - own_trend 게이트: 평균회귀·눌림목 매수 시 종목 MA20 >= MA60×0.97일 때만 진입(하락추세 물타기 방지). 실서비스는 recommendations.is_mean_reversion_candidate 에서 StockAnalysis.ma20/ma60로, check_buy_signal엔 ma20/ma60 선택 파라미터 (기본 None=하위호환) 추가. 백테스트(kr_backtest_engine, run_backtest)도 평균회귀 한정. - 매수 현금 해결 방어(trade_executor._execute_buy): 0-현금 캐시를 신뢰하지 않고 현금<=0이면 자산과 무관하게 KIS 예수금 재조회, 실현금(>0)일 때만 캐시. 0-현금 고착으로 수량 0이 되는 잠재 무한루프 방어. 검증: code-verifier 통과(CONFIRMED 버그 없음, None-safe, §3/§4/§5 준수), pytest 442 통과, 6파일 컨테이너 컴파일·리로드 정상. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HBmzf1HDnvFHLraJ8eaaKJ
734 lines
29 KiB
Python
734 lines
29 KiB
Python
"""
|
|
추천 종목 및 자동매매 API 엔드포인트
|
|
|
|
- GET /rule-based: 룰 기반 추천 종목 조회 (DB)
|
|
- GET /rule-based/realtime: 실시간 분석 후 DB upsert + 결과 반환
|
|
- POST /execute-trade: 추천 기반 자동매매 즉시 실행 (수동 트리거)
|
|
- 내부 유틸: execute_auto_trade_background() — auto_trade_scheduler 에서 호출
|
|
"""
|
|
from fastapi import APIRouter, HTTPException, Query, Depends
|
|
from typing import List, Optional
|
|
from datetime import datetime, date, timedelta
|
|
import asyncio
|
|
import logging
|
|
from collections import namedtuple
|
|
|
|
from app.core.database import get_db, AsyncSessionLocal
|
|
from app.models.models import StockAnalysis
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy import select
|
|
|
|
from app.core.config import settings
|
|
from app.services.market_analyzer import (
|
|
check_market_regime, get_all_tradable_stocks, analyze_stock,
|
|
analyze_stock_for_batch,
|
|
RecommendationCriteria, StockRecommendation, KST
|
|
)
|
|
from app.services.trade_executor import execute_auto_trade_background, execute_auto_trade
|
|
from app.services.mean_reversion_strategy import MarketRegime
|
|
import pytz
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_KST = pytz.timezone("Asia/Seoul")
|
|
|
|
|
|
def _today_kst() -> date:
|
|
"""KST 기준 오늘 날짜 반환 (UTC 기준 date.today() 대신 항상 이 함수 사용)"""
|
|
return datetime.now(_KST).date()
|
|
|
|
|
|
# 매매 규칙 namedtuple
|
|
Rule = namedtuple('Rule', ['id', 'name', 'description', 'rule_type', 'priority', 'conditions', 'actions', 'is_active'])
|
|
|
|
def _stock_analysis_to_dict(stock, rule=None, actions=None) -> dict:
|
|
"""StockAnalysis ORM 행 → stock_dict 변환 헬퍼 (중복 제거)."""
|
|
base = {
|
|
"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"),
|
|
}
|
|
if rule is not None:
|
|
_a = actions or {}
|
|
base.update({
|
|
"rule_id": rule.id,
|
|
"rule_name": rule.name,
|
|
"rule_description": rule.description,
|
|
"rule_priority": rule.priority,
|
|
"take_profit": _a.get("take_profit", 3.0),
|
|
"stop_loss": _a.get("stop_loss", -1.5),
|
|
"max_add_buys": _a.get("max_add_buys", 3),
|
|
"add_buy_cooldown_min": _a.get("add_buy_cooldown_min", 15),
|
|
})
|
|
else:
|
|
base.update({
|
|
"rule_id": None,
|
|
"rule_name": "고점수 분석 종목",
|
|
"rule_description": "매매 규칙 조건 미충족 - 분석 점수 기준 상위 종목",
|
|
"rule_priority": 99,
|
|
"take_profit": 3.0,
|
|
"stop_loss": -1.5,
|
|
"max_add_buys": 2,
|
|
"add_buy_cooldown_min": 30,
|
|
})
|
|
return base
|
|
|
|
@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, ge=1, le=200, description="분석할 최대 종목 수 (기본 100개, 최대 200개)"),
|
|
):
|
|
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
|
|
|
|
all_stocks = await get_all_tradable_stocks()
|
|
|
|
if not all_stocks:
|
|
return {
|
|
"total": 0,
|
|
"analyzed_stocks": 0,
|
|
"criteria": criteria.model_dump(),
|
|
"recommendations": [],
|
|
"message": "거래 가능한 종목을 조회할 수 없습니다"
|
|
}
|
|
|
|
stocks_to_analyze = all_stocks[:max_stocks]
|
|
logger.info(f"Analyzing {len(stocks_to_analyze)} stocks with criteria: {criteria.model_dump()}")
|
|
|
|
batch_size = 5
|
|
delay_between_batches = 1.2
|
|
all_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]
|
|
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)
|
|
|
|
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 > 5000
|
|
and r.score >= regime_min_score
|
|
]
|
|
recommendations.sort(key=lambda x: x.score, reverse=True)
|
|
|
|
return {
|
|
"total": len(recommendations),
|
|
"analyzed_stocks": len(stocks_to_analyze),
|
|
"criteria": criteria.model_dump(),
|
|
"market_regime": market_regime,
|
|
"recommendations": recommendations[: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)
|
|
):
|
|
if realtime:
|
|
result = await get_rule_based_recommendations_realtime()
|
|
else:
|
|
result = await get_rule_based_recommendations_from_db(db)
|
|
|
|
if auto_trade and result.get("recommendations_by_rule"):
|
|
rec_snapshot = result
|
|
async def _bg_trade():
|
|
try:
|
|
async with AsyncSessionLocal() as bg_db:
|
|
await execute_auto_trade_background(rec_snapshot, bg_db)
|
|
except Exception as e:
|
|
logger.error(f"[_bg_trade] 백그라운드 자동매매 실행 중 예외: {e}", exc_info=True)
|
|
asyncio.create_task(_bg_trade())
|
|
|
|
return result
|
|
|
|
async def _load_active_rules_from_db(db: AsyncSession) -> list:
|
|
from app.models.models import TradingRule as TradingRuleModel
|
|
try:
|
|
result = await db.execute(
|
|
select(TradingRuleModel)
|
|
.where(TradingRuleModel.is_active == True)
|
|
.order_by(TradingRuleModel.priority.desc())
|
|
)
|
|
db_rules = result.scalars().all()
|
|
if db_rules:
|
|
return [
|
|
Rule(id=r.id, name=r.name, description=r.description or "",
|
|
rule_type=r.rule_type, priority=r.priority,
|
|
conditions=r.conditions, actions=r.actions, is_active=True)
|
|
for r in db_rules
|
|
]
|
|
except Exception as e:
|
|
logger.warning(f"trading_rules DB 조회 실패: {e}")
|
|
|
|
try:
|
|
import json as _json
|
|
from pathlib import Path as _Path
|
|
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 = [r for r in config.get("rules", []) if r.get("is_active")]
|
|
if rules_data:
|
|
return [
|
|
Rule(id=i + 1, name=r["name"], description=r.get("description", ""),
|
|
rule_type=r["rule_type"].upper(), priority=r.get("priority", i + 1),
|
|
conditions=r.get("conditions", {}), actions=r.get("actions", {}),
|
|
is_active=True)
|
|
for i, r in enumerate(rules_data)
|
|
]
|
|
except Exception as e:
|
|
logger.warning(f"JSON 규칙 파일 로드 실패: {e}")
|
|
|
|
return [
|
|
Rule(
|
|
id=1, name="RSI+볼린저 평균회귀", description="기본 평균회귀 규칙",
|
|
rule_type="MEAN_REVERSION", priority=1,
|
|
conditions={"rsi_bull_max": 40, "rsi_bear_max": 30,
|
|
"bb_lower_pct": 3.0, "bb_upper_pct": 1.5},
|
|
actions={"take_profit": 3.0, "stop_loss": -1.5,
|
|
"position_sizing": {"target_ratio": 0.10},
|
|
"max_add_buys": 2, "add_buy_cooldown_min": 30},
|
|
is_active=True
|
|
)
|
|
]
|
|
|
|
def is_mean_reversion_candidate(stock, conditions: dict, market_regime_str: str) -> bool:
|
|
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
|
|
|
|
if close < settings.MIN_PRICE_KRW:
|
|
return False
|
|
|
|
# [안전형 개선①] own_trend 게이트 (검증 완료: Sharpe 0.27→0.57)
|
|
# 종목 자체 MA20 < MA60*0.97 (하락추세)이면 평균회귀 매수(물타기) 차단.
|
|
# ma20/ma60 미조회(None)인 경우 게이트 미적용 — 기존 동작 보존.
|
|
ma20_val = float(stock.ma20) if getattr(stock, "ma20", None) else None
|
|
ma60_val = float(stock.ma60) if getattr(stock, "ma60", None) else None
|
|
if ma20_val is not None and ma60_val is not None and ma60_val > 0 and ma20_val < ma60_val * 0.97:
|
|
return False
|
|
|
|
regime = MarketRegime.BULL if market_regime_str == "bull" else MarketRegime.BEAR
|
|
adx_val = float(stock.adx_14) if getattr(stock, "adx_14", None) else None
|
|
# ADX는 0~100 범위. 100 초과 시 계산 오류이므로 무시
|
|
if adx_val is not None and adx_val > 100:
|
|
adx_val = None
|
|
adx_strong_trend = adx_val is not None and adx_val > 30
|
|
|
|
# %B 계산: 밴드 내 상대 위치 (0=하단, 1=상단)
|
|
bb_width = bb_upper - bb_lower
|
|
pct_b = (close - bb_lower) / bb_width if bb_width > 0 else 0.5
|
|
|
|
if regime == MarketRegime.BULL:
|
|
rsi_threshold = conditions.get("rsi_bull_max", 40)
|
|
if adx_strong_trend:
|
|
return False
|
|
if rsi_val > rsi_threshold:
|
|
return False
|
|
# RSI 깊이에 따른 동적 %B 임계값
|
|
if rsi_val < 25:
|
|
pct_b_max = 0.35 # 깊은 과매도 → BB 조건 완화
|
|
elif rsi_val < 30:
|
|
pct_b_max = 0.30
|
|
else:
|
|
pct_b_max = 0.20 # RSI 30~40 → BB 하단 근처 요구
|
|
return pct_b <= pct_b_max
|
|
else:
|
|
rsi_threshold = conditions.get("rsi_bear_max", 30)
|
|
if adx_strong_trend:
|
|
return False
|
|
if rsi_val > rsi_threshold:
|
|
return False
|
|
# BEAR: RSI 깊이에 따른 동적 %B (BULL보다 엄격)
|
|
if rsi_val < 20:
|
|
pct_b_max = 0.30
|
|
elif rsi_val < 25:
|
|
pct_b_max = 0.25
|
|
else:
|
|
pct_b_max = 0.20
|
|
return pct_b <= pct_b_max
|
|
|
|
async def get_rule_based_recommendations_from_db(db: AsyncSession):
|
|
active_rules = await _load_active_rules_from_db(db)
|
|
|
|
# KST 기준 오늘만 조회 — timedelta(days=1) 제거로 어제 데이터 누적 표시 방지
|
|
today_kst = _today_kst()
|
|
result = await db.execute(
|
|
select(StockAnalysis)
|
|
.where(StockAnalysis.analysis_date == today_kst)
|
|
.order_by(StockAnalysis.score.desc())
|
|
)
|
|
raw_stocks = result.scalars().all()
|
|
seen = set()
|
|
all_analyzed_stocks = []
|
|
for s in raw_stocks:
|
|
if s.stock_code not in seen:
|
|
# 전일 거래량 필터: 20만주 미만 종목 제외 (유동성 확보)
|
|
if s.volume is not None and s.volume < 200_000:
|
|
continue
|
|
seen.add(s.stock_code)
|
|
all_analyzed_stocks.append(s)
|
|
|
|
if not all_analyzed_stocks or len(all_analyzed_stocks) < 1:
|
|
return {
|
|
"total": 0,
|
|
"active_rules": len(active_rules),
|
|
"analyzed_stocks": 0,
|
|
"analyzed_stock_list": [],
|
|
"recommendations": [],
|
|
"recommendations_by_rule": [],
|
|
"rules_applied": [],
|
|
"message": "⚠️ 배치 분석 데이터 없음. 'make batch-test' 명령어로 분석 실행 필요."
|
|
}
|
|
|
|
rule_matches = []
|
|
recommendations_by_rule = []
|
|
stock_to_rules = {}
|
|
|
|
market_regime_info = await check_market_regime()
|
|
current_market_regime = market_regime_info.get("regime_200d", "bear")
|
|
|
|
for rule in active_rules:
|
|
conditions = rule.conditions
|
|
actions = rule.actions
|
|
rule_matched_stocks = []
|
|
|
|
for stock in all_analyzed_stocks:
|
|
matches = True
|
|
if rule.rule_type == "MEAN_REVERSION":
|
|
matches = is_mean_reversion_candidate(stock, conditions, current_market_regime)
|
|
else:
|
|
matches = False
|
|
|
|
if not matches:
|
|
continue
|
|
|
|
rule_matched_stocks.append(stock)
|
|
|
|
rule_matched_stocks.sort(key=lambda x: x.score or 0, reverse=True)
|
|
|
|
rule_stocks = []
|
|
for stock in rule_matched_stocks[:10]:
|
|
stock_dict = _stock_analysis_to_dict(stock, rule=rule, actions=actions)
|
|
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,
|
|
})
|
|
|
|
if not rule_matches and all_analyzed_stocks:
|
|
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_analysis_to_dict(stock)
|
|
fallback_stocks.append(stock_dict)
|
|
rule_matches.append(stock_dict)
|
|
|
|
if fallback_stocks:
|
|
recommendations_by_rule.append({
|
|
"rule_id": None,
|
|
"rule_name": "고점수 분석 종목",
|
|
"rule_description": "현재 매매 규칙 조건 미충족 - 분석 점수 기준 상위 종목",
|
|
"rule_type": "ANALYSIS",
|
|
"priority": 99,
|
|
"matched_count": len(fallback_stocks),
|
|
"stocks": fallback_stocks,
|
|
})
|
|
|
|
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],
|
|
"batch_time": (lambda d: d.date() if isinstance(d, datetime) else d)(
|
|
max((s.analysis_date for s in all_analyzed_stocks), default=_today_kst())
|
|
).isoformat(),
|
|
}
|
|
|
|
async def get_rule_based_recommendations_realtime():
|
|
async with AsyncSessionLocal() as _db:
|
|
active_rules = await _load_active_rules_from_db(_db)
|
|
|
|
if not active_rules:
|
|
criteria = RecommendationCriteria(
|
|
use_rsi=True,
|
|
use_moving_average=True
|
|
)
|
|
all_stocks = await get_all_tradable_stocks()
|
|
stocks_to_analyze = all_stocks[: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": None,
|
|
"rule_name": "기본 추천",
|
|
"rule_description": "RSI + 이동평균 기반 추천",
|
|
"rule_priority": 1,
|
|
"position_ratio": 0.15,
|
|
"take_profit": 5.0,
|
|
"stop_loss": -6.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": None,
|
|
"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": "기본 지표로 분석되었습니다 (규칙 미설정)"
|
|
}
|
|
|
|
all_stocks = await get_all_tradable_stocks()
|
|
stocks_to_analyze = all_stocks[:settings.ANALYSIS_STOCK_COUNT]
|
|
|
|
try:
|
|
from app.models.models import Portfolio as _PortfolioModel
|
|
async with AsyncSessionLocal() as _pf_db:
|
|
_pf_result = await _pf_db.execute(
|
|
select(_PortfolioModel).where(_PortfolioModel.quantity > 0)
|
|
)
|
|
_pf_stocks = _pf_result.scalars().all()
|
|
existing_codes = {code for code, _ in stocks_to_analyze}
|
|
for _pf in _pf_stocks:
|
|
if _pf.stock_code not in existing_codes:
|
|
stocks_to_analyze = list(stocks_to_analyze)
|
|
stocks_to_analyze.append((_pf.stock_code, _pf.stock_name or _pf.stock_code))
|
|
existing_codes.add(_pf.stock_code)
|
|
except Exception as _e:
|
|
logger.warning(f"포트폴리오 종목 추가 실패 (non-critical): {_e}")
|
|
|
|
criteria = RecommendationCriteria(
|
|
use_rsi=True,
|
|
use_moving_average=True,
|
|
use_macd=True,
|
|
use_bollinger=True,
|
|
use_stochastic=True,
|
|
for_batch=True,
|
|
min_volume_ratio=0, # intraday acml_vol << avg_vol_20 → 장중엔 항상 0으로 고정
|
|
)
|
|
|
|
batch_size = 5 # 네이버 일봉 사용으로 병렬 처리 가능
|
|
delay_between_batches = 0.3 # 네이버 서버 부하 방지 최소 딜레이
|
|
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)
|
|
|
|
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)
|
|
]
|
|
|
|
market_regime_info = await check_market_regime()
|
|
current_market_regime_str = market_regime_info.get("regime_200d", "bear")
|
|
|
|
rule_matches = []
|
|
recommendations_by_rule = []
|
|
|
|
for rule in active_rules:
|
|
conditions = rule.conditions
|
|
actions = rule.actions
|
|
rule_matched_stocks = []
|
|
|
|
for stock in valid_analyzed_stocks:
|
|
etf_keywords = ["ETF", "etf", "레버리지", "인버스", "KODEX", "선물"]
|
|
if any(k in stock.stock_name for k in etf_keywords):
|
|
continue
|
|
|
|
if rule.rule_type == "MEAN_REVERSION":
|
|
matches = is_mean_reversion_candidate(stock, conditions, current_market_regime_str)
|
|
elif rule.rule_type in ("TREND", "BREAKOUT", "MOMENTUM"):
|
|
matches = False
|
|
else:
|
|
rsi_threshold = conditions.get("rsi_threshold", 40)
|
|
matches = (stock.rsi is not None and stock.rsi <= rsi_threshold)
|
|
|
|
if matches:
|
|
rule_matched_stocks.append(stock)
|
|
|
|
rule_matched_stocks.sort(key=lambda x: x.score or 0, reverse=True)
|
|
|
|
rule_stocks = []
|
|
for rec in rule_matched_stocks[: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
|
|
})
|
|
|
|
rule_matches.sort(key=lambda x: (x["rule_priority"], -x["score"]))
|
|
|
|
# 현재가는 DB 분석 데이터 그대로 사용 (KIS API 미호출)
|
|
top_recommendations = rule_matches[:20]
|
|
|
|
# KST 기준 오늘 날짜 사용 (UTC 기준 date.today() 사용 시 KST와 날짜 불일치 → UPSERT 대신 INSERT 반복 버그 수정)
|
|
# db_worker 큐 경유 — 실시간 분석 경로에서 AsyncSessionLocal 직접 사용 금지 (CLAUDE.md 3.2)
|
|
from app.services.analysis_db_service import queue_bulk_upsert_analysis as _queue_bulk
|
|
_today = _today_kst()
|
|
_items = [(rec.stock_code, rec.stock_name, rec) for rec in valid_analyzed_stocks]
|
|
await _queue_bulk(_items, _today)
|
|
|
|
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]
|
|
}
|
|
|
|
@router.post("/auto-trade")
|
|
async def execute_auto_trading(db: AsyncSession = Depends(get_db)):
|
|
try:
|
|
recommendations_response = await get_rule_based_recommendations(realtime=True, auto_trade=False)
|
|
if not recommendations_response.get("recommendations_by_rule"):
|
|
return {"message": "추천 종목이 없습니다", "trades": []}
|
|
|
|
trade_results = []
|
|
total_processed = 0
|
|
max_trades = 5
|
|
|
|
# make sure we iterate a list of rule group dicts
|
|
rec_groups = recommendations_response.get("recommendations_by_rule", [])
|
|
if isinstance(rec_groups, dict):
|
|
normalized = []
|
|
for name, grp in rec_groups.items():
|
|
if isinstance(grp, dict):
|
|
normalized.append(grp)
|
|
else:
|
|
normalized.append({"rule_name": name, "stocks": grp})
|
|
rec_groups = normalized
|
|
|
|
for rule_group in rec_groups:
|
|
if total_processed >= max_trades:
|
|
break
|
|
|
|
for stock in rule_group.get("stocks", [])[:settings.AUTO_TRADE_TOP_N_PER_RULE]:
|
|
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)
|
|
|
|
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):
|
|
"""종목 기술적 분석 — AI 미사용, 전략 엔진(RSI/BB/MA/ATR/ADX) 직접 계산."""
|
|
from app.services.market_analyzer import analyze_stock_for_batch
|
|
try:
|
|
rec = await analyze_stock_for_batch(stock_code, stock_code)
|
|
if rec is None:
|
|
raise HTTPException(status_code=404, detail="분석 데이터를 가져올 수 없습니다.")
|
|
|
|
signals = rec.signals or []
|
|
score = rec.score or 0
|
|
|
|
# 전략 신호 기반 요약 문자열 생성
|
|
if score >= 3:
|
|
summary = f"매수 신호 감지 (점수: {score}). 주요 신호: {', '.join(signals[:3])}"
|
|
elif score <= -2:
|
|
summary = f"매도/경고 신호 (점수: {score}). 주요 신호: {', '.join(signals[:3])}"
|
|
else:
|
|
summary = f"보유/관망 (점수: {score}). 신호: {', '.join(signals[:3]) if signals else '없음'}"
|
|
|
|
return {
|
|
"stock_code": stock_code,
|
|
"analysis": summary,
|
|
"signals": signals,
|
|
"score": score,
|
|
"market_data": {
|
|
"current_price": rec.current_price,
|
|
"change_rate": rec.change_rate,
|
|
"volume": rec.volume,
|
|
"rsi": rec.rsi,
|
|
"ma5": rec.ma5,
|
|
"ma20": rec.ma20,
|
|
"ma60": rec.ma60,
|
|
"bollinger_upper": rec.bollinger_upper,
|
|
"bollinger_lower": rec.bollinger_lower,
|
|
"bollinger_middle": rec.bollinger_middle,
|
|
"atr_14": rec.atr_14,
|
|
"adx_14": rec.adx_14,
|
|
"stochastic_k": rec.stochastic_k,
|
|
"stochastic_d": rec.stochastic_d,
|
|
}
|
|
}
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"Strategy analysis error for {stock_code}: {e}")
|
|
raise HTTPException(status_code=500, detail=str(e))
|