""" 추천 종목 및 자동매매 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 re 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() def _row_price_date_v2(stock) -> tuple: """DB 행의 (price_date 'YYYYMMDD', stale 여부). stock_analysis 에는 price_date 컬럼이 없다. 장전(09:00 이전) 갱신 행은 차트 마지막 봉이 전일이라 analysis_date 를 가격일로 쓰면 거짓이다. 갱신 시각(KST naive)이 analysis_date 당일 09:00 이전이면 가격일 미상("")+stale=True. """ ad = stock.analysis_date if isinstance(ad, datetime): ad = ad.date() ts = getattr(stock, "updated_at", None) if isinstance(ts, datetime) and ts < datetime(ad.year, ad.month, ad.day, 9, 0): return "", True return ad.strftime("%Y%m%d"), False def _persistable_items_v2(items, today: date) -> list: """분석 결과 중 DB(analysis_date=오늘)에 저장해도 되는 것만 남긴다. price_date 가 오늘이 아닌(장전 전일 봉 등) 결과를 오늘 날짜로 UPSERT 하면 소비처 (analysis_date >= today 후보 쿼리)가 전일 데이터를 오늘 것으로 읽는다. 가격일 미상("")은 유지. """ ts = today.strftime("%Y%m%d") out = [] for it in items: pd_ = str(getattr(it[2], "price_date", "") or "") if pd_ and pd_ != ts: continue out.append(it) return out # ── 스캐너 표시용 매수 차단 판정 (2026-10-08) ──────────────────────────────── # 신호 문자열에 이미 "…(거래량부족-차단)", "이격도 과열… - 매수금지", "급등과열: …" 처럼 # 차단 사유가 섞여 있는데 화면은 이를 구분하지 못해 '추천'과 '차단'이 같은 줄에 표시됐다. _BLOCK_MARKERS = ("차단", "매수금지", "급등과열", "이격도 과열") def _block_reasons_v2(signals) -> list: out = [] for sig in (signals or []): text = str(sig) if any(m in text for m in _BLOCK_MARKERS): out.append(text) return out _VOL_RATIO_SIGNAL_RE = re.compile(r"거래량 실린 상승 \(([0-9]+(?:\.[0-9]+)?)배\)") def _stock_volume_ratio_v2(stock): """종목의 거래량 배수(20일 평균 대비). 없으면 None. StockRecommendation 에는 volume_ratio 필드가 없으므로(market_analyzer 소유), 1) 속성이 있으면 사용, 2) 없으면 market_analyzer 가 남기는 신호 문자열 '거래량 실린 상승 (N.N배)' 에서 복원한다. 이 신호는 배수>=1.5 & 상승일 때만 생성되므로 복원 불가면 None(= 배수 미확인, 호출부 fail-closed). 장중 부분거래량은 배수를 낮게 만들 뿐이라 보수적 방향으로만 틀린다. """ ratio = getattr(stock, "volume_ratio", None) if ratio is not None: return ratio for sig in (getattr(stock, "signals", None) or []): m = _VOL_RATIO_SIGNAL_RE.search(str(sig)) if m: return float(m.group(1)) return None def _generic_rule_matches_v2(rule, stock, conditions: dict) -> bool: """realtime 빌더 else 분기(비-MEAN_REVERSION 규칙)의 후보 판정. [하드닝 R1] 이전에는 rule_type 을 가리지 않아 SELL 규칙까지 rule_id 가 붙은 매수 후보가 되었고, trade_executor 의 has_rule 강제매수가 '거래량부족-차단' 등 차단 신호를 덮어썼다. - SELL(및 EXIT/STOP 계열) 규칙은 매수 후보에서 제외 - 차단 신호(_block_reasons_v2)가 있는 종목은 규칙 후보에서 제외 RSI 임계(기본 40)는 기존 값을 유지한다(변경은 백테스트 필요). """ if str(getattr(rule, "rule_type", "") or "").upper() in ("SELL", "EXIT", "STOP_LOSS", "TAKE_PROFIT"): return False if _block_reasons_v2(getattr(stock, "signals", None)): return False conditions = conditions or {} has_rsi_key = ("rsi_below" in conditions) or ("rsi_threshold" in conditions) has_vol_key = "volume_above_average" in conditions if conditions and not (has_rsi_key or has_vol_key): # 해석 가능한 조건 키가 하나도 없으면 규칙 의도를 알 수 없다 -> fail-closed return False if stock.rsi is None: return False # rsi_below(엄격 미만) / rsi_threshold(이하, 기존 의미). 둘 다 있으면 더 엄격한 쪽. if has_rsi_key: try: if "rsi_below" in conditions and not (stock.rsi < float(conditions["rsi_below"])): return False if "rsi_threshold" in conditions and not (stock.rsi <= float(conditions["rsi_threshold"])): return False except (TypeError, ValueError): return False elif not (stock.rsi <= 40): # RSI 키가 없는 조건의 기존 기본 동작 유지 return False if has_vol_key: # 거래량 배수를 알 수 없으면(필드 미노출) 통과시키지 않는다(fail-closed). ratio = _stock_volume_ratio_v2(stock) try: if ratio is None or not (float(ratio) >= float(conditions["volume_above_average"])): return False except (TypeError, ValueError): return False return True # trade_executor 의 'ML HOLD(conf≥0.6) → 매수 차단' 게이트와 같은 신뢰도 기준(리터럴 단일화). _ML_HOLD_BLOCK_CONF_V2 = 0.6 def _ml_block_reasons_v2(ml_signal, ml_confidence) -> list: """trade_executor 실제 매수 ML 게이트와 같은 정의의 ML 차단 사유. - ML HOLD & 신뢰도 ≥ 0.6 & ML_ENSEMBLE_ENABLED → 매수 차단('ML HOLD(conf≥0.6)') - ML SELL 은 ML_SELL_SIGNAL_ENABLED=True 일 때만 차단 사유(False 면 trade_executor 가 매수를 막지 않으므로 사유에 넣지 않는다 — 참고 정보는 ml_signal 필드로 노출). """ sig = str(ml_signal or "").upper() reasons = [] try: conf = float(ml_confidence or 0) except (TypeError, ValueError): conf = 0.0 if sig == "HOLD" and settings.ML_ENSEMBLE_ENABLED and conf >= _ML_HOLD_BLOCK_CONF_V2: reasons.append(f"ML HOLD(conf≥{_ML_HOLD_BLOCK_CONF_V2:g})") if sig == "SELL" and settings.ML_SELL_SIGNAL_ENABLED: reasons.append("ML 신호 SELL") return reasons def _scanner_flags_v2(stock, rule=None) -> dict: """스캐너 항목의 규칙 출처·차단 사유·buy_allowed·ML 판정. 기존 필드는 건드리지 않는다. buy_allowed = 신호 차단 사유 없음 AND ML 게이트 비차단(`_ml_block_reasons_v2`, trade_executor 와 동일 정의). (MDD·일일한도 등 실행 시점 게이트는 여기서 판정하지 않는다 — 표시용 1차 판정) """ reasons = _block_reasons_v2(getattr(stock, "signals", None)) ml_reasons = _ml_block_reasons_v2(getattr(stock, "ml_signal", None), getattr(stock, "ml_confidence", None)) reasons = reasons + ml_reasons matched = rule is not None return { "match_source": "rule" if matched else "fallback_high_score", "match_source_label": "활성 규칙 매칭" if matched else "고점수 분석 종목(규칙 미충족 대체)", "block_reasons": reasons, "buy_allowed": not reasons, "ml_blocks_buy": bool(ml_reasons), } def _batch_time_iso_v2(stocks) -> str: """배치 실제 스캔 시각 — KST 타임존 포함 ISO(예: 2026-10-08T09:10:03+09:00). DB updated_at/created_at 은 KST naive(core.timeutil). 시각이 없으면 분석일 00:00 KST. (이전: 날짜만 내려가 브라우저가 UTC 자정 → KST 09:00 으로 표시) """ latest = None for st in stocks: ts = getattr(st, "updated_at", None) or getattr(st, "created_at", None) if isinstance(ts, datetime) and (latest is None or ts > latest): latest = ts if latest is None: d = max((st.analysis_date for st in stocks), default=_today_kst()) if isinstance(d, datetime): d = d.date() latest = datetime(d.year, d.month, d.day) if latest.tzinfo is None: latest = _KST.localize(latest) else: latest = latest.astimezone(_KST) return latest.isoformat() async def _overlay_realtime_v2(result: dict) -> dict: """Redis 실시간 틱(tick:{code}, TTL 10s)이 있으면 현재가와 등락률을 **같은 소스**로 덮어쓴다. 배치 값(change_rate)은 배치 시점 기준이라 실시간 가격과 섞이면 서로 어긋난다. 원본 result 는 변경하지 않는다(자동매매 스냅샷과 공유될 수 있음) — 항목 dict 를 복사한다. 틱이 없는 종목은 배치 값 유지 + price_source="batch". """ from app.core.redis import get_json lists = [result.get("recommendations") or [], result.get("analyzed_stock_list") or []] for g in result.get("recommendations_by_rule") or []: lists.append(g.get("stocks") or []) codes = sorted({it["stock_code"] for lst in lists for it in lst if it.get("stock_code")})[:400] if not codes: return result ticks = await asyncio.gather(*[get_json(f"tick:{c}") for c in codes], return_exceptions=True) tick_by = { c: t for c, t in zip(codes, ticks) if isinstance(t, dict) and (t.get("price") or 0) > 0 and t.get("change_rate") is not None } def _apply(item: dict) -> dict: new = dict(item) t = tick_by.get(item.get("stock_code")) if t: new["batch_current_price"] = item.get("current_price") new["batch_change_rate"] = item.get("change_rate") new["current_price"] = int(t["price"]) new["change_rate"] = round(float(t["change_rate"]), 2) new["price_source"] = "realtime" else: new["price_source"] = "batch" return new out = dict(result) out["recommendations"] = [_apply(i) for i in lists[0]] out["analyzed_stock_list"] = [_apply(i) for i in lists[1]] out["recommendations_by_rule"] = [ {**g, "stocks": [_apply(i) for i in (g.get("stocks") or [])]} for g in (result.get("recommendations_by_rule") or []) ] out["realtime_overlay_count"] = len(tick_by) return out # 매매 규칙 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": _row_price_date_v2(stock)[0], "price_stale": _row_price_date_v2(stock)[1], "price_source": "batch", **_scanner_flags_v2(stock, rule), } 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] } # 이벤트 루프는 태스크를 약참조로만 보관 — 주문 경로가 중간 GC 되지 않도록 강참조 보관 _bg_trade_tasks: set = set() def _spawn_bg_trade_v2(coro) -> "asyncio.Task": task = asyncio.create_task(coro) _bg_trade_tasks.add(task) task.add_done_callback(_bg_trade_tasks.discard) return task @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) _spawn_bg_trade_v2(_bg_trade()) # 표시용 실시간 보정(현재가+등락률 동일 소스). 매매 스냅샷(rec_snapshot)은 원본 유지. if not realtime: try: return await _overlay_realtime_v2(result) except Exception as e: logger.warning(f"[rule-based] 실시간 오버레이 실패 — 배치 값 사용: {e}") return result def _merge_json_mean_reversion_rule_v2(rules: list) -> list: """DB 활성 규칙에 MEAN_REVERSION 이 없으면 trading_rules_config.json 의 MR 규칙을 병합한다. 운영 DB 에는 BUY/SELL/SELL 3개뿐이라 `is_mean_reversion_candidate`(own_trend·ADX·%B 게이트)가 한 번도 호출되지 않았다. 진입 집합이 크게 바뀌므로 플래그 `MEAN_REVERSION_RULE_MERGE_ENABLED`(기본 False, §8.4 백테스트 후 켤 것)로 제어한다. 플래그/파일 처리는 병합 자체에만 영향 — 읽기 실패는 warning 후 DB 규칙 그대로 반환. """ if not getattr(settings, "MEAN_REVERSION_RULE_MERGE_ENABLED", False): return rules if any(str(getattr(r, "rule_type", "") or "").upper() == "MEAN_REVERSION" for r in rules): return rules 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) mr = [r for r in config.get("rules", []) if r.get("is_active") and str(r.get("rule_type", "")).upper() == "MEAN_REVERSION"] except Exception as e: logger.warning(f"JSON MEAN_REVERSION 규칙 병합 실패 — DB 규칙만 사용: {e}") return rules if not mr: return rules r = mr[0] next_id = max([int(x.id) for x in rules if x.id is not None] + [0]) + 1 return rules + [Rule( id=next_id, name=r["name"], description=r.get("description", ""), rule_type="MEAN_REVERSION", priority=r.get("priority", 1), conditions=r.get("conditions", {}), actions=r.get("actions", {}), is_active=True, )] 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: rules = [ 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 ] return _merge_json_mean_reversion_rule_v2(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: # CLAUDE.md §5.1 '>5,000원' (정확히 5,000원 제외) 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) # 규칙 미충족 대체 목록은 매수 후보가 아니다: MR 게이트(own_trend·ADX·%B·RSI)를 통과한 # 종목만 매수 가능, 나머지는 청산 판정 전용(_exit_only — trade_executor 가 should_buy 에서 SKIP). # 게이트 판정 예외는 fail-closed(청산 전용). try: _mr_ok = bool(is_mean_reversion_candidate(stock, {}, current_market_regime)) except Exception as e: logger.warning(f"[rule-based] 대체 목록 MR 게이트 판정 실패 — 청산 전용 처리 {stock.stock_code}: {e}") _mr_ok = False stock_dict["_exit_only"] = not _mr_ok stock_dict["buy_allowed"] = bool(stock_dict.get("buy_allowed")) and _mr_ok 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, "ml_signal": s.ml_signal, "ml_confidence": round(float(s.ml_confidence), 4) if s.ml_confidence else None, "price_source": "batch", "block_reasons": _block_reasons_v2(s.signals) + _ml_block_reasons_v2(s.ml_signal, s.ml_confidence), "buy_allowed": not (_block_reasons_v2(s.signals) + _ml_block_reasons_v2(s.ml_signal, s.ml_confidence)), } 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: 실제 스캔 시각(KST 오프셋 포함 ISO). 날짜만 필요하면 batch_date. "batch_time": _batch_time_iso_v2(all_analyzed_stocks), "batch_date": (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: matches = _generic_rule_matches_v2(rule, stock, conditions) 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 = _persistable_items_v2( [(rec.stock_code, rec.stock_name, rec) for rec in valid_analyzed_stocks], _today ) if _items: 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))