Files
auto-stock/backend/app/api/endpoints/recommendations.py
shuim 9fab4018d4 fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소.

주문·리스크
- MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문)
- KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류)
- 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백
- 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락
- 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드)
- 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단
데이트레이딩
- 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한
- 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정
스윙
- 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT)
- 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed
- 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일
- 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON
인프라·보안
- 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹
- KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환
- db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시
- KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패
하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준
CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신

backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:01:31 +09:00

784 lines
33 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
추천 종목 및 자동매매 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
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)
# 대시보드 폴링(GET)이 매매를 트리거하면 스윙 스케줄러와 동시 실행되어 일일 한도가 무력화됨
# (2026-09-17). 기본적으로 조회 전용이며, API_TRIGGERED_AUTO_TRADE=true 일 때만 실행.
if auto_trade and settings.API_TRIGGERED_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 _mean_reversion_rsi_thresholds_v2(conditions: dict) -> tuple:
"""추천 스캐너 평균회귀 RSI 임계 (bull, bear, 출처).
[C1 2026-10-02] 플래그 `MEAN_REVERSION_CANONICAL_PARAMS_ENABLED`
- False(기본, 현행 동작 불변): trading_rules conditions 의
`rsi_bull_max`/`rsi_bear_max` — DB `trading_rules` → `trading_rules_config.json`
→ `_load_active_rules_from_db` 하드코딩 폴백 순. 키가 없으면 40/30.
⚠️ 즉 CLAUDE.md §6.2 의 46/35 와 다르다(§0 우선순위가 이 경로에는 적용되지 않음).
- True: CLAUDE.md §0 우선순위 — adaptive(`rsi_entry`/`bear_rsi_entry`, fallback_used=False 일
때) → strategy_cfg(`MEAN_REVERSION_RSI_THRESHOLD`/`_BEAR_RSI_THRESHOLD`) → settings.
mean_reversion_strategy.check_buy_signal 과 같은 출처를 쓴다. 비교식(rsi > 임계 → 탈락,
즉 `rsi <= 임계` 통과)·RSI 계산 방식·나머지 필터는 바꾸지 않는다.
"""
if not settings.MEAN_REVERSION_CANONICAL_PARAMS_ENABLED:
return conditions.get("rsi_bull_max", 40), conditions.get("rsi_bear_max", 30), "trading_rules"
bull = float(settings.MEAN_REVERSION_RSI_THRESHOLD)
bear = float(settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD)
source = "settings"
try:
from app.services.strategy_config_service import strategy_cfg
_b = strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD")
_r = strategy_cfg.get("MEAN_REVERSION_BEAR_RSI_THRESHOLD")
if _b is not None:
bull, source = float(_b), "strategy_cfg"
if _r is not None:
bear, source = float(_r), "strategy_cfg"
except Exception as e:
logger.warning(f"[MR_SCAN] strategy_cfg RSI 임계 조회 실패 — settings 값 사용: {e}")
try:
from app.services.adaptive_strategy import get_current_adaptive_params
_adp = get_current_adaptive_params()
if not _adp.fallback_used:
bull, bear, source = float(_adp.rsi_entry), float(_adp.bear_rsi_entry), "adaptive"
except Exception as e:
logger.warning(f"[MR_SCAN] 적응형 RSI 임계 조회 실패 — {source} 값 사용: {e}")
return bull, bear, source
def is_mean_reversion_candidate(stock, conditions: dict, market_regime_str: str) -> bool:
"""추천 스캐너의 평균회귀 후보 판정 — **실제 스윙 평균회귀 진입의 1차 게이트**.
현재 실제 진입 기준 (2026-10-02 기준, 이 함수 기준):
- 공통: RSI·종가·BB(상/중/하) 모두 존재, 종가 ≥ MIN_PRICE_KRW,
own_trend 게이트(MA20 < MA60×0.97 이면 탈락, MA 없으면 미적용),
ADX(14) > 30 이면 탈락(강추세 — 100 초과는 계산오류로 무시)
- RSI 는 StockAnalysis.rsi(배치 산출값)를 그대로 쓴다. 거래량 하한·RSI 반등 조건은
**이 함수에 없다**(후속 execute_auto_trade 경로의 필터에 의존).
- BULL: RSI ≤ bull 임계, %B ≤ 0.35(RSI<25) / 0.30(RSI<30) / 0.20(그 외)
- BEAR: RSI ≤ bear 임계, %B ≤ 0.30(RSI<20) / 0.25(RSI<25) / 0.20(그 외)
- 임계 출처는 `_mean_reversion_rsi_thresholds_v2` 참고(기본: trading_rules 40/30).
"""
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
_bull_thr, _bear_thr, _thr_src = _mean_reversion_rsi_thresholds_v2(conditions)
if regime == MarketRegime.BULL:
rsi_threshold = _bull_thr
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 = _bear_thr
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)):
"""추천 기반 자동매매 수동 트리거.
[H-3 2026-10-02] 기존에는 종목별 `execute_auto_trade()` 를 직접 호출해
시장 국면(SIDEWAYS/CRASH → max_trades=0)·position_multiplier·일일 매수 한도·
`_auto_trade_bg_lock`(스윙 스케줄러와의 동시 실행 방지)·`AUTO_TRADE_ENABLED` 를
전부 우회했다. 이제 스케줄러와 **같은 진입점** `execute_auto_trade_background()` 로
위임해 동일 락·게이트를 탄다. 게이트 판정은 try 밖(§4).
"""
if not settings.AUTO_TRADE_ENABLED:
raise HTTPException(status_code=423, detail="AUTO_TRADE_ENABLED=False — 자동매매 비활성 상태")
if settings.TRADING_HALTED:
raise HTTPException(status_code=423, detail="TRADING_HALTED — 전체 매수 중단 상태")
from app.services.trade_executor import _auto_trade_bg_lock
if _auto_trade_bg_lock.locked():
raise HTTPException(
status_code=409,
detail="자동매매가 이미 실행 중입니다(스윙 스케줄러 등) — 중복 실행 방지",
)
try:
recommendations_response = await get_rule_based_recommendations(
realtime=True, auto_trade=False, db=db
)
except Exception as e:
logger.error(f"❌ 자동 매매 추천 조회 오류: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
if not recommendations_response.get("recommendations_by_rule"):
return {"message": "추천 종목이 없습니다", "trades": []}
try:
results = await execute_auto_trade_background(recommendations_response, db)
except Exception as e:
logger.error(f"❌ 자동 매매 실행 오류: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
results = results if isinstance(results, list) else []
# 반환 리스트에는 SELL·실패건도 섞인다 — 진입 건수는 BUY & success 로 센다(CLAUDE.md §6.4)
trades = [r for r in results if isinstance(r, dict) and r.get("action") in ("BUY", "SELL")]
buys = sum(1 for r in trades if r.get("action") == "BUY" and r.get("success"))
return {
"message": f"자동 매매 완료: 진입 {buys}건 / 처리 {len(results)}건",
"trades": trades,
"total_analyzed": len(results),
}
@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))