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:
2026-10-01 17:04:07 +09:00
parent e4346d30fb
commit 599de3b52e
3 changed files with 275 additions and 6 deletions

View File

@@ -195,8 +195,9 @@ adaptive_strategy(_MODE_PARAMS) > strategy_cfg(DB: strategy_configs) > setti
- **최소 손절폭 보장(`DAY_TRADE_MIN_STOP_LOSS_PCT` = 2%)은 §5.2의 "타이트한 쪽" 규칙의 의도적 예외**다. 장중 전략에서 손절이 너무 타이트하면 노이즈에 털리므로, `min(raw_stop, min_stop)` 으로 **더 느슨한 쪽**을 고른다. 이 `min()` 을 "버그"로 판단해 뒤집지 말 것. - **최소 손절폭 보장(`DAY_TRADE_MIN_STOP_LOSS_PCT` = 2%)은 §5.2의 "타이트한 쪽" 규칙의 의도적 예외**다. 장중 전략에서 손절이 너무 타이트하면 노이즈에 털리므로, `min(raw_stop, min_stop)` 으로 **더 느슨한 쪽**을 고른다. 이 `min()` 을 "버그"로 판단해 뒤집지 말 것.
- 사이징: 종목당 **6%**(`DAY_TRADE_MAX_CAPITAL_RATIO`), 동시 **5종목**(`DAY_TRADE_MAX_POSITIONS`) = 5×6% = 30%. confidence 조정 `예산 × min(1.2, confidence/0.7)` 후 **세 한도(종목당 6% / DT 잔여 30% / 가용현금)로 재클램프**된다. - 사이징: 종목당 **6%**(`DAY_TRADE_MAX_CAPITAL_RATIO`), 동시 **5종목**(`DAY_TRADE_MAX_POSITIONS`) = 5×6% = 30%. confidence 조정 `예산 × min(1.2, confidence/0.7)` 후 **세 한도(종목당 6% / DT 잔여 30% / 가용현금)로 재클램프**된다.
- 청산: **1.5R**(`DAY_TRADE_TARGET_MULTIPLIER`) 도달 시 50% 분할 → 잔량 트레일링(ATR×**1.5**, 발동 최소수익 1%) / 진입 30분(6봉)간 **-0.3% 미만**이면 시간손절(수익 중이면 보유 유지) / 5분 RSI ≥70 & 수수료 이상 수익 시 / **15:20 EOD 전량 청산** / **전일 미청산 포지션은 장중 발견 즉시 전량 청산**(`close_stale_positions`) - 청산: **1.5R**(`DAY_TRADE_TARGET_MULTIPLIER`) 도달 시 50% 분할 → 잔량 트레일링(ATR×**1.5**, 발동 최소수익 1%) / **진입 후 30분(`DAY_TRADE_TIME_STOP_MINUTES`) 경과 이후 언제든** 수익률이 **-0.3% 미만**(`DAY_TRADE_TIME_STOP_MIN_GAIN`)이면 시간손절 — "30분 동안"이 아니라 30분 시점부터 EOD 까지 상한 없이 매 사이클 판정한다(`day_trading_strategy.check_day_trade_exit`). -0.3% 이상이면 보유 유지. 경과시간이 음수/390분 초과면 entry_time 기준 불일치로 보고 판정하지 않는다(`_elapsed_minutes`) / 5분 RSI ≥70 & 수수료 이상 수익 시 / **15:20 EOD 전량 청산** / **전일 미청산 포지션은 장중 발견 즉시 전량 청산**(`close_stale_positions`)
- **전일 포지션 청산 손익은 당일 실현손익에 넣지 않는다**(`_finalize_exit_impl(count_daily_pnl=False)`). 넣으면 당일 매매 0건인데 -3% 손실한도가 발동해 정상 포지션까지 전량 청산된다. 전일 손익은 `get_stale_realized_pnl()` + warning 로그 + TradeHistory로 추적한다. stale 청산 종목은 `closed_today` 에 추가해 당일 재진입을 막는다(주문 실패 시에도). - **전일 포지션 청산 손익은 당일 실현손익에 넣지 않는다**(`_finalize_exit_impl(count_daily_pnl=False)`). 넣으면 당일 매매 0건인데 -3% 손실한도가 발동해 정상 포지션까지 전량 청산된다. 전일 손익은 `get_stale_realized_pnl()` + warning 로그 + TradeHistory로 추적한다. stale 청산 종목은 `closed_today` 에 추가해 당일 재진입을 막는다(주문 실패 시에도).
- **트레일링 고점(`highest_price`)은 진입 이후에 시작한 1분봉 고가 + 현재가로만 갱신한다**(`daytrading/exit_service._post_entry_candle_high`, 2026-10-01). 진입이 속한 봉은 진입 전 체결이 섞여 제외하고, 라벨 ≥ 진입+60초 봉만 인정하며 날짜까지 비교한다. entry_time 이 없거나 정규장 밖(UTC 저장 추정)이면 현재가만 쓴다. 이전의 `max(candles_5m[-3:].high)` 는 진입 전 고가로 +1% 트리거를 오발동시켰다.
- **분봉 조회 실패 시 폴백 손절**이 있다(`_check_fallback_stop_loss`). 현재가만으로 손절선 이탈을 판정해 전량 청산한다. 폴백 손절가는 `min()` 으로 고른 가장 타이트한 하드캡(-2%) 기준이며, 현재가조차 못 구하면 `logger.error` 로 수동 확인을 요청한다. - **분봉 조회 실패 시 폴백 손절**이 있다(`_check_fallback_stop_loss`). 현재가만으로 손절선 이탈을 판정해 전량 청산한다. 폴백 손절가는 `min()` 으로 고른 가장 타이트한 하드캡(-2%) 기준이며, 현재가조차 못 구하면 `logger.error` 로 수동 확인을 요청한다.
- 제한: **15:00 이후 신규 마감**(`DAY_TRADE_ENTRY_CUTOFF_*`) / 일일 **-3%** 손실 시 당일 중단(`DAY_TRADE_DAILY_LOSS_LIMIT`) / 당일 3연패 쿨다운(position_manager) / 전량 매도 시 loss_tracker 기록 + `save_to_db()` - 제한: **15:00 이후 신규 마감**(`DAY_TRADE_ENTRY_CUTOFF_*`) / 일일 **-3%** 손실 시 당일 중단(`DAY_TRADE_DAILY_LOSS_LIMIT`) / 당일 3연패 쿨다운(position_manager) / 전량 매도 시 loss_tracker 기록 + `save_to_db()`
- 감시종목: 09:00에 당일 분석이 없으면 최근 분석일 결과로 선채움(ORB 수집 보장), 자동 목록은 30분마다 갱신(수동 설정 목록은 유지) - 감시종목: 09:00에 당일 분석이 없으면 최근 분석일 결과로 선채움(ORB 수집 보장), 자동 목록은 30분마다 갱신(수동 설정 목록은 유지)

View File

@@ -11,7 +11,7 @@
2. EOD 15:20 도달 → 전체 강제 청산 2. EOD 15:20 도달 → 전체 강제 청산
3. 개별 포지션 체크: 3. 개별 포지션 체크:
- 손절: ATR 기반 또는 하드스탑 - 손절: ATR 기반 또는 하드스탑
- 시간 손절: 30분(6봉) +0.3% 미달 - 시간 손절: 진입 30분 경과 후 언제든 -0.3% 미만
- 과매수 청산: RSI >= 70 - 과매수 청산: RSI >= 70
- 분할 익절: 목표가 +2R 달성 시 50% 매도 - 분할 익절: 목표가 +2R 달성 시 50% 매도
@@ -21,7 +21,7 @@
""" """
import asyncio import asyncio
import logging import logging
from datetime import datetime from datetime import datetime, timedelta
import pytz import pytz
@@ -89,6 +89,82 @@ def _kst_now() -> datetime:
return datetime.now(_KST).replace(tzinfo=None) 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]: async def check_and_exit_positions() -> list[dict]:
"""활성 데이트레이딩 포지션의 청산 조건 체크 및 실행. """활성 데이트레이딩 포지션의 청산 조건 체크 및 실행.
@@ -208,9 +284,17 @@ async def _check_single_position(stock_code: str) -> dict | None:
try: try:
current_price = float(candles_5m[-1].close) current_price = float(candles_5m[-1].close)
# highest_price 갱신 — 5분봉 high 값도 반영 (30초 주기 보완) # highest_price 갱신 — 진입 이후에 시작한 1분봉 고가만 반영 (30초 주기 보완).
candle_high = max(c.high for c in candles_5m[-3:]) if len(candles_5m) >= 3 else current_price # [2026-10-01] 예전 `candles_5m[-3:]` 는 진입 전 봉 고가가 섞여 트레일링 고점을 오염시켰다.
new_highest = max(current_price, candle_high, highest_price) # 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: async with day_trading_state.trade_lock:
if stock_code in day_trading_state.positions: if stock_code in day_trading_state.positions:
if new_highest > highest_price: if new_highest > highest_price:

View File

@@ -0,0 +1,184 @@
"""DT 트레일링 고점 오염 회귀 테스트 (2026-10-01).
버그: exit_service._check_single_position 이 `max(c.high for c in candles_5m[-3:])` 로
진입 **전** 5분봉 고가까지 highest_price 에 반영 → 진입 직후 +1% 트레일링 트리거 오판,
`고점 − 1.5×ATR` 손절선 부풀림. 운영 실측: 트레일링 6건 "고점 +1.1~2.2%" 후 손실 종료.
"""
from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
import pytz
from app.services.daytrading import exit_service as es
from app.services.daytrading.position_manager import day_trading_state
TODAY = "20261001"
NOW = datetime(2026, 10, 1, 10, 15, 0) # KST naive (진입 후 10분 — 최근 5분봉 3개에 진입 전 봉 포함)
ENTRY = datetime(2026, 10, 1, 10, 5, 30) # KST naive
ENTRY_PRICE = 10_000.0
class LiveCallBlocked(BaseException):
"""주문 경로 도달 = 목킹 누락 신호 (except Exception 폴백에 삼켜지지 않도록 BaseException)."""
def _c(date, hhmm, high, close=None):
return SimpleNamespace(date=date, time=f"{hhmm}00", high=float(high),
close=float(close if close is not None else high))
def _row(date, hhmm, o, h, l, c, v=1000):
return {
"stck_bsop_date": date, "stck_cntg_hour": f"{hhmm}00",
"stck_oprc": str(o), "stck_hgpr": str(h), "stck_lwpr": str(l),
"stck_prpr": str(c), "cntg_vol": str(v), "acml_vol": "0", "acml_tr_pbmn": "0",
}
def _minute_rows(pre_entry_spike: float, post_entry_high: float, date=TODAY):
"""09:30~10:14 1분봉. 10:02 에 진입 전 스파이크, 10:12 에 진입 후 고가."""
rows = []
t = datetime(2026, 10, 1, 9, 30)
while t < datetime(2026, 10, 1, 10, 15):
hhmm = t.strftime("%H%M")
h = 10_010
if hhmm == "1002":
h = pre_entry_spike
elif hhmm == "1012":
h = post_entry_high
rows.append(_row(date, hhmm, 10_000, h, 9_990, 10_000))
t += timedelta(minutes=1)
return rows
@pytest.fixture(autouse=True)
def _frozen_now_and_blocked_orders():
import app.services.trade_executor as te
def _boom(name):
async def _f(*a, **k):
raise LiveCallBlocked(f"실주문 경로 도달: {name}")
return _f
with patch.object(es, "_kst_now", lambda: NOW), \
patch.object(te, "safe_place_order", _boom("safe_place_order")), \
patch.object(es, "_execute_exit_impl", _boom("_execute_exit_impl")):
saved = dict(day_trading_state.positions)
day_trading_state.positions.clear()
yield
day_trading_state.positions.clear()
day_trading_state.positions.update(saved)
# ── 순수 함수 ─────────────────────────────────────────────────────────────
def test_pre_entry_candle_high_excluded():
candles = [_c(TODAY, "1002", 10_500), _c(TODAY, "1005", 10_400), # 진입 전 / 진입봉
_c(TODAY, "1006", 10_300), # 라벨 < 진입+60s(10:06:30)
_c(TODAY, "1007", 10_050)]
assert es._post_entry_candle_high(candles, ENTRY) == 10_050
def test_post_entry_new_high_reflected():
candles = [_c(TODAY, "1002", 10_500), _c(TODAY, "1010", 10_600), _c(TODAY, "1015", 10_200)]
assert es._post_entry_candle_high(candles, ENTRY) == 10_600
def test_no_post_entry_candle_returns_none():
assert es._post_entry_candle_high([_c(TODAY, "1002", 10_500)], ENTRY) is None
def test_entry_time_missing_returns_none():
assert es._post_entry_candle_high([_c(TODAY, "1010", 10_600)], None) is None
assert es._post_entry_candle_high([_c(TODAY, "1010", 10_600)], "2026-10-01 10:05") is None
def test_utc_stored_entry_time_treated_unreliable():
# KST 10:05 진입이 UTC 01:05 로 저장된 레거시 행 → 정규장 밖 → 불신
utc_like = datetime(2026, 10, 1, 1, 5, 30)
assert es._post_entry_candle_high([_c(TODAY, "1002", 10_500)], utc_like) is None
def test_tz_aware_entry_time_converted_to_kst():
aware = pytz.utc.localize(datetime(2026, 10, 1, 1, 5, 30)) # = KST 10:05:30
candles = [_c(TODAY, "1002", 10_500), _c(TODAY, "1010", 10_100)]
assert es._post_entry_candle_high(candles, aware) == 10_100
def test_date_boundary_previous_day_candles_excluded():
# 전일 14:50 진입 포지션이 아니라 '당일 09:05 진입' — 전일 같은 시각대 이후 봉(라벨상 늦은 시각)이 섞여도 제외
entry = datetime(2026, 10, 1, 9, 5, 0)
candles = [_c("20260930", "1500", 11_000), _c("20260930", "1519", 11_200),
_c(TODAY, "0901", 10_900), _c(TODAY, "0910", 10_100)]
with patch.object(es, "_kst_now", lambda: datetime(2026, 10, 1, 9, 20)):
assert es._post_entry_candle_high(candles, entry) == 10_100
def test_candle_without_date_excluded():
candles = [SimpleNamespace(date="", time="101000", high=12_000.0),
_c(TODAY, "1010", 10_100)]
assert es._post_entry_candle_high(candles, ENTRY) == 10_100
def test_future_entry_time_unreliable():
assert es._post_entry_candle_high([_c(TODAY, "1010", 10_600)], NOW + timedelta(minutes=5)) is None
# ── _check_single_position 통합 (KIS 분봉만 목, 주문 경로는 예외 발생) ──────────────
async def _run_single(pos: dict, rows):
captured = {}
def _capture(**kw):
captured.update(kw)
return None # 청산 신호 없음
day_trading_state.positions["005930"] = pos
with patch.object(es.korea_investment_api, "get_minute_chart_full_day",
AsyncMock(return_value=rows)), \
patch.object(es, "check_day_trade_exit", _capture):
result = await es._check_single_position("005930")
return result, captured
def _pos(entry_time=ENTRY, highest=ENTRY_PRICE):
return {
"stock_name": "삼성전자", "strategy": "day_trade", "entry_price": ENTRY_PRICE,
"stop_loss": 9_700.0, "target_price": 10_450.0, "quantity": 10,
"highest_price": highest, "entry_time": entry_time,
}
@pytest.mark.asyncio
async def test_single_position_high_not_contaminated_by_pre_entry_spike():
# 진입 전 10:02 고가 10,250(+2.5%) — 예전 코드면 트레일링 +1% 트리거가 켜졌다
rows = _minute_rows(pre_entry_spike=10_250, post_entry_high=10_040)
result, cap = await _run_single(_pos(), rows)
assert result is None
assert cap["highest_since_entry"] == 10_040
assert day_trading_state.positions["005930"]["highest_price"] == 10_040
assert (cap["highest_since_entry"] - ENTRY_PRICE) / ENTRY_PRICE < es.settings.DAY_TRADE_TRAILING_TRIGGER
@pytest.mark.asyncio
async def test_single_position_post_entry_new_high_reflected():
rows = _minute_rows(pre_entry_spike=10_050, post_entry_high=10_300)
_, cap = await _run_single(_pos(), rows)
assert cap["highest_since_entry"] == 10_300
@pytest.mark.asyncio
async def test_single_position_highest_monotonic():
rows = _minute_rows(pre_entry_spike=10_050, post_entry_high=10_040)
_, cap = await _run_single(_pos(highest=10_400), rows)
assert cap["highest_since_entry"] == 10_400
@pytest.mark.asyncio
async def test_single_position_missing_entry_time_uses_current_price_only():
rows = _minute_rows(pre_entry_spike=10_250, post_entry_high=10_300)
_, cap = await _run_single(_pos(entry_time=None), rows)
# 마지막 1분봉 종가 10,000 (= 현재가) 와 기존 고점 10,000 만 사용 → 분봉 고가 무시
assert cap["highest_since_entry"] == ENTRY_PRICE