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:
@@ -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분마다 갱신(수동 설정 목록은 유지)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
184
backend/tests/test_dt_trailing_high_post_entry_20261001.py
Normal file
184
backend/tests/test_dt_trailing_high_post_entry_20261001.py
Normal 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
|
||||||
Reference in New Issue
Block a user