From 5706a8971c80b8bb7042656af0865ef10c1f0872 Mon Sep 17 00:00:00 2001 From: shuim Date: Wed, 23 Sep 2026 12:54:22 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=8B=9C=EA=B0=81=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=20=EB=B6=88=EC=9D=BC=EC=B9=98=EB=A1=9C=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EC=86=90=EC=A0=88=EC=9D=B4=20=EC=A6=89=EC=8B=9C=20=EB=B0=9C?= =?UTF-8?q?=EB=8F=99=ED=95=B4=20=ED=8F=AC=EC=A7=80=EC=85=98=EC=9D=B4=20?= =?UTF-8?q?=EC=A1=B0=EA=B8=B0=20=EC=B2=AD=EC=82=B0=EB=90=98=EB=8D=98=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 재배포로 DB 복원된 데이트레이딩 포지션의 entry_time 이 UTC(구 기본값)라 KST now 와의 차이가 +540분이 되어, 진입 25분 만에 '시간 손절: 565분 경과'로 청산됐다 (2026-09-23 산일전기 -0.5%, 미스토홀딩스 -0.4% — 익절 조건까지 가보지도 못함). _elapsed_minutes(): 경과가 음수이거나 정규장 길이(390분)를 넘으면 기준 불일치로 보고 무시. 시간손절·트레일링 최소보유 판정 모두 이 헬퍼를 사용. Co-Authored-By: Claude Opus 5 (1M context) --- backend/app/services/day_trading_strategy.py | 58 +++++++++++++------- backend/tests/test_time_stop_guard.py | 40 ++++++++++++++ 2 files changed, 77 insertions(+), 21 deletions(-) create mode 100644 backend/tests/test_time_stop_guard.py diff --git a/backend/app/services/day_trading_strategy.py b/backend/app/services/day_trading_strategy.py index 339c164..de5cf05 100644 --- a/backend/app/services/day_trading_strategy.py +++ b/backend/app/services/day_trading_strategy.py @@ -498,6 +498,33 @@ def check_rsi_bb_signal( # ── 청산 신호 ───────────────────────────────────────────────────────────────── +def _elapsed_minutes(entry_time) -> Optional[float]: + """진입 후 경과 시간(분). 값이 신뢰할 수 없으면 None. + + [2026-09-23] DB에서 복원한 포지션의 entry_time 이 UTC(예전 기본값)면 KST now 와 비교 시 + 경과가 +540분으로 부풀어, 재배포 직후 '시간 손절'이 즉시 발동해 포지션이 몇 분 만에 + 청산됐다(산일전기 565분·미스토홀딩스 568분 = 실제 25·28분 + 9시간). + 음수이거나 정규장 길이(09:00~15:30 = 390분)를 넘는 값은 시각 기준 불일치로 보고 무시한다. + """ + if entry_time is None: + return None + try: + from app.core.timeutil import now_kst as _now_kst + + now_kst = _now_kst() + elapsed = (now_kst - entry_time).total_seconds() / 60 + except Exception as e: + logger.debug(f"[DayTradeExit] 경과 시간 계산 실패: {e}") + return None + if elapsed < 0 or elapsed > 390: + logger.warning( + f"[DayTradeExit] entry_time 기준 불일치 추정 — 경과 {elapsed:.0f}분 무시 " + f"(entry_time={entry_time})" + ) + return None + return elapsed + + def check_day_trade_exit( entry_price: float, current_price: float, @@ -536,19 +563,14 @@ def check_day_trade_exit( ) # 3. [P2] 시간 손절 — 진입 후 설정 시간 경과 시 목표 방향 진행 미미하면 청산 - if entry_time is not None: - try: - now_kst = _dt.now(pytz.timezone("Asia/Seoul")).replace(tzinfo=None) - elapsed_min = (now_kst - entry_time).total_seconds() / 60 - if elapsed_min >= settings.DAY_TRADE_TIME_STOP_MINUTES: - gain_pct = (current_price - entry_price) / entry_price - if gain_pct < settings.DAY_TRADE_TIME_STOP_MIN_GAIN: # 최소 수익 미달 (수수료 커버 못함) - return DayTradeExit( - action="SELL_ALL", - reason=f"시간 손절: {elapsed_min:.0f}분 경과, 수익={gain_pct*100:.1f}% < {settings.DAY_TRADE_TIME_STOP_MIN_GAIN*100:.1f}%", - ) - except Exception: - pass + elapsed_min = _elapsed_minutes(entry_time) + if elapsed_min is not None and elapsed_min >= settings.DAY_TRADE_TIME_STOP_MINUTES: + gain_pct = (current_price - entry_price) / entry_price + if gain_pct < settings.DAY_TRADE_TIME_STOP_MIN_GAIN: # 최소 수익 미달 (수수료 커버 못함) + return DayTradeExit( + action="SELL_ALL", + reason=f"시간 손절: {elapsed_min:.0f}분 경과, 수익={gain_pct*100:.1f}% < {settings.DAY_TRADE_TIME_STOP_MIN_GAIN*100:.1f}%", + ) # 4. 목표가 달성 → 50% 분할 매도 (CLAUDE.md 5.2: +2R 시 50% 분할 매도) # [FIX] target_price<=0 방어 — 0이면 즉시 조건 충족되어 오매도되는 것을 방지 @@ -563,14 +585,8 @@ def check_day_trade_exit( # 5. 트레일링 스탑 (최소 보유 시간 경과 + 최소 수익률 이상 시 발동) # [whipsaw 방지] 진입 직후 순간 상승→되돌림에 트레일링이 걸리는 것을 방지 _trailing_allowed = True - if entry_time is not None: - try: - now_kst = _dt.now(pytz.timezone("Asia/Seoul")).replace(tzinfo=None) - elapsed_sec = (now_kst - entry_time).total_seconds() - if elapsed_sec < settings.DAY_TRADE_TRAILING_MIN_HOLD_SEC: - _trailing_allowed = False # 최소 보유 시간(5분) 미경과 → 하드스탑만 적용 - except Exception: - pass + if elapsed_min is not None and elapsed_min * 60 < settings.DAY_TRADE_TRAILING_MIN_HOLD_SEC: + _trailing_allowed = False # 최소 보유 시간(5분) 미경과 → 하드스탑만 적용 if _trailing_allowed: profit_pct = (highest_since_entry - entry_price) / entry_price diff --git a/backend/tests/test_time_stop_guard.py b/backend/tests/test_time_stop_guard.py new file mode 100644 index 0000000..277d70d --- /dev/null +++ b/backend/tests/test_time_stop_guard.py @@ -0,0 +1,40 @@ +"""시간 손절이 시각 기준 불일치로 조기 발동하지 않는지 검증. + +2026-09-23: 재배포로 DB 복원된 포지션의 entry_time 이 UTC 라 KST now 와의 차이가 +540분이 되어 +진입 25분 만에 '시간 손절: 565분 경과'로 청산됐다(익절까지 가지 못함). +""" +from datetime import datetime, timedelta + +import pytz + +from app.services.day_trading_strategy import _elapsed_minutes + +KST = pytz.timezone("Asia/Seoul") + + +def _kst_now(): + return datetime.now(KST).replace(tzinfo=None) + + +def test_normal_elapsed_is_returned(): + v = _elapsed_minutes(_kst_now() - timedelta(minutes=40)) + assert v is not None and 39 < v < 41 + + +def test_utc_entry_time_is_rejected(): + """UTC 로 저장된 진입시각(=KST-9h) → 경과 +540분 → 무시해야 한다.""" + utc_entry = _kst_now() - timedelta(hours=9) + timedelta(minutes=25) # 실제 25분 전 + assert _elapsed_minutes(utc_entry) is None + + +def test_future_entry_time_is_rejected(): + assert _elapsed_minutes(_kst_now() + timedelta(minutes=5)) is None + + +def test_none_entry_time(): + assert _elapsed_minutes(None) is None + + +def test_boundary_is_market_day_length(): + assert _elapsed_minutes(_kst_now() - timedelta(minutes=389)) is not None + assert _elapsed_minutes(_kst_now() - timedelta(minutes=391)) is None