fix: 시각 기준 불일치로 시간손절이 즉시 발동해 포지션이 조기 청산되던 문제
재배포로 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
40
backend/tests/test_time_stop_guard.py
Normal file
40
backend/tests/test_time_stop_guard.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user