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

@@ -18,7 +18,9 @@ CLAUDE.md 3.1: 실시간 레이어에서 DB I/O 절대 금지.
주의사항:
- [동시성] positions, daily_pnl, closed_today 수정 시 반드시 trade_lock 획득
- [영속성] 앱 재시작 시 인메모리 상태가 유실됨 (DB 복원 메커니즘 없음)
- [영속성] 앱 재시작 시 restore_from_db()/load_orb_cache() 로 복원한다.
복원 직전에 반드시 begin_restore() 를 호출할 것 — 호출하지 않으면
첫 reset_if_new_day() 가 복원분을 전부 지운다(2026-09-28 수정).
- [단일 프로세스] 다중 워커 환경에서는 상태 불일치 발생 가능
"""
import asyncio
@@ -114,6 +116,29 @@ class DayTradingState:
return True
return False
def begin_restore(self) -> bool:
"""기동 시 복원(load_orb_cache/restore_from_db) **직전에 1회** 호출.
[2026-09-28 재기동 내구성 수정]
새 프로세스는 ``last_reset_date is None`` 이고 복원 경로 3개
(load_orb_cache / restore_from_db / close_stale_positions) 중 어느 것도
이 필드를 설정하지 않았다. 그래서 스케줄러 첫 루프의
``reset_if_new_day()`` 가 무조건 참이 되어 **방금 복원한
positions/orb_cache 를 전부 지웠다**(복원 성공 로그는 그대로 출력되므로
조용히 실패). 손절·트레일링·시간손절·15:20 EOD 청산이 인메모리
positions 만 순회하므로 재기동한 당일의 포지션이 고아가 됐다.
복원 **전에** 날짜 경계를 확정하면 두 불변식을 동시에 만족한다:
1) 같은 날 첫 루프의 reset 은 no-op → 복원분 생존
2) 자정을 넘기면 ``last_reset_date != today`` → 정상 초기화되어
전일 closed_today / daily_pnl / loss_streak_today 가 새지 않는다
(§7 일일 -3% 한도·3연패 쿨다운·재진입 차단 보호)
Returns:
bool: 날짜 경계 확정 과정에서 초기화가 수행되었으면 True
"""
return self.reset_if_new_day()
def add_position(self, stock_code: str, data: dict) -> None:
"""포지션 추가.
@@ -282,6 +307,100 @@ class DayTradingState:
logger.warning(f"[DayTrading] ORB 캐시 복원 실패: {e}")
return 0
async def save_daily_state(self) -> bool:
"""[F9 2026-09-28] 일일 안전장치 3종을 Redis 에 저장 (당일 키, 자정까지 TTL).
``daily_pnl`` / ``loss_streak_today`` / ``closed_today`` 는 어디에도
영속화되지 않아 **장중 재기동 시 전부 0/빈 집합으로 돌아갔다.** 이것은 단순
상태 유실이 아니라 **안전장치가 풀리는 방향**이다:
- ``daily_pnl=0`` → §7 일일 -3% 손실한도(DAY_TRADE_DAILY_LOSS_LIMIT)가
리셋되어, 이미 한도를 맞고 중단됐어야 할 날에 매매가 재개된다
- ``loss_streak_today=0`` → 당일 3연패 쿨다운이 풀린다
- ``closed_today=∅`` → 손절로 나온 종목을 같은 날 다시 매수한다
저장 포맷에 ``date`` 를 실어 ``load_daily_state()`` 가 **다른 날짜면 버린다**
(전일 값이 다음 날로 새면 F1 불변식 ②를 정면으로 깨뜨린다).
ORB 캐시(save_orb_cache)와 동일한 Redis 경로를 그대로 확장한 것이다.
"""
try:
from app.core.redis import setex_json
today = _today_kst()
payload = {
"date": today.isoformat(),
"daily_pnl": float(self.daily_pnl),
"loss_streak_today": int(self.loss_streak_today),
"closed_today": sorted(self.closed_today),
# [C2 2026-09-28] 손실률 분모. 이 값이 0 이면
# is_daily_loss_exceeded() 가 total_assets<=0 에서 **무조건 False** 라
# 재기동 직후 첫 scan_and_enter() 가 일일 -3% 한도를 통과해버린다
# (scan_and_enter 는 잔고 조회 전에 이 필드를 읽는다).
"last_total_assets": float(self.last_total_assets),
}
ok = await setex_json(f"daytrading:daily:{today}", 86400, payload)
if not ok:
logger.warning(
"[DayTrading] ⚠️ 일일 안전장치 저장 실패(Redis) — 재기동 시 "
"일일 손실한도·연패 쿨다운·재진입 차단이 초기화된다"
)
return ok
except Exception as e:
logger.warning(f"[DayTrading] ⚠️ 일일 안전장치 저장 실패: {e}")
return False
async def load_daily_state(self) -> bool:
"""[F9] Redis 에서 당일 안전장치 3종을 복원. 복원했으면 True.
**날짜 게이트**: 저장된 ``date`` 가 오늘이 아니면 복원하지 않고 버린다.
키 자체에도 날짜가 들어 있지만(자정 TTL), 페이로드 날짜를 한 번 더 확인해
키 충돌·시계 오차·수동 주입으로 전일 값이 새는 경로를 이중으로 막는다.
복원값은 현재 인메모리 값과 **병합하지 않고 더 보수적인 쪽을 취한다**
(손익은 합산이 아니라 저장값 채택, closed_today 는 합집합, 연패는 최대값)
— 재기동 직후 인메모리는 비어 있는 것이 정상이고, 만약 비어 있지 않다면
안전장치를 약화시키지 않는 방향을 골라야 한다.
"""
try:
from app.core.redis import get_json
today = _today_kst()
data = await get_json(f"daytrading:daily:{today}")
if not data:
return False
saved_date = str(data.get("date") or "")
if saved_date != today.isoformat():
logger.warning(
f"[DayTrading] 일일 안전장치 복원 건너뜀: 저장 날짜 {saved_date!r} "
f"≠ 오늘 {today.isoformat()} — 전일 값 누수 방지(폐기)"
)
return False
_pnl = float(data.get("daily_pnl", 0) or 0)
_streak = int(data.get("loss_streak_today", 0) or 0)
_closed = {str(c) for c in (data.get("closed_today") or []) if c}
_assets = float(data.get("last_total_assets", 0) or 0)
self.daily_pnl = _pnl if not self.daily_pnl else min(self.daily_pnl, _pnl)
self.loss_streak_today = max(self.loss_streak_today, _streak)
self.closed_today |= _closed
# [C2] 분모는 **작은 쪽이 보수적**이다: pnl<0 일 때 (pnl/assets) 가 더 음수가
# 되어 -3% 한도가 더 빨리 걸린다. 단 0/음수는 게이트를 통째로 무력화하므로
# 후보에서 제외하고, 양수가 하나도 없으면 현재값(0.0)을 그대로 둔다.
_cands = [v for v in (self.last_total_assets, _assets) if v > 0]
if _cands:
self.last_total_assets = min(_cands)
logger.info(
f"[DayTrading] 일일 안전장치 복원: 실현손익={self.daily_pnl:+,.0f}원, "
f"연패={self.loss_streak_today}, 당일청산={len(self.closed_today)}종목, "
f"총자산기준={self.last_total_assets:,.0f}원"
)
return True
except Exception as e:
logger.warning(
f"[DayTrading] ⚠️ 일일 안전장치 복원 실패: {e} — 일일 손실한도·연패 "
f"쿨다운·재진입 차단이 초기화된 상태로 시작한다"
)
return False
def set_orb(self, stock_code: str, orb: Any) -> None:
"""ORB(시초가 레인지) 데이터 저장.