fix: 재기동 내구성 + DT 진입 복구 + 유입 잔고 격리 + 대시보드 표시 오류

## 재기동 내구성
- scheduler: 복원 직후 reset_if_new_day()가 복원분을 전부 폐기하던 버그
  (begin_restore()로 날짜 경계를 선확정. 복원 성공 로그는 뜨는데 실제로는 지워졌다)
- 일일 안전장치(daily_pnl/loss_streak_today/closed_today) Redis 영속화
  날짜를 키와 페이로드 양쪽에 실어 이중 게이트, 복원은 보수적 병합
  (손익 min/연패 max/청산목록 합집합 — 복원이 안전장치를 약화시킬 수 없다)
- db_worker 종료 drain이 insert_trade_history만 커밋하고 포지션 추적 행은
  DLQ로 버리던 비대칭 제거 + DLQ 재처리 경로 신설(쓰기 전용이었다)
- 장중 기동 시 ORB 소급 수집(backfill), 전일 캔들 혼입 차단
- swing/exit_service "보유 종목 없음" debug→info, 10회 연속 warning

## DT 진입 복구
- 워밍업 회귀 복구: 1b9257c(2026-09-28)가 조회 계층에 당일 필터를 넣어
  5분봉 RSI(14)/BB(20) 워밍업이 죽고 VWAP/RSI_BB가 10:15/10:40 이전
  발화 불가가 됐다. 필터를 삭제하지 않고 용도별로 분리
  (get_minute_chart_full_day_v2 신설, 기존 메서드·3개 호출부 동작 무변경)
- 거래량 게이트 비교 대상 교정: 형성 중 봉 → 완성봉 (5분봉·ORB 1분봉 양쪽)
  임계값(1.3/1.5)은 불변, 고친 것은 비교 대상뿐
- 세션 경계 차단: _last_session_candles()로 거래량 비교가 전일 봉을 잡아
  개장 직후 게이트가 자동 통과하는 것을 방지
- 스캔 선조회(동시성 4)·종목 타임아웃·시간예산·라운드로빈 + 잘린 종목 수 로그
- watchlist 30분 갱신 시 ORB 보유 종목 우선 유지

## 유입 잔고 격리
- sync_portfolio()가 시스템 매수 근거 없는 KIS 잔고를 자기 스윙 포지션으로
  편입해 자동 손절로 처분하던 문제 → strategy_type='external' 격리
  (승격만 허용, 강등 없음. 기존 행은 수동 SQL 필요)
- 격리분 손절선 이탈은 집행 없이 warning + summary 노출
- 수동 청산 경로에 record_trade_result 누락 보강, 전략 간 당일 쿨다운

## 4% 상한
- market_data 표시 경로 클램프 누락 + confidence 배율 후 재클램프
- 회귀 방지 테스트(AST로 가드가 try 밖에 있는지 검증)

## 프론트
- 총자산 보정 가드(3%·종목수·가격결손·국내분 한정), 원인 미규명 명시
- cumulative_realized_pl이 /portfolio/combined에 없어 항상 0이던 문제

테스트 579 → 677 passed / 0 failed (소켓 차단 하 통과, 실호출 없음)
전략 임계값 변경 0건

⚠️ 워밍업 복구의 효과 크기는 미확정 — KIS 분봉 API에 날짜 파라미터가 없어
   미래 ref_time에 전일 데이터를 주는지가 미검증 전제다. 배포 후
   "[분봉] 워밍업 5분봉 N봉 (당일 a + 전일 b)" 로그의 b로 확정할 것.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 15:49:25 +09:00
parent 3fdc19c749
commit 7abbb3411c
27 changed files with 4164 additions and 227 deletions

View File

