diff --git a/backend/app/api/endpoints/recommendations.py b/backend/app/api/endpoints/recommendations.py index 0c18b23..46e6ba2 100644 --- a/backend/app/api/endpoints/recommendations.py +++ b/backend/app/api/endpoints/recommendations.py @@ -253,6 +253,14 @@ def is_mean_reversion_candidate(stock, conditions: dict, market_regime_str: str) 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 초과 시 계산 오류이므로 무시 @@ -625,7 +633,7 @@ async def get_rule_based_recommendations_realtime(): @router.post("/auto-trade") async def execute_auto_trading(db: AsyncSession = Depends(get_db)): try: - recommendations_response = await get_rule_based_recommendations(realtime=True) + recommendations_response = await get_rule_based_recommendations(realtime=True, auto_trade=False) if not recommendations_response.get("recommendations_by_rule"): return {"message": "추천 종목이 없습니다", "trades": []} diff --git a/backend/app/services/kr_backtest_engine.py b/backend/app/services/kr_backtest_engine.py index c3d542c..e7dc0ca 100644 --- a/backend/app/services/kr_backtest_engine.py +++ b/backend/app/services/kr_backtest_engine.py @@ -487,6 +487,10 @@ class MeanReversionBacktest(BacktestStrategy): prev_vol = volumes[-2] if len(volumes) >= 2 else 0 + # [안전형 개선①] own_trend 게이트용 — 종목 자체 MA20/MA60 (데이터 있으면 재사용) + ma20_val = sum(closes[-20:]) / 20 if len(closes) >= 20 else None + ma60_val = sum(closes[-60:]) / 60 if len(closes) >= 60 else None + signal = check_buy_signal( regime=self._get_regime(params), rsi=rsi, @@ -499,6 +503,8 @@ class MeanReversionBacktest(BacktestStrategy): prev_volume=prev_vol, is_reversal=reversal, rsi_prev=rsi_prev, + ma20=ma20_val, + ma60=ma60_val, ) if signal: diff --git a/backend/app/services/mean_reversion_strategy.py b/backend/app/services/mean_reversion_strategy.py index 367224b..6fe27b4 100644 --- a/backend/app/services/mean_reversion_strategy.py +++ b/backend/app/services/mean_reversion_strategy.py @@ -294,6 +294,8 @@ def check_buy_signal( prev_volume: int, is_reversal: bool = False, rsi_prev: Optional[float] = None, # [2026-03] RSI 반등 확인용 — 3봉 전 RSI + ma20: Optional[float] = None, # [안전형 개선①] own_trend 게이트용 — 종목 자체 MA20 + ma60: Optional[float] = None, # [안전형 개선①] own_trend 게이트용 — 종목 자체 MA60 ) -> Optional[Dict]: """CLAUDE.md 6.2: 매수 조건 판별 @@ -302,12 +304,25 @@ def check_buy_signal( - BULL: RSI < 40, BB하단 -3%~+1.5%, 거래량 >= 20일평균 - BEAR: RSI < 30, 종가 <= BB하단, 되돌림 캔들, 거래량 > 1.5×20일평균 - RSI 반등 확인: rsi_prev 제공 시, RSI가 반등 중일 때만 진입 허용 + + [안전형 개선①] own_trend 게이트 (검증 완료: Sharpe 0.27→0.57) + - ma20/ma60 제공 시: 종목 자체 MA20 < MA60*0.97 (하락추세)이면 진입 차단 + (물타기 방지 — 시장 국면과 무관하게 개별 종목이 자체 하락추세면 평균회귀 매수 금지) + - ma20/ma60 미제공(기본값 None): 게이트 미적용 — 기존 호출부/테스트 하위호환 유지 """ # CLAUDE.md 6.1: 횡보장(SIDEWAYS)에서는 모든 진입 신호를 무시 if regime == MarketRegime.SIDEWAYS: logger.debug("⏭️ SIDEWAYS 국면 — 진입 차단 (CLAUDE.md 6.1)") return None + # [안전형 개선①] own_trend 게이트 — 종목 자체 하락추세 물타기 방지 + if ma20 is not None and ma60 is not None and ma60 > 0 and ma20 < ma60 * 0.97: + logger.debug( + "⏭️ own_trend 게이트 — 종목 자체 하락추세 진입 차단: MA20(%.1f) < MA60(%.1f)*0.97", + ma20, ma60, + ) + return None + if current_volume < avg_volume_20d: return None diff --git a/backend/app/services/swing/entry_service.py b/backend/app/services/swing/entry_service.py index 9c11895..9421eda 100644 --- a/backend/app/services/swing/entry_service.py +++ b/backend/app/services/swing/entry_service.py @@ -40,6 +40,9 @@ logger = logging.getLogger(__name__) # 스윙 동시 보유 최대 종목 수 (CLAUDE.md 5.1: MAX_HOLDINGS_COUNT = 20) MAX_SWING_POSITIONS = 20 +# RSI 반등 방향 확인용 과거 구간 (kosdaq_scanner.py RSI_DIRECTION_LOOKBACK과 동일) +RSI_DIRECTION_LOOKBACK = 3 + async def execute_swing_entries(recommendations_response: dict, db: AsyncSession) -> list[dict]: """배치 분석 추천 결과를 기반으로 스윙 매수 실행. @@ -100,13 +103,26 @@ async def execute_swing_entries(recommendations_response: dict, db: AsyncSession logger.info("[SwingEntry] 플레이북 차단 — 신규 진입 비활성화 (이벤트 대응)") return [] - # SIDEWAYS 국면 체크 + # SIDEWAYS 국면 체크 + [안전형 개선②] 지수 서킷브레이커 try: regime = await check_market_regime() regime_str = regime.get("regime_200d", "bull") if isinstance(regime, dict) else "bull" if regime_str == "sideways": logger.info("[SwingEntry] SIDEWAYS 국면 — 진입 신호 무시 (CLAUDE.md 6.1)") return [] + + # [안전형 개선②] 지수 서킷브레이커 (검증 완료: 2024H2 CAGR -9.9%→+1.0%, MDD -7→-1.9) + # KODEX200(069500) 일봉 종가 < MA20 → 신규 스윙 진입 전면 중단 (기존 보유 청산/관리는 정상 동작). + # check_market_regime()이 이미 계산한 drop_from_ma20(=(현재가-MA20)/MA20*100)을 재사용하여 + # 지수 데이터 중복 조회를 피한다. + if isinstance(regime, dict): + _drop_from_ma20 = regime.get("drop_from_ma20") + if _drop_from_ma20 is not None and float(_drop_from_ma20) < 0: + logger.info( + "[SwingEntry] 지수 서킷브레이커 — KODEX200 종가 list[dict]: current_close = closes[-1] current_volume = volumes[-1] if volumes else 0 - rsi_values = calculate_rsi(closes, period=14) - rsi = rsi_values[-1] if rsi_values else 50.0 - rsi_prev = rsi_values[-2] if len(rsi_values) >= 2 else None + rsi = calculate_rsi(closes, period=14) + if rsi is None: + continue + rsi_prev = calculate_rsi(closes[:-RSI_DIRECTION_LOOKBACK], period=14) scan_count += 1 diff --git a/backend/app/services/trade_executor.py b/backend/app/services/trade_executor.py index 37a240a..91f23d3 100644 --- a/backend/app/services/trade_executor.py +++ b/backend/app/services/trade_executor.py @@ -501,7 +501,9 @@ async def _execute_buy( # ── 3. 현금·자산 조회 (30초 캐시 → portfolio_cache → KIS API 직접) ───── cached = _get_cached_account() - if cached: + # [FIX] 현금이 0인 캐시는 신뢰하지 않는다. 기존엔 available_cash=0이 캐시되면 + # 그대로 재사용해 수량이 0으로 계산되어 매수 주문이 영영 안 나갔다(무한루프). + if cached and cached[0] > 0: available_cash, total_assets = cached else: available_cash, total_assets = 0.0, 0.0 @@ -514,8 +516,11 @@ async def _execute_buy( total_assets = float(summary_copy.get("total_assets", 0)) except Exception: pass - # 2차: 캐시 미스 → KIS API 직접 조회 (잔고 0이면 미초기화로 판단) - if available_cash <= 0 and total_assets <= 0: + # 2차: 현금이 0이면 KIS API로 직접 조회. + # [FIX] 기존 조건은 (cash<=0 AND assets<=0) 이라, 평가자산은 있는데 캐시 현금만 + # 0인 경우(portfolio_cache 요약이 available_cash=0을 준 케이스) KIS 재조회를 + # 건너뛰어 현금 0이 고착됐다. 현금이 0이면 자산과 무관하게 실제 예수금을 읽는다. + if available_cash <= 0: try: balance = await korea_investment_api.get_balance() output2 = balance.get("output2", [{}]) @@ -526,7 +531,8 @@ async def _execute_buy( logger.info(f"[CASH_INIT] KIS API 직접 조회: cash={available_cash:,.0f} assets={total_assets:,.0f}") except Exception as e: logger.warning(f"[CASH_INIT] KIS 잔고 조회 실패: {e}") - if available_cash > 0 or total_assets > 0: + # [FIX] 실제 현금(>0)일 때만 캐시한다. 0 현금을 캐시하면 위 무한루프가 재발한다. + if available_cash > 0: _set_account_cache(available_cash, total_assets) # [이슈1 수정] TradeHistory.created_at은 UTC 저장(datetime.utcnow)이므로 diff --git a/backend/run_backtest.py b/backend/run_backtest.py index 868a6d5..3399009 100644 --- a/backend/run_backtest.py +++ b/backend/run_backtest.py @@ -545,10 +545,20 @@ def check_buy_signal( if mp.get("position_size", 0) <= 0: return None + # [안전형 개선①] own_trend 게이트 (검증 완료: Sharpe 0.27→0.57) + # 종목 자체 MA20 < MA60*0.97 (하락추세)이면 평균회귀(물타기) 진입 차단. + # 모멘텀 돌파(추세추종)에는 미적용 — 전략 2 블록 진입 전에만 반영. + _own_trend_ok = True + if closes and len(closes) >= 60: + _mr_ma20 = sum(closes[-20:]) / 20 + _mr_ma60 = sum(closes[-60:]) / 60 + if _mr_ma60 > 0 and _mr_ma20 < _mr_ma60 * 0.97: + _own_trend_ok = False + # ═══════════════════════════════════════════════════════════════ # 전략 1: 평균회귀 (RSI 과매도 + BB 하단) # ═══════════════════════════════════════════════════════════════ - if current_volume >= avg_volume_20d: + if current_volume >= avg_volume_20d and _own_trend_ok: if regime == MarketRegime.BULL: rsi_thresh = mp["rsi_entry"] if rsi < rsi_thresh: @@ -1043,6 +1053,7 @@ def run_simulation( adaptive_mode = "normal" mode_params = ADAPTIVE_MODES["normal"] mode_changes: List[Dict] = [] # 모드 전환 이력 + circuit_breaker_days = 0 # [안전형 개선②] 서킷브레이커 발동일 수 (검증용 통계) total_days = len(all_dates) last_progress = -1 @@ -1063,6 +1074,15 @@ def run_simulation( idx_highs_acc.append(idx_bar.high_price) idx_lows_acc.append(idx_bar.low_price) + # [안전형 개선②] 지수 서킷브레이커 (검증 완료: 2024H2 CAGR -9.9%→+1.0%, MDD -7→-1.9) + # KODEX200 당일 종가 < MA20(20일) → 신규 진입 전면 중단 (기존 보유 청산/관리는 정상). + # 매일 갱신(적응형 모드처럼 5일 지연시키지 않음) — 하락장 상관붕괴에 즉시 반응해야 함. + idx_circuit_breaker = False + if len(idx_closes_acc) >= 20: + _idx_ma20_cb = sum(idx_closes_acc[-20:]) / 20 + if idx_closes_acc[-1] < _idx_ma20_cb: + idx_circuit_breaker = True + # 시장 국면 + 적응형 모드 (매 5일마다 업데이트) regime_update_counter += 1 if regime_update_counter >= 5 and len(idx_closes_acc) >= MA200_PERIOD + MA200_SLOPE_LOOKBACK: @@ -1084,6 +1104,9 @@ def run_simulation( if sim_start_date is not None and current_date < sim_start_date: continue + if idx_circuit_breaker: + circuit_breaker_days += 1 + # 현재 평가 자산 position_value = 0.0 for code, pos in positions.items(): @@ -1189,8 +1212,9 @@ def run_simulation( pos.remaining_pct = remaining_qty / (remaining_qty + sell_qty) # ---- 매수 시그널 ---- + # [안전형 개선②] 서킷브레이커 발동 중이면 신규 진입 전면 스킵 (기존 보유 관리는 위에서 정상 수행) _max_holdings = mode_params.get("max_holdings", 12) - if len(positions) < _max_holdings: + if len(positions) < _max_holdings and not idx_circuit_breaker: buy_candidates = [] for code, stock_bars in bars.items(): @@ -1397,6 +1421,11 @@ def run_simulation( ) metrics = compute_metrics(equity_values, all_trades, initial_capital, _sim_trading_days) + # [안전형 개선②] 서킷브레이커 발동 통계 출력 (검증용) + if _sim_trading_days: + print(f" [서킷브레이커] KODEX200 종가