diff --git a/backend/app/api/endpoints/portfolio.py b/backend/app/api/endpoints/portfolio.py index 7990e84..c462c47 100644 --- a/backend/app/api/endpoints/portfolio.py +++ b/backend/app/api/endpoints/portfolio.py @@ -19,6 +19,7 @@ from app.models.models import Portfolio, TradeHistory, ActiveTrade, StockAnalysi from app.services.korea_investment_api import korea_investment_api from app.services.mean_reversion_strategy import ( calculate_atr, calculate_rsi, calculate_bollinger, check_exit_signal, + MarketRegime, # ① regime 파라미터 전달용 추가 ) from app.services.mdd_manager import mdd_manager from app.services.loss_tracker import loss_tracker @@ -481,9 +482,10 @@ async def check_and_execute_exit_trades(db: AsyncSession): """ 보유 종목의 손절/익절 조건을 체크하고 자동 매도 실행 CLAUDE.md 섹션 6.2 + 7.3 + 7.4 기반: - - ATR 기반 손절: min(최저가 - 2.5×ATR, 진입가 -8%) - - 1차 익절(50%): RSI ≥ 50 + BB 중앙선 상회 - - 2차 익절(전량): RSI 60~70 + BB 상단선 + - ATR 기반 손절: min(최저가 - 2.5×ATR, 진입가 -6%) ← -8% → -6% + - 1차 익절(30%/50%): RSI 55~65 + BB 중앙선 상회 + - 2차 익절(50%): RSI ≥ 65 + BB 상단선 도달 + - 트레일링 스탑: 최고가 대비 -3% 하락 시 잔여 전량 청산 - 폴백: 고정 비율 손절 (STOP_LOSS_PERCENT) """ from app.core.config import settings @@ -545,17 +547,27 @@ async def check_and_execute_exit_trades(db: AsyncSession): chart_data = chart_resp.get("output2", []) if len(chart_data) >= 20: closes = [float(d.get("stck_clpr", 0)) for d in reversed(chart_data)] - highs = [float(d.get("stck_hgpr", 0)) for d in reversed(chart_data)] - lows = [float(d.get("stck_lwpr", 0)) for d in reversed(chart_data)] + highs = [float(d.get("stck_hgpr", 0)) for d in reversed(chart_data)] + lows = [float(d.get("stck_lwpr", 0)) for d in reversed(chart_data)] atr = calculate_atr(highs, lows, closes) rsi = calculate_rsi(closes) - bb = calculate_bollinger(closes) + bb = calculate_bollinger(closes) if atr and rsi is not None and bb: bb_upper, bb_middle, bb_lower = bb lowest_since = min(lows[-5:]) if lows else avg_price + + # ② 진입 후 최고가 (트레일링 스탑용) — 최근 10봉 최고값으로 근사 + trailing_high = max(highs[-10:]) if highs else None + + # ① 시장 레짐 결정 + # TODO: KOSPI 지수 일봉 기반 detect_market_regime() 으로 대체 예정 + # 현재는 BULL 기본값 사용 (보수적 선택 — 1차 30% 익절) + regime = MarketRegime.BULL + exit_signal = check_exit_signal( + regime=regime, rsi=rsi, close=latest_price, bb_upper=bb_upper, @@ -565,6 +577,7 @@ async def check_and_execute_exit_trades(db: AsyncSession): atr=atr, lowest_since_entry=lowest_since, remaining_pct=remaining_pct, + trailing_high=trailing_high, # ② 트레일링 스탑 활성화 ) except Exception as chart_err: logger.debug(f"ATR 청산 계산 실패 ({stock_code}): {chart_err}") @@ -637,7 +650,12 @@ async def check_and_execute_exit_trades(db: AsyncSession): # 분할 청산 시 ActiveTrade 잔여 비율 업데이트 if exit_signal and exit_signal.action == "SELL_PARTIAL" and active_trade: - active_trade.remaining_pct = Decimal(str(remaining_pct - exit_signal.sell_pct)) + # ③ 정확한 잔여 비율 계산: + # sell_pct = 현재 보유량 대비 비율이므로 + # new_remaining = remaining_pct × (1 - sell_pct) + # 예) remaining=0.70, sell_pct=0.50 → 0.70×0.50=0.35 (기존: 0.70-0.50=0.20 오류) + new_remaining = remaining_pct * (1.0 - exit_signal.sell_pct) + active_trade.remaining_pct = Decimal(str(round(new_remaining, 4))) await db.commit() else: # 전량 매도