fix(daytrading): 트레일링 고점이 진입 전 고가로 오염되던 버그
- highest_price 를 max(candles_5m[-3:].high) 대신 진입+60초 이후 시작한 1분봉 고가 + 현재가로만 갱신 (진입 전 스파이크가 +1% 트리거를 오발동시키고 고점-1.5×ATR 손절선을 부풀리던 문제) - 날짜+시각 KST 비교로 전일 캔들 제외, entry_time 부재·UTC 저장 추정 시 현재가만 사용 - 파라미터 변경 없음. 하드손절·시간손절·EOD·폴백 손절 경로 불변 - CLAUDE.md §7 시간손절 서술을 실제 동작(30분 경과 후 상한 없이 -0.3% 미만)으로 정정 - 회귀 테스트 13건 (전체 909 passed) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
2. EOD 15:20 도달 → 전체 강제 청산
|
||||
3. 개별 포지션 체크:
|
||||
- 손절: ATR 기반 또는 하드스탑
|
||||
- 시간 손절: 30분(6봉) +0.3% 미달
|
||||
- 시간 손절: 진입 30분 경과 후 언제든 -0.3% 미만
|
||||
- 과매수 청산: RSI >= 70
|
||||
- 분할 익절: 목표가 +2R 달성 시 50% 매도
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytz
|
||||
|
||||
@@ -89,6 +89,82 @@ def _kst_now() -> datetime:
|
||||
return datetime.now(_KST).replace(tzinfo=None)
|
||||
|
||||
|
||||
# [2026-10-01] 진입 시각 신뢰 구간 (KST 시각). DT 진입은 정규장 안에서만 일어나므로
|
||||
# 이 밖의 entry_time 은 기준 불일치(예: 과거 UTC 저장 행 — KST 09:05 → UTC 00:05)로 본다.
|
||||
_ENTRY_TIME_VALID_FROM = (9, 0)
|
||||
_ENTRY_TIME_VALID_TO = (15, 30)
|
||||
|
||||
# 1분봉 라벨(stck_cntg_hour)이 "분 시작"인지 "분 종료"인지 응답 규약이 확정되지 않았다.
|
||||
# 라벨 >= 진입시각 + 60초 인 봉만 쓰면 두 해석 모두에서 봉 전체가 진입 이후 체결이다.
|
||||
_POST_ENTRY_CANDLE_LAG_SEC = 60
|
||||
|
||||
|
||||
def _normalize_entry_time(entry_time) -> datetime | None:
|
||||
"""entry_time 을 KST naive datetime 으로 정규화. 신뢰할 수 없으면 None.
|
||||
|
||||
- 인메모리 진입(entry_service): `datetime.now(KST).replace(tzinfo=None)` (KST naive)
|
||||
- DB 복원(position_manager.restore_from_db): `ActiveTrade.created_at` (모델 기본값 now_kst
|
||||
= KST naive). 단 2026-09-23 이전 행은 UTC 로 저장된 것이 섞여 있다(timeutil 참조).
|
||||
"""
|
||||
if entry_time is None or not isinstance(entry_time, datetime):
|
||||
return None
|
||||
if entry_time.tzinfo is not None:
|
||||
entry_time = entry_time.astimezone(_KST).replace(tzinfo=None)
|
||||
hm = (entry_time.hour, entry_time.minute)
|
||||
if hm < _ENTRY_TIME_VALID_FROM or hm > _ENTRY_TIME_VALID_TO:
|
||||
return None
|
||||
if entry_time > _kst_now():
|
||||
return None
|
||||
return entry_time
|
||||
|
||||
|
||||
def _candle_datetime(candle) -> datetime | None:
|
||||
"""MinuteCandle(date=YYYYMMDD, time=HHMMSS, KST) → naive datetime. 형식 불량이면 None."""
|
||||
d = getattr(candle, "date", "") or ""
|
||||
t = getattr(candle, "time", "") or ""
|
||||
if len(d) != 8 or len(t) < 4:
|
||||
return None
|
||||
try:
|
||||
return datetime(
|
||||
int(d[:4]), int(d[4:6]), int(d[6:8]),
|
||||
int(t[:2]), int(t[2:4]), int(t[4:6]) if len(t) >= 6 else 0,
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def _post_entry_candle_high(candles, entry_time) -> float | None:
|
||||
"""진입 **이후에 시작한** 분봉들의 최고가. 산출 불가/해당 봉 없음이면 None (순수 함수).
|
||||
|
||||
[2026-10-01 버그 수정] 예전에는 `max(c.high for c in candles_5m[-3:])` 로 최근 5분봉 3개의
|
||||
고가를 그대로 트레일링 기준 고점에 넣었다. 진입 후 15분 이내에는 **진입 전 봉의 고가**가
|
||||
섞여, 실제로 오르지 않았는데 +1% 트레일링 트리거가 켜지거나 `고점 − 1.5×ATR` 손절선이
|
||||
부풀려졌다(운영 실측: 트레일링 6건이 "고점 +1.1~2.2%" 기록 후 −0.13~−0.81% 로 종료).
|
||||
|
||||
보수적 처리:
|
||||
- **진입이 속한 봉은 제외한다.** 봉 고가가 진입 전/후 어느 쪽 체결인지 알 수 없다.
|
||||
진입 직후 구간의 고점은 30초 주기의 현재가 관측과 진입 시 초기값(=진입가)으로만 잡는다
|
||||
(고점을 과소 추정 → 트레일링이 늦게 켜질 뿐, 하드 손절·시간손절은 영향 없음).
|
||||
- 5분봉 대신 1분봉을 쓰고, 라벨 >= 진입시각+60초 인 봉만 인정한다(라벨 규약 무관 안전).
|
||||
- 날짜까지 포함한 datetime 으로 비교해 전일 캔들 혼입(CLAUDE.md §7)을 차단한다.
|
||||
날짜·시각이 비어 있거나 형식 불량인 봉은 위치를 알 수 없으므로 제외한다.
|
||||
- entry_time 이 없거나 신뢰할 수 없으면 None → 호출부는 현재가만 쓴다.
|
||||
"""
|
||||
entry = _normalize_entry_time(entry_time)
|
||||
if entry is None:
|
||||
return None
|
||||
threshold = entry + timedelta(seconds=_POST_ENTRY_CANDLE_LAG_SEC)
|
||||
best: float | None = None
|
||||
for c in candles or []:
|
||||
cdt = _candle_datetime(c)
|
||||
if cdt is None or cdt < threshold:
|
||||
continue
|
||||
h = float(getattr(c, "high", 0) or 0)
|
||||
if h > 0 and (best is None or h > best):
|
||||
best = h
|
||||
return best
|
||||
|
||||
|
||||
async def check_and_exit_positions() -> list[dict]:
|
||||
"""활성 데이트레이딩 포지션의 청산 조건 체크 및 실행.
|
||||
|
||||
@@ -208,9 +284,17 @@ async def _check_single_position(stock_code: str) -> dict | None:
|
||||
try:
|
||||
current_price = float(candles_5m[-1].close)
|
||||
|
||||
# highest_price 갱신 — 5분봉 high 값도 반영 (30초 주기 보완)
|
||||
candle_high = max(c.high for c in candles_5m[-3:]) if len(candles_5m) >= 3 else current_price
|
||||
new_highest = max(current_price, candle_high, highest_price)
|
||||
# highest_price 갱신 — 진입 이후에 시작한 1분봉 고가만 반영 (30초 주기 보완).
|
||||
# [2026-10-01] 예전 `candles_5m[-3:]` 는 진입 전 봉 고가가 섞여 트레일링 고점을 오염시켰다.
|
||||
# entry_time 없음/불신 → None → 현재가만 사용(고점 과대 추정보다 과소 추정이 안전).
|
||||
# 기존 highest_price 와 max → 단조 증가 유지.
|
||||
candle_high = _post_entry_candle_high(candles, entry_time)
|
||||
if candle_high is None and entry_time is not None and _normalize_entry_time(entry_time) is None:
|
||||
logger.warning(
|
||||
f"[DayExit] entry_time 불신 ({stock_code}, entry_time={entry_time}) "
|
||||
f"— 트레일링 고점은 현재가만으로 갱신"
|
||||
)
|
||||
new_highest = max(current_price, candle_high or 0.0, highest_price)
|
||||
async with day_trading_state.trade_lock:
|
||||
if stock_code in day_trading_state.positions:
|
||||
if new_highest > highest_price:
|
||||
|
||||
Reference in New Issue
Block a user