@@ -223,6 +223,27 @@ def aggregate_to_5min(candles: List[MinuteCandle]) -> List[MinuteCandle]:
return result
def _last_session_candles(candles: List[MinuteCandle]) -> List[MinuteCandle]:
"""마지막 봉과 **같은 영업일자**의 봉만 남긴다 (세션 경계 차단, 순수 함수).
진입 판정 경로의 시계열에는 지표 워밍업용 전일 봉이 섞여 있다
(`korea_investment_api.get_minute_chart_full_day_v2(include_prev_day=True)`).
RSI·BB 처럼 연속 시계열이 필요한 지표는 그대로 써도 되지만, **거래량 비교는
같은 세션 안에서만 의미가 있다.** 예컨대 09:30 에 5분봉 `[-3]` 이 전일 마지막
5분봉(15:15~15:19, 종가 동시호가 물량)이면 당일 개장봉과의 비교가 등가가 아니고,
거래량 게이트가 개장 직후 자동 통과하거나 반대로 영구 탈락한다.
영업일자 필드가 비어 있는 응답(목/레거시)은 기존 동작을 유지하기 위해
그대로 통과시킨다.
"""
if not candles:
return []
last_date = getattr(candles[-1], "date", "") or ""
if not last_date:
return list(candles)
return [c for c in candles if (getattr(c, "date", "") or "") == last_date]
# ── 지표 계산 ─────────────────────────────────────────────────────────────────
def calculate_vwap(candles: List[MinuteCandle]) -> Optional[float]:
@@ -310,13 +331,22 @@ def check_orb_signal(
"""ORB 상향 돌파 매수 신호
조건:
1. 현재가 > ORB 고점 × 1.002 (0.2% 이상 돌파)
2. 최근 1분봉 거래량 ≥ ORB 분당평균 × 1.5 (거래량 폭발)
1. 현재가 > ORB 고점 × (1 + DAY_TRADE_ORB_BREAKOUT_MARGIN)
2. **마지막 완성 1분봉** 거래량 ≥ ORB 분당평균 × DAY_TRADE_ORB_BREAKOUT_VOL_MULT
3. 직전 5분봉이 양봉 (음봉이면 Fake-out 위험)
[2026-09-29] 2번은 `candles_1min[-1]`(형성 중 봉)을 쓰고 있었다. 30초 주기 스캔의
사이클 대부분에서 마지막 1분봉은 절반만 채워져 있어 **실효 요구가 임계값의 약 2배**가
됐다(volume_surge_ratio 의 F7 과 같은 클래스의 버그). 비교 대상만 마지막 **완성봉**
으로 고치고 임계값은 건드리지 않는다. `orb.avg_vol_per_min` 이 당일 기준이므로
분자도 당일이어야 한다 — `_last_session_candles()` 로 세션을 고정한다.
"""
if not orb or current_price <= 0:
return None
# 전일 워밍업 봉이 섞여 들어와도 ORB 판정(갭·거래량·양봉·ATR)은 당일 봉만 쓴다
candles_1min = _last_session_candles(candles_1min)
# M4: Gap filter — 시가가 전일종가 대비 4%+ 갭이면 ORB 돌파 위험 과대, 진입 거부
# prev_close 근사: candles_1min 첫 봉의 open 이전 데이터 없으므로 orb.open_price 사용
# orb.open_price = 09:00 시가. 현재가와 시가의 괴리가 4% 초과면 갭 상승 종목으로 간주
@@ -336,10 +366,13 @@ def check_orb_signal(
if current_price < orb.high * (1 + settings.DAY_TRADE_ORB_BREAKOUT_MARGIN):
return None
# 최근 1분봉 거래량 체크
if not candles_1min:
# 거래량 체크 — 마지막 **완성** 1분봉([-2]) 기준. 형성 중 봉([-1])은 부분 거래량이라
# 임계값을 실질적으로 부풀린다. 완성봉이 없으면(당일 봉 2개 미만) 게이트 미통과.
if len(candles_1min) < 2:
return None
recent_vol = candles_1min[-2].volume
if orb.avg_vol_per_min <= 0:
return None
recent_vol = candles_1min[-1].volume
if recent_vol < orb.avg_vol_per_min * settings.DAY_TRADE_ORB_BREAKOUT_VOL_MULT:
return None
@@ -379,11 +412,44 @@ def check_orb_signal(
reason=(
f"ORB 상향 돌파: ORB고={orb.high:.0f}, "
f"현재={current_price:.0f}, 거래량={vol_ratio:.1f}배"
f"(≥{settings.DAY_TRADE_ORB_BREAKOUT_VOL_MULT}배, 완성봉 기준)"
),
confidence=round(confidence, 2),
)
def volume_surge_ratio(candles_5min: List[MinuteCandle]) -> Optional[float]:
"""직전 거래량 증가 배율 — **완성된 5분봉끼리** 비교한 값.
[F7 2026-09-28] 기존 게이트는 ``candles_5min[-1].volume >= candles_5min[-2].volume
* DAY_TRADE_VOLUME_MULTIPLIER`` 였다. 그런데 ``aggregate_to_5min`` 은 **형성 중인
5분 버킷도 하나의 봉으로 만든다.** 즉 좌변은 "아직 채워지는 중인 봉의 부분
거래량"이고 우변은 "완성된 봉의 전체 거래량"이라, 실제로 거래량이 1.3배로 늘어난
구간에서도 **버킷의 마지막 1분 남짓에만** 조건이 성립했다. 30초 주기 스캔에서
5분 버킷은 약 10사이클에 걸치므로 ~80% 사이클이 구조적으로 전멸했다.
비교 대상만 고친다(임계값 1.3 은 그대로): 마지막 **완성봉**([-2])을 그 직전
완성봉([-3])과 비교한다. 두 항이 모두 전체 5분치라 비교가 등가이고, 벽시계에
의존하지 않아 순수 함수로 남는다.
[2026-09-29] 진입 경로의 시계열에 전일 워밍업 봉이 포함되므로, 비교는
`_last_session_candles()` 로 **마지막 봉과 같은 세션**에 한정한다. 세션을 넘겨
비교하면 전일 종가 동시호가 물량과 당일 개장봉을 견주게 되어 게이트가 무의미해진다.
Returns:
float | None: 배율. **당일** 완성봉이 2개 미만이거나 분모가 0이면 None
(= 게이트 미통과. 데이터 부족은 통과 쪽으로 열지 않는다)
"""
session = _last_session_candles(candles_5min)
if len(session) < 3:
return None
prev_completed = session[-3].volume
last_completed = session[-2].volume
if prev_completed <= 0:
return None
return last_completed / prev_completed
def check_vwap_bounce_signal(
stock_code: str,
stock_name: str,
@@ -396,7 +462,7 @@ def check_vwap_bounce_signal(
조건:
1. 현재가가 VWAP의 -1% ~ 0.5% 구간 (VWAP 근처)
2. 5분봉 RSI(14) ≤ threshold
3. 최근 봉 거래량 ≥ 직전 봉 × vol_mult
3. 마지막 완성 5분봉 거래량 ≥ 그 직전 완성봉 × vol_mult (형성 중 봉 제외)
4. 최근 5분봉 양봉 전환 (close > open)
"""
if not candles_5min or vwap <= 0 or current_price <= 0:
@@ -409,9 +475,9 @@ def check_vwap_bounce_signal(
if rsi is None or rsi > settings.DAY_TRADE_VWAP_RSI_THRESHOLD:
return None
if len(candles_5min) < 2:
return None
if candles_5min[-1].volume < candles_5min[-2].volume * settings.DAY_TRADE_VOLUME_MULTIPLIER:
# [F7] 거래량 게이트 — 형성 중 봉이 아니라 완성봉끼리 비교 (임계값 무변경)
vol_ratio = volume_surge_ratio(candles_5min)
if vol_ratio is None or vol_ratio < settings.DAY_TRADE_VOLUME_MULTIPLIER:
return None
# 최근 봉 양봉 확인
@@ -435,8 +501,9 @@ def check_vwap_bounce_signal(
stop_loss=round(stop_loss, 0),
target_price=round(target, 0),
reason=(
f"VWAP 반등: VWAP={vwap:.0f}, RSI={rsi:.1f}, "
f"현재={current_price:.0f}"
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}배, 완성봉 기준)"
),
confidence=0.65,
)
@@ -453,7 +520,7 @@ def check_rsi_bb_signal(
조건:
1. RSI(14) ≤ threshold (강한 과매도)
2. 종가 ≤ BB 하단 + buffer
3. 직전 봉 대비 거래량 ≥ vol_mult배
3. 마지막 완성 5분봉 거래량 ≥ 그 직전 완성봉 × vol_mult (형성 중 봉 제외)
4. 최근 봉 양봉 (음봉이면 스킵)
"""
if not candles_5min or current_price <= 0:
@@ -471,9 +538,9 @@ def check_rsi_bb_signal(
if current_price > bb_lower * settings.DAY_TRADE_RSI_BB_BB_LOWER_BUFFER:
return None
if len(candles_5min) < 2:
return None
if candles_5min[-1].volume < candles_5min[-2].volume * settings.DAY_TRADE_VOLUME_MULTIPLIER:
# [F7] 거래량 게이트 — 형성 중 봉이 아니라 완성봉끼리 비교 (임계값 무변경)
vol_ratio = volume_surge_ratio(candles_5min)
if vol_ratio is None or vol_ratio < settings.DAY_TRADE_VOLUME_MULTIPLIER:
return None
last = candles_5min[-1]
@@ -500,8 +567,9 @@ def check_rsi_bb_signal(
stop_loss=round(stop_loss, 0),
target_price=round(target, 0),
reason=(
f"5분봉 과매도 반등: RSI={rsi:.1f}, "
f"BB하단={bb_lower:.0f}, 현재={current_price:.0f}"
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}배, 완성봉 기준)"
),
confidence=0.60,
)