fix: portfolio.py check_exit_signal 호출부 3가지 버그 수정

① regime 파라미터 누락 → MarketRegime.BULL 기본값으로 수정
  (기존 코드 런타임 TypeError 버그 — check_exit_signal 첫 인자 누락)
② trailing_high 미전달 → 최근 10봉 최고가로 계산하여 전달
  (트레일링 스탑 기능 실제 동작 가능)
③ remaining_pct 업데이트 수식 오류
  - 기존: remaining_pct - sell_pct (2차 이후 잘못된 비율)
  - 수정: remaining_pct * (1 - sell_pct) (정확한 잔여 비율)
  예) remaining=0.70, sell_pct=0.50 시
    기존: 0.70 - 0.50 = 0.20 (잘못됨)
    수정: 0.70 × 0.50 = 0.35 (올바름)
This commit is contained in:
2026-02-22 20:31:18 +09:00
parent 7c0b4fb3fd
commit e794267b96

View File

@@ -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.korea_investment_api import korea_investment_api
from app.services.mean_reversion_strategy import ( from app.services.mean_reversion_strategy import (
calculate_atr, calculate_rsi, calculate_bollinger, check_exit_signal, calculate_atr, calculate_rsi, calculate_bollinger, check_exit_signal,
MarketRegime, # ① regime 파라미터 전달용 추가
) )
from app.services.mdd_manager import mdd_manager from app.services.mdd_manager import mdd_manager
from app.services.loss_tracker import loss_tracker 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 기반: CLAUDE.md 섹션 6.2 + 7.3 + 7.4 기반:
- ATR 기반 손절: min(최저가 - 2.5×ATR, 진입가 -8%) - ATR 기반 손절: min(최저가 - 2.5×ATR, 진입가 -6%) ← -8% → -6%
- 1차 익절(50%): RSI ≥ 50 + BB 중앙선 상회 - 1차 익절(30%/50%): RSI 55~65 + BB 중앙선 상회
- 2차 익절(전량): RSI 60~70 + BB 상단선 - 2차 익절(50%): RSI ≥ 65 + BB 상단선 도달
- 트레일링 스탑: 최고가 대비 -3% 하락 시 잔여 전량 청산
- 폴백: 고정 비율 손절 (STOP_LOSS_PERCENT) - 폴백: 고정 비율 손절 (STOP_LOSS_PERCENT)
""" """
from app.core.config import settings 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", []) chart_data = chart_resp.get("output2", [])
if len(chart_data) >= 20: if len(chart_data) >= 20:
closes = [float(d.get("stck_clpr", 0)) for d in reversed(chart_data)] 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)] 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)] lows = [float(d.get("stck_lwpr", 0)) for d in reversed(chart_data)]
atr = calculate_atr(highs, lows, closes) atr = calculate_atr(highs, lows, closes)
rsi = calculate_rsi(closes) rsi = calculate_rsi(closes)
bb = calculate_bollinger(closes) bb = calculate_bollinger(closes)
if atr and rsi is not None and bb: if atr and rsi is not None and bb:
bb_upper, bb_middle, bb_lower = bb bb_upper, bb_middle, bb_lower = bb
lowest_since = min(lows[-5:]) if lows else avg_price 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( exit_signal = check_exit_signal(
regime=regime,
rsi=rsi, rsi=rsi,
close=latest_price, close=latest_price,
bb_upper=bb_upper, bb_upper=bb_upper,
@@ -565,6 +577,7 @@ async def check_and_execute_exit_trades(db: AsyncSession):
atr=atr, atr=atr,
lowest_since_entry=lowest_since, lowest_since_entry=lowest_since,
remaining_pct=remaining_pct, remaining_pct=remaining_pct,
trailing_high=trailing_high, # ② 트레일링 스탑 활성화
) )
except Exception as chart_err: except Exception as chart_err:
logger.debug(f"ATR 청산 계산 실패 ({stock_code}): {chart_err}") logger.debug(f"ATR 청산 계산 실패 ({stock_code}): {chart_err}")
@@ -637,7 +650,12 @@ async def check_and_execute_exit_trades(db: AsyncSession):
# 분할 청산 시 ActiveTrade 잔여 비율 업데이트 # 분할 청산 시 ActiveTrade 잔여 비율 업데이트
if exit_signal and exit_signal.action == "SELL_PARTIAL" and active_trade: 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() await db.commit()
else: else:
# 전량 매도 # 전량 매도