fix: 스윙 자동편입 차단 + DT 처리량 복구 + ATR/양봉 세션 경계 + DLQ 안전

사용자 요구 "스윙으로 편입하는 건 없어야 해" — 시스템이 매수하지 않은 KIS
계좌 잔고가 strategy_type='swing' 이 되는 경로를 전부 닫았다. 2026-09-30
09:26 에 유한양행 8주가 -6.49%/-40,000원 손절된 것이 직접 계기다.

## 스윙 자동편입 차단
- 강등 허용(swing→external). 기존엔 승격만 가능해 유입 잔고가 영구히
  자동 손절 대상이었다. 3중 방어로 정상 포지션 강등을 막는다:
  system_buy_registry(주문 즉시 인메모리) / 연속2회+120초 / db_worker 큐 적체 보류
  ※ 레이스는 이론이 아니다 — _verify_and_sync_execution 이 매수 직후
    _delayed_sync_portfolio(0) 을 지연 0초로 부른다
- 수량 인식 분류(_classify_holding_strategy_type_v2):
  _sys<=0<qty → external / 0<_sys<qty AND _sys==_dt_held → external
  == 을 쓴 이유: 과소·과대 어느 쪽으로 틀려도 swing(관리 유지)으로 실패한다
- _system_qty 를 과대 집계 쪽으로 편향 — 오판이 안전한 방향으로 나게
- external 행에 스윙 추가매수 차단(혼재 발생 자체를 봉쇄, 청산은 통과)
- 근거를 두 벌로 분리: 신규태깅·강등방어는 registry 포함("있다고 보는 게 안전"),
  승격·ActiveTrade 교정은 durable("없다고 보는 게 안전"). 안전 방향이 반대다
- 정합 루프가 external/혼재 행을 교정하지 않게(전제 "active 1행 = 전량 대표"를
  직접 검사). 미교정 시 ActiveTrade 가 부풀어 스윙 청산이 유입분까지 판다(§3.5)
- status 허용목록 → 거부목록(FAILED 만 제외). 진짜 구멍은 FILLED 가 아니라
  PENDING 이었다(체결확인 실패 시 영구 PENDING, 주식은 보유 중)
- SELL 중복제거 키에 nullif+trim — order_id 는 NULL 이 아니라 '' 로 저장된다
  (build_kis_order_id 가 미발견 시 "" 반환). coalesce 만으로는 그룹이 붕괴해
  SELL 이 과소 집계되고 유입 행이 영구히 swing 이 된다

## DT 처리량
- 감시종목 20 → 30 (DAY_TRADE_WATCHLIST_SIZE, 예산 상한 32에서 마진)
- 선조회 동시성 4→8, per-stock 45초, 단계 데드라인 120초 신설
  (120 < 150 _SCAN_TIME_BUDGET < 180 _guard). 오후는 종목당 32초가
  타임아웃 20초를 넘겨 4~7종목만 스캔되던 포화 상태였다 — 약 9배 복구
- 리스크 노출 불변: 동시 5종목·종목당 6%·DT 30% 무변경. 기회만 증가

## 세션 경계 (ATR·양봉)
- _atr() 이 내부에서 세션을 끊는다. 워밍업 시계열의 당일 첫 봉 TR =
  오버나이트 갭이라 갭 3%에서 ATR 1.50배, 10%에서 2.75배 부풀었고
  클램프 없는 target=현재가+1.5×ATR 을 직접 왜곡했다
  → 진입 ATR == 청산 트레일링 ATR 보장
  ※ 부작용 승인됨: 09:30~10:10 은 ATR 부재로 폴백 -3.5% 가 실효 손절폭
- 양봉 판정이 형성 중 버킷을 보던 것 → 마지막 완성봉(거래량 게이트와 같은 봉)
- 1.5R 하한 보장(두 분기). risk=max(1.5×ATR,2%) 라 저변동 종목은
  R:R 0.21 까지 떨어졌다 — §7 "1.5R 분할" 규약 복귀, RSI_BB 와 동일

## DLQ 안전
- replay_dlq 나이 제한: 상태 2시간+같은날짜 / 기록 24시간 / close 는
  충돌판정만(7일). "닫아라"는 지시는 시간이 지나도 거짓이 되지 않는다 —
  격리하면 반대로 유령 active 행이 남는다(§8.4.1 129개와 같은 형태)
- 상태 충돌 확인(유령 생성 위험 시 expired/ 격리, 삭제 안 함)
- DB 장애 시 단락 — 없으면 500파일 × 접속타임아웃만큼 기동이 정지한다

## 진단·테스트
- 진입 reason 에 [진단 ATR=…, 갭=…], 탈락 후보 RSI·갭 집계 로그(하루 ~60줄)
- 잔고조회 실패 debug→warning (available_cash=0 → 모든 진입 조용히 차단)
- 벽시계 의존 테스트 재발 방지: autouse 가드(시각 미고정 시 AssertionError)
  + 호출부 전수 테스트
- 소스 문자열 매칭 단언을 동작 검증으로 전환(값·분기 판정 한정)

테스트 848 → 865 passed / 0 failed. 전략 임계값 변경 0건. alembic 불필요.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-30 16:50:54 +09:00
parent b5e0d6a9bb
commit 9de0715165
19 changed files with 4519 additions and 126 deletions

View File

@@ -310,17 +310,91 @@ def _atr(candles: List[MinuteCandle], period: int = 14) -> Optional[float]:
"""Wrapper: MinuteCandle → highs/lows/closes 추출 후 canonical calculate_atr 위임.
알고리즘: TR 의 단순이동평균 (indicators_core.atr_sma) — Wilder ATR 아님.
⚠️ **세션 경계를 먼저 끊는다** (`_last_session_candles`) — 2026-09-30 수정.
진입 판정 경로의 시계열에는 지표 워밍업용 **전일 봉**이 섞여 있다
(`get_minute_chart_full_day_v2(include_prev_day=True)`). TR 은
`max(H-L, |H-C_prev|, |L-C_prev|)` 이라 **당일 첫 봉의 TR = 오버나이트 갭**이고,
`atr_sma` 가 마지막 `period` 개 TR 의 단순평균이므로 당일 5분봉이 14개 쌓이기
전(대략 10:10 이전)에는 갭 TR + 전일 오후 TR 이 윈도에 남는다. 그 구간이 바로
DT 진입이 가장 많이 나는 구간이다.
영향은 임계값이 아니라 **산출값**이었다:
- `check_vwap_bounce_signal` / `check_rsi_bb_signal` 의
`target = 현재가 + DAY_TRADE_TARGET_MULTIPLIER × ATR` 은 **클램프가 없어**
부푼 ATR 이 1.5R 목표를 그대로 멀리 밀었다.
- `raw_stop = 현재가 − ATR_MULT × ATR` 은 보통 최소손절폭(-2%)이 이기지만
고변동 종목에서는 -2% 를 뚫고 손절이 깊어졌다.
- 청산 경로(`daytrading/exit_service`)는 당일 전용 분봉을 쓰므로 트레일링 ATR 은
깨끗했다 → **진입은 갭 ATR, 청산은 당일 ATR** 로 기준이 엇갈렸다.
여기서 한 번 끊으면 네 호출부(ORB 손절·VWAP·RSI_BB·트레일링) 전부가 같은 기준을
쓴다. 당일 봉이 `period+1` 개 미만이면 `None` 이고, 호출부는 기존 고정 폴백
(`DAY_TRADE_REVERSION_STOP_LOSS_PCT` 등)을 쓴다 — 워밍업 도입 이전과 같은 동작이다.
**임계값은 하나도 바뀌지 않는다.** RSI(14)/BB(20) 는 연속 시계열이 필요한 판정
게이트이므로 워밍업을 그대로 받는다(`_rsi`/`_bollinger` 는 손대지 않았다).
"""
if len(candles) < period + 1:
session = _last_session_candles(candles)
if len(session) < period + 1:
return None
highs = [c.high for c in candles]
lows = [c.low for c in candles]
closes = [c.close for c in candles]
highs = [c.high for c in session]
lows = [c.low for c in session]
closes = [c.close for c in session]
return calculate_atr(highs, lows, closes, period)
# ── 진입 신호 ─────────────────────────────────────────────────────────────────
def _overnight_gap_pct(candles: List[MinuteCandle]) -> Optional[float]:
"""오버나이트 갭률(%) = (당일 첫 봉 시가 − 전일 마지막 봉 종가) / 전일 종가 × 100.
**진단용 순수 함수다 — 어떤 게이트도 이 값을 쓰지 않는다.**
진입 판정 경로의 시계열에는 지표 워밍업용 전일 봉이 섞여 있으므로
(`get_minute_chart_full_day_v2(include_prev_day=True)`) 벽시계나 별도 조회 없이
데이터만으로 계산된다. 전일 봉이 없거나 날짜 필드가 비면 `None`.
용도(2026-09-30): 장중 5분봉 RSI 는 워밍업 시계열을 쓰므로 **오버나이트 갭이
종가 차분 한 개로 RSI 에 들어간다**(실측: 3% 갭하락 → 워밍업 RSI 52.7 → 30.3).
이는 차트 플랫폼 표준 동작이고 평균회귀가 과매도를 사는 의도와 방향이 맞아
**그대로 유지하기로 결정**됐으나, 의도한 설계가 아니었으므로 관찰 대상이다.
진입 `reason` 에 이 값을 남겨 두면 나중에 원장(`trade_history.entry_reason`)만으로
"갭 하락 종목에 쏠렸는가"를 집계할 수 있다.
"""
if not candles:
return None
today = _last_session_candles(candles)
if not today:
return None
today_date = getattr(today[0], "date", "") or ""
if not today_date or len(today) == len(candles):
return None # 전일 봉이 없다 / 날짜 필드 없음
prev = [c for c in candles if (getattr(c, "date", "") or "") != today_date]
if not prev:
return None
prev_close = prev[-1].close
first_open = today[0].open
if prev_close <= 0 or first_open <= 0:
return None
return (first_open - prev_close) / prev_close * 100.0
def _diag_suffix(candles: List[MinuteCandle], atr: Optional[float]) -> str:
"""진입 `reason` 뒤에 붙는 **진단 정보** 문자열 (게이트 아님).
- `갭=` : `_overnight_gap_pct` (RSI 갭 섭취 관찰용, 없으면 생략)
- `ATR=` : 손절·목표 산출에 실제로 쓰인 ATR. `None` 이면 폴백 손절이 쓰였음을
명시한다 — 09:30~10:10 구간은 당일 5분봉 15개 미만이라 ATR 이 없고
`DAY_TRADE_REVERSION_STOP_LOSS_PCT` 폴백이 적용된다(CLAUDE.md §7 예외).
값만 넣는다. 임계값을 문자열에 하드코딩하지 않는다(§4).
"""
parts = ["ATR=없음→폴백손절" if atr is None else f"ATR={atr:.0f}"]
gap = _overnight_gap_pct(candles)
if gap is not None:
parts.append(f"갭={gap:+.2f}%")
return " [진단 " + ", ".join(parts) + "]"
def check_orb_signal(
stock_code: str,
stock_name: str,
@@ -413,11 +487,41 @@ def check_orb_signal(
f"ORB 상향 돌파: ORB고={orb.high:.0f}, "
f"현재={current_price:.0f}, 거래량={vol_ratio:.1f}배"
f"(≥{settings.DAY_TRADE_ORB_BREAKOUT_VOL_MULT}배, 완성봉 기준)"
# ORB 경로의 시계열은 당일 전용이라 오버나이트 갭을 계산할 수 없다
# (_diag_suffix 가 갭을 생략한다). ATR 만 남는다.
+ _diag_suffix(candles_1min, atr_orb)
),
confidence=round(confidence, 2),
)
def _last_completed_candle(candles_5min: List[MinuteCandle]) -> Optional[MinuteCandle]:
"""**당일** 마지막 완성 5분봉. 없으면 None (순수 함수).
[2026-09-30] `aggregate_to_5min` 은 **형성 중인 5분 버킷도 하나의 봉으로 만든다.**
따라서 `candles_5min[-1]` 로 양봉/음봉을 판정하면 30초 주기 스캔에서 같은 버킷을
약 10회 보며 **같은 시장 상태에 사이클마다 다른 답**이 나온다(버킷 초반에 음봉이면
차단, 후반에 양봉이 되면 통과). 재현성이 없는 게이트다.
F7(2026-09-29)이 거래량 게이트를 완성봉으로 고칠 때 **양봉 판정은 빠졌다.**
`volume_surge_ratio()` 가 비교하는 봉과 **같은 봉**(`session[-2]`)을 돌려주므로
"거래량이 튄 그 봉이 양봉인가"라는 원래 의도가 성립한다 —
`check_orb_signal` 은 이미 `candles_5m[-2]` 로 그렇게 하고 있었다(`:407`).
세션 경계도 함께 끊는다(`_last_session_candles`): 진입 경로의 시계열에는 지표
워밍업용 전일 봉이 섞여 있어(`include_prev_day=True`) 개장 직후에는 마지막 완성봉이
**전일 종가 동시호가 봉**이 된다. 그걸로 양봉 판정을 하면 게이트가 전일 결과로
열린다(C1 과 같은 함정).
완성봉이 없으면(당일 봉 2개 미만) `None` — 호출부는 **미통과**로 닫는다.
데이터 부족을 통과 쪽으로 열지 않는다.
"""
session = _last_session_candles(candles_5min)
if len(session) < 2:
return None
return session[-2]
def volume_surge_ratio(candles_5min: List[MinuteCandle]) -> Optional[float]:
"""직전 거래량 증가 배율 — **완성된 5분봉끼리** 비교한 값.
@@ -463,7 +567,7 @@ def check_vwap_bounce_signal(
1. 현재가가 VWAP의 -1% ~ 0.5% 구간 (VWAP 근처)
2. 5분봉 RSI(14) ≤ threshold
3. 마지막 완성 5분봉 거래량 ≥ 그 직전 완성봉 × vol_mult (형성 중 봉 제외)
4. 최근 5분봉 양봉 전환 (close > open)
4. **그 완성봉이 양봉** (close > open) — 형성 중 버킷 제외, 당일 봉만 (2026-09-30)
"""
if not candles_5min or vwap <= 0 or current_price <= 0:
return None
@@ -480,9 +584,11 @@ def check_vwap_bounce_signal(
if vol_ratio is None or vol_ratio < settings.DAY_TRADE_VOLUME_MULTIPLIER:
return None
# 최근 봉 양봉 확인
last = candles_5min[-1]
if last.close <= last.open:
# 양봉 확인 — **당일 마지막 완성봉**(거래량 게이트가 본 그 봉). 형성 중 버킷을
# 보면 사이클마다 답이 바뀐다(_last_completed_candle docstring). 임계값·조건 정의는
# 그대로이고 '어느 봉을 보는가'만 고친다.
last = _last_completed_candle(candles_5min)
if last is None or last.close <= last.open:
return None
atr = _atr(candles_5min, 14)
@@ -491,7 +597,21 @@ def check_vwap_bounce_signal(
min_stop = current_price * (1 - settings.DAY_TRADE_MIN_STOP_LOSS_PCT)
stop_loss = min(raw_stop, min_stop)
stop_loss = max(stop_loss, 1.0) # 최소 1원 보장
target = (current_price + settings.DAY_TRADE_TARGET_MULTIPLIER * atr) if atr else vwap * 1.03
# [M-1 2026-09-30] **1.5R 하한 보장** — RSI_BB 경로와 같은 방식(`max(기존목표, 진입가
# + risk × TARGET_MULTIPLIER)`)으로 맞춘다. 새 임계값은 없다: 두 값 중 큰 쪽을 고를 뿐이다.
#
# 고치지 않으면 `target_price` 가 위험보다 작아진다 — 이 값은
# `daytrading/exit_service` 를 통해 **1.5R 분할매도 트리거로 실제 쓰인다**(§7).
# · ATR 폴백 구간(09:30~약 10:10, 당일 5분봉 15개 미만): 손절 -3.5%(폴백) vs
# 목표 `vwap×1.03`(≈+3%) → **위험 > 보상**
# · ATR 이 있는 구간도 마찬가지다: `risk = max(1.5×ATR, 2%)` 인데(최소 손절폭 보장의
# `min()` 때문에 손절이 -2% 쪽으로 느슨해진다) 목표는 `1.5×ATR` 이라
# **1.5×ATR < 2% 인 저변동 종목에서는 R:R 이 0.2~0.5** 로 떨어진다.
# 실측: 진입 10,120 / ATR 29 → 손절 9,918(-2.0%) / 목표 10,164(+0.43%) = **0.21R**.
# §7 은 "1.5R 도달 시 50% 분할"을 규약으로 못박고 있으므로 이는 규약 복귀다.
_target_base = (current_price + settings.DAY_TRADE_TARGET_MULTIPLIER * atr) if atr else vwap * 1.03
risk = current_price - stop_loss
target = max(_target_base, current_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER)
return DayTradeSignal(
stock_code=stock_code,
@@ -504,6 +624,7 @@ def check_vwap_bounce_signal(
f"VWAP 반등: VWAP={vwap:.0f}, RSI={rsi:.1f}(≤{settings.DAY_TRADE_VWAP_RSI_THRESHOLD:.0f}), "
f"현재={current_price:.0f}, 거래량={vol_ratio:.2f}배"
f"(≥{settings.DAY_TRADE_VOLUME_MULTIPLIER}배, 완성봉 기준)"
+ _diag_suffix(candles_5min, atr)
),
confidence=0.65,
)
@@ -521,7 +642,7 @@ def check_rsi_bb_signal(
1. RSI(14) ≤ threshold (강한 과매도)
2. 종가 ≤ BB 하단 + buffer
3. 마지막 완성 5분봉 거래량 ≥ 그 직전 완성봉 × vol_mult (형성 중 봉 제외)
4. 최근 봉 양봉 (음봉이면 스킵)
4. **그 완성봉이 양봉** (음봉이면 스킵) — 형성 중 버킷 제외, 당일 봉만 (2026-09-30)
"""
if not candles_5min or current_price <= 0:
return None
@@ -543,8 +664,9 @@ def check_rsi_bb_signal(
if vol_ratio is None or vol_ratio < settings.DAY_TRADE_VOLUME_MULTIPLIER:
return None
last = candles_5min[-1]
if last.close <= last.open:
# 양봉 확인 — VWAP 경로와 동일하게 당일 마지막 완성봉 기준
last = _last_completed_candle(candles_5min)
if last is None or last.close <= last.open:
return None
atr = _atr(candles_5min, 14)
@@ -570,6 +692,7 @@ def check_rsi_bb_signal(
f"5분봉 과매도 반등: RSI={rsi:.1f}(≤{settings.DAY_TRADE_RSI_BB_RSI_THRESHOLD:.0f}), "
f"BB하단={bb_lower:.0f}, 현재={current_price:.0f}, "
f"거래량={vol_ratio:.2f}배(≥{settings.DAY_TRADE_VOLUME_MULTIPLIER}배, 완성봉 기준)"
+ _diag_suffix(candles_5min, atr)
),
confidence=0.60,
)