From 0d483d62a35583285cbd4c65e66ec1c9d3e6de80 Mon Sep 17 00:00:00 2001 From: Quant Trader Date: Thu, 2 Jul 2026 13:57:19 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=82=AC=EC=9D=B4=ED=81=B4=20=EA=B4=80?= =?UTF-8?q?=EC=B8=A1=EC=84=B1=20=E2=80=94=20=EA=B2=B0=EC=B8=A1=20=EC=9B=90?= =?UTF-8?q?=EC=9D=B8=20=EC=B6=94=EC=A0=81=20+=20=EB=8B=B9=EC=9D=BC=20?= =?UTF-8?q?=EA=B2=B0=EC=B8=A1=20=EA=B2=BD=EB=B3=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 한 달 운영 리뷰(docs/PAPER_MONTH1_REVIEW_AND_PLAN.md P0-1) 처방. 6/26 스냅샷 결측이 operation_events에 사이클 이벤트가 없어 원인 추적조차 불가능했던 문제를 해결한다. 트랙레코드 재시작 전에 머지해야 하는 항목(새 60일은 결측 예산 3일뿐). core/cycle_observability.py 신설: - find_snapshot_gaps / format_gap_alert: 순수 함수(테스트 용이) - record_cycle_event: best-effort 기록(실패해도 사이클 불변) - detect_snapshot_gaps_for_account: DB 스냅샷+영업일로 결측일 산출 (운영 시작 이전 영업일 제외) main.py run_rebalance 배선: - CYCLE_START/END(저장 n/N)/ERROR로 '언제 어디서 멈췄는지' 추적 가능 - 바스켓별 SNAPSHOT_SAVED/SKIPPED - 최근 영업일 결측 → SNAPSHOT_GAP + Discord 경보 (오늘 결측만 critical, 과거 결측은 warning — 알림 피로 방지) 재시도는 스케줄링 영역: 사이클이 멱등이라 11:00·14:00 재실행이 안전하다 (문서에 절차). PC가 하루 종일 꺼진 경우는 당일 복구 구조적 불가 — 다음 실행 결측 경보로 인지. 적대적 리뷰 반영: - 결측 판정 '오늘'을 스냅샷 귀속과 같은 KST로 통일(비KST 호스트 오탐 방지) - 룩백 14일로 확대(명절 연휴 클러스터 커버) - live 동기화 실패 sys.exit 전에 CYCLE_ERROR 기록(중도 사망 breadcrumb) - run_rebalance 배선 통합 테스트 보강(스킵/오늘·과거 결측 criticality) 전체 스위트 1565 통과, 실데이터로 6/26 결측 감지 확인. --- core/cycle_observability.py | 150 +++++++++++++++++++++++ docs/PAPER_MONTH1_REVIEW_AND_PLAN.md | 22 +++- main.py | 77 +++++++++++- tests/test_cycle_observability.py | 177 +++++++++++++++++++++++++++ tests/test_rebalance_snapshot.py | 96 +++++++++++++++ 5 files changed, 517 insertions(+), 5 deletions(-) create mode 100644 core/cycle_observability.py create mode 100644 tests/test_cycle_observability.py diff --git a/core/cycle_observability.py b/core/cycle_observability.py new file mode 100644 index 0000000..c15778e --- /dev/null +++ b/core/cycle_observability.py @@ -0,0 +1,150 @@ +"""일일 사이클 관측성 — 사이클 이벤트 기록 + 스냅샷 결측(gap) 감지. + +한 달 운영 리뷰(docs/PAPER_MONTH1_REVIEW_AND_PLAN.md P0-1)의 배경: 2026-06-26 +스냅샷 결측은 operation_events에 사이클 이벤트가 전혀 없어 '왜 빠졌는지' 사후 +추적조차 불가능했다. 이 모듈은 두 가지를 제공한다. + + 1) record_cycle_event: 사이클 시작/종료/실패·스냅샷 저장/스킵을 남겨, 결측이 + 나도 '언제·어디서 멈췄는지' 추적 가능하게 한다(best-effort, 사이클에 영향 없음). + 2) find_snapshot_gaps: 최근 영업일 중 스냅샷이 빠진 날을 찾는 순수 함수 — + 다음 사이클이 이를 당일 경보로 노출한다(결측 당일/익일 인지). + +재시도(11:00·14:00) 자체는 스케줄링 영역이다. 사이클은 멱등((account_key, date) +스냅샷 upsert, 드리프트 재평가)하므로 같은 날 재실행이 안전하다 — 결측일 재실행이 +그 날 스냅샷을 채운다. 이 모듈은 재시도의 '관측·경보' 절반을 담당한다. +""" + +from __future__ import annotations + +from datetime import date +from typing import Any, Iterable + +from loguru import logger + + +def _as_date(value: Any) -> Any: + """datetime/date를 date로 정규화(문자열·None은 그대로).""" + if hasattr(value, "date") and callable(getattr(value, "date")): + return value.date() + return value + + +def find_snapshot_gaps( + trading_days: Iterable[Any], + snapshot_dates: Iterable[Any], +) -> list[date]: + """검사 대상 영업일 중 스냅샷이 없는 날을 정렬해 반환한다(순수 함수). + + trading_days: 검사할 영업일(date/datetime) 목록. + snapshot_dates: 스냅샷이 존재하는 날(date/datetime) 집합/목록. + 반환: 스냅샷이 빠진 영업일(date) 오름차순 목록. + """ + snaps = {_as_date(d) for d in snapshot_dates} + targets = {_as_date(d) for d in trading_days} + return sorted(d for d in targets if d not in snaps) + + +def format_gap_alert(basket_name: str, gaps: list[Any], *, today: Any = None) -> str: + """결측 영업일 목록을 운영자 경보 문구로 만든다(순수 함수). + + 당일(today)이 결측 목록에 있으면 '오늘 포함'을 명시해 즉시 조치를 유도한다. + """ + gap_dates = [_as_date(g) for g in gaps] + today_d = _as_date(today) if today is not None else None + includes_today = today_d is not None and today_d in gap_dates + shown = ", ".join(str(g) for g in gap_dates[-5:]) # 최근 5개만 표기 + more = f" 외 {len(gap_dates) - 5}일" if len(gap_dates) > 5 else "" + head = f"⚠️ 바스켓 '{basket_name}' NAV 스냅샷 결측 {len(gap_dates)}일" + tail = ( + " — 오늘 포함, 사이클 재실행 필요(커버리지 게이트 위험)" + if includes_today + else " — 일일 사이클 누락 의심, 재실행 권장" + ) + return f"{head}: {shown}{more}{tail}" + + +def detect_snapshot_gaps_for_account( + config: Any, + account_key: str, + today: Any, + *, + lookback_calendar_days: int = 14, +) -> list[date]: + """최근 구간의 영업일 중 이 계정 스냅샷이 빠진 날을 반환한다(impure 수집). + + 운영 시작 전(첫 스냅샷 이전) 영업일은 결측이 아니므로 제외한다 — 계정에 + 스냅샷이 하나도 없으면 아직 운영 전으로 보고 빈 목록을 반환한다. + 스냅샷 저장 시도 '이후'에 호출해야 오늘이 정확히 판정된다(저장됨=정상, 스킵=결측). + + lookback 기본 14일: 명절(추석·설) 연휴+주말 클러스터(최장 ~9-10일)를 넘겨 재개해도 + 직전 결측을 놓치지 않게 한다. 그보다 오래된 결측은 이 경보 계층이 아니라 승격 + 게이트(전체 기간 커버리지)와 헬스 점검(장기 stale)이 담당한다. + """ + from datetime import datetime, timedelta + + from core.trading_hours import TradingHours + from database.models import PortfolioSnapshot, get_session + + today_d = _as_date(today) + th = TradingHours(config) + + session = get_session() + try: + snaps = ( + session.query(PortfolioSnapshot) + .filter(PortfolioSnapshot.account_key == account_key) + .all() + ) + snap_dates = [_as_date(s.date) for s in snaps] + finally: + session.close() + + if not snap_dates: + return [] # 운영 전 — gap 판정 대상 아님 + earliest = min(snap_dates) + + start = today_d - timedelta(days=lookback_calendar_days) + trading_days: list[date] = [] + d = start + while d <= today_d: + if d >= earliest and th.is_trading_day(datetime(d.year, d.month, d.day)): + trading_days.append(d) + d += timedelta(days=1) + + return find_snapshot_gaps(trading_days, snap_dates) + + +def record_cycle_event( + event_type: str, + message: str, + *, + severity: str = "info", + strategy: str | None = None, + mode: str = "paper", + detail: str | None = None, +) -> bool: + """사이클 이벤트를 operation_events에 남긴다(best-effort). + + event_type 규약: CYCLE_START / CYCLE_END / CYCLE_ERROR / SNAPSHOT_SAVED / + SNAPSHOT_SKIPPED / SNAPSHOT_GAP. 기록 실패는 사이클을 막지 않는다(로그만). + """ + try: + from database.models import OperationEvent, get_session + + session = get_session() + try: + session.add(OperationEvent( + event_type=event_type, + severity=severity, + strategy=strategy, + message=message, + detail=detail, + mode=mode, + )) + session.commit() + finally: + session.close() + return True + except Exception as e: # 관측 실패가 운영을 막으면 안 된다 + logger.debug("사이클 이벤트 기록 실패(무시): {} {} — {}", event_type, message, e) + return False diff --git a/docs/PAPER_MONTH1_REVIEW_AND_PLAN.md b/docs/PAPER_MONTH1_REVIEW_AND_PLAN.md index 78b0f5d..0da074a 100644 --- a/docs/PAPER_MONTH1_REVIEW_AND_PLAN.md +++ b/docs/PAPER_MONTH1_REVIEW_AND_PLAN.md @@ -159,7 +159,14 @@ P0-1(하트비트·재시도)을 먼저 머지하고 재시작하는 순서를 > 결측예산 게이트 정합, min_days=0 방어, 경계 테스트 보강). > - 남은 저순위: KS11 벤치마크 조회 캐시 없음 → 바스켓 수(N)만큼 매일 재조회(현재 N=1이라 무해). > 공유 함수(`fetch_benchmark_return`) 변경이라 별도 처리 — P1에서 검토. -> - ⬜ P0-1 사이클 하트비트/결측경보/재시도 — 재시작 전에 머지 예정 +> - ✅ **P0-1 사이클 하트비트/결측경보** — `core/cycle_observability.py` 신설. +> CYCLE_START/END/ERROR·SNAPSHOT_SAVED/SKIPPED/GAP 이벤트를 `operation_events`에 남겨 +> 결측 원인 추적을 가능하게 하고(6/26은 이벤트가 없어 추적 불가였음), 최근 영업일 결측을 +> 당일 Discord 경보로 노출한다(순수 함수 `find_snapshot_gaps` + 실데이터로 6/26 감지 확인). +> 재시도는 아래 "재시도(스케줄링)" 참고 — 사이클이 멱등이라 같은 날 재실행이 안전하다. +> 적대적 리뷰 반영: 결측 판정 '오늘'을 스냅샷 귀속과 같은 KST로 맞춤(비KST 호스트 오탐 방지), +> 룩백 14일로 확대(명절 연휴 클러스터 커버), live 동기화 실패 시 CYCLE_ERROR 기록, +> run_rebalance 배선 통합 테스트 보강(스킵/critical 분기). > - ⬜ P0-2 SMTP 재발급 — 오너 액션 > - ⬜ P1~P3 — 대기 @@ -171,6 +178,19 @@ P0-1(하트비트·재시도)을 먼저 머지하고 재시작하는 순서를 | 2 | **SMTP_PASSWORD 재발급** (오너 액션 ~5분) | 안정성 | 이메일 경보가 6/11부터 죽어 있어 Discord가 단일 장애점이다. Gmail 앱 비밀번호 재발급 → `.env` 갱신 → `tools/`의 알림 테스트로 확인. **수용: 경보 채널 2개 활성.** | | 3 | **일일 리포트 v2** | 편의성 | 현재 리포트(총평가금·현금·일일/누적·MDD·보유·매매)는 "얼마"만 있고 "왜"가 없다. 추가: ① 같은 구간 KS11과 격차 ② 배치율(실제 주식비중 vs 설계 80%) ③ 진행률(n/60, 커버리지 %, 잔여 결측 예산 n일) ④ 미체결 슬롯 경고(예: "하이닉스 0주 — n일째, 자본 결정 대기") ⑤ 누적 비용. 데이터는 전부 evaluator에 이미 있음 — 구현 위치는 #425의 스냅샷 리포트부(core/basket_rebalancer.py). **수용: 리포트만 보고 시장 대비/설계 대비/일정 대비 판단 가능.** | +#### 재시도(스케줄링) — 코드가 아니라 크론 설정 + +사이클(`--mode rebalance`)은 멱등하다: NAV 스냅샷은 `(account_key, date)` upsert이고, +드리프트는 매번 현재 상태에서 재평가하므로 같은 날 재실행이 안전하다(중복 매매·이중 기록 없음). +따라서 "재시도"는 코드 변경이 아니라 스케줄 작업에 항목을 더하는 일이다: + +- 기존: `daily-basket-paper-rebalance` (10:00 KST) +- 추가 권장: 같은 명령을 11:00·14:00 KST에 한 번 더. 10:00 실행이 일시적 사유(가격 조회 실패 등)로 + 스냅샷을 못 남겼으면 11:00/14:00 재실행이 그 날 스냅샷을 채운다(당일 복구). +- 이미 저장된 날 재실행은 upsert라 무해하고, P0-1의 SNAPSHOT_GAP 경보가 여전히 못 채운 결측을 알린다. +- 한계: PC가 하루 종일 꺼져 있던 경우는 같은 날 복구가 구조적으로 불가능하다 — 이때는 다음 실행의 + 결측 경보로 인지하고, 지난 거래일 종가 백필은 별도 도구가 필요하다(현재 범위 밖). + ### P1 — 2주 내 | # | 항목 | 축 | 내용 · 수용 기준 | diff --git a/main.py b/main.py index 01bb118..72637b3 100644 --- a/main.py +++ b/main.py @@ -654,8 +654,15 @@ def run_deploy_check(args) -> int: def run_rebalance(args): """바스켓 포트폴리오 리밸런싱 모드.""" + from datetime import datetime + from zoneinfo import ZoneInfo from core.basket_rebalancer import BasketRebalancer from core.notifier import Notifier + from core.cycle_observability import ( + detect_snapshot_gaps_for_account, + format_gap_alert, + record_cycle_event, + ) config = Config.get() notifier = Notifier(config) @@ -713,6 +720,13 @@ def run_rebalance(args): logger.info("🔄 바스켓 리밸런싱 시작 (바스켓: {}, dry_run: {})", basket_names, dry_run) logger.info("=" * 50) + # 사이클 하트비트: 결측이 나도 '언제 멈췄는지' 추적 가능하게 시작을 남긴다(6/26 교훈). + if not dry_run: + record_cycle_event( + "CYCLE_START", f"리밸런싱 사이클 시작: {basket_names}", mode=mode, + ) + cycle_snapshots_saved = 0 + for name in basket_names: try: live_strategy_name = _rebalance_live_strategy_id(name) @@ -728,10 +742,16 @@ def run_rebalance(args): if mode == "live" and not dry_run: sync_result = rebalancer.portfolio_mgr.sync_with_broker() if not sync_result.get("ok"): + msg = sync_result.get("message", "sync failed") logger.error( - "바스켓 '{}' live 리밸런싱 전 포지션 동기화 실패: {}", - name, - sync_result.get("message", "sync failed"), + "바스켓 '{}' live 리밸런싱 전 포지션 동기화 실패: {}", name, msg, + ) + # sys.exit는 SystemExit(BaseException)라 아래 except Exception에 안 걸려 + # CYCLE_END가 안 남는다 — 중도 사망 breadcrumb을 여기서 명시로 남긴다. + record_cycle_event( + "CYCLE_ERROR", + f"바스켓 '{name}' live 동기화 실패로 사이클 중단: {msg}", + severity="error", strategy=live_strategy_name, mode=mode, ) sys.exit(1) @@ -764,7 +784,42 @@ def run_rebalance(args): # 트랙레코드: 거래 여부와 무관하게 바스켓 계정의 일일 NAV 스냅샷을 남긴다. # 보유 종목 가격이 전부 확보된 경우에만 저장(가짜 NAV 방지), 멱등 upsert. if not dry_run: - rebalancer.save_daily_nav_snapshot() + snapshot_saved = rebalancer.save_daily_nav_snapshot() + # 사이클 관측·경보: 스냅샷 저장/스킵을 기록하고, 최근 영업일 결측을 + # 당일 경보로 노출한다(6/26류 조용한 누락 재발 방지). best-effort. + try: + if snapshot_saved: + cycle_snapshots_saved += 1 + record_cycle_event( + "SNAPSHOT_SAVED", f"바스켓 '{name}' NAV 스냅샷 저장", + strategy=live_strategy_name, mode=mode, + ) + else: + record_cycle_event( + "SNAPSHOT_SKIPPED", + f"바스켓 '{name}' NAV 스냅샷 스킵 — 가격 미확보 등", + severity="warning", strategy=live_strategy_name, mode=mode, + ) + # 스냅샷 귀속(_nav_attribution_date)이 KST 기준이므로 결측 판정의 + # '오늘'도 KST로 맞춘다 — 호스트 TZ가 KST가 아니면(클라우드/CI) 당일 + # 결측 경보가 엉뚱한 날을 보거나 안 울리는 것을 방지(적대적 리뷰 medium). + now = datetime.now(ZoneInfo("Asia/Seoul")).replace(tzinfo=None) + gaps = detect_snapshot_gaps_for_account( + config, live_strategy_name, now, + ) + if gaps: + alert = format_gap_alert(name, gaps, today=now) + today_missing = now.date() in gaps + # 오늘 결측은 즉시 조치(critical), 과거 결측 재알림은 경고 수준 + # — 복구 불가한 과거 결측을 매일 critical로 울리는 피로 방지. + record_cycle_event( + "SNAPSHOT_GAP", alert, + severity="critical" if today_missing else "warning", + strategy=live_strategy_name, mode=mode, + ) + notifier.send_message(alert, critical=today_missing) + except Exception as e: + logger.debug("바스켓 '{}' 스냅샷 관측/경보 생략: {}", name, e) # 일일 디스코드 리포트: 상시 스케줄러의 장마감 리포트는 일일 CLI 운영 # 에서는 돌지 않아 운영자가 받는 푸시가 0건이었다 — 사이클마다 바스켓 # NAV 요약 카드를 보낸다. 실패해도 사이클에는 영향 없음(채널은 보조). @@ -826,6 +881,20 @@ def run_rebalance(args): except Exception as e: logger.error("바스켓 '{}' 리밸런싱 실패: {}", name, e) notifier.send_message(f"바스켓 '{name}' 리밸런싱 오류: {e}") + if not dry_run: + # 사이클이 이 바스켓에서 죽었음을 남긴다 — 결측 원인 추적의 핵심. + record_cycle_event( + "CYCLE_ERROR", f"바스켓 '{name}' 리밸런싱 실패: {e}", + severity="error", strategy=_rebalance_live_strategy_id(name), mode=mode, + ) + + if not dry_run: + # 사이클 정상 종료 기록: START는 있는데 END가 없으면 중도 사망으로 판별된다. + record_cycle_event( + "CYCLE_END", + f"리밸런싱 사이클 종료: 스냅샷 {cycle_snapshots_saved}/{len(basket_names)} 저장", + mode=mode, + ) # DB 일일 백업: 트랙레코드(거래·포지션·NAV 시계열)가 단일 SQLite 파일이라 # 손상 시 60영업일 증거가 통째로 소실된다. 기존엔 상시 스케줄러 장마감에서만 diff --git a/tests/test_cycle_observability.py b/tests/test_cycle_observability.py new file mode 100644 index 0000000..3922029 --- /dev/null +++ b/tests/test_cycle_observability.py @@ -0,0 +1,177 @@ +"""core/cycle_observability.py — 사이클 관측성 단위 테스트. + +find_snapshot_gaps / format_gap_alert 는 순수 함수(외부 상태 없음). +record_cycle_event / detect_snapshot_gaps_for_account 는 DB를 쓰므로 격리 DB에서 검증. +""" +from datetime import date, datetime + +from core.cycle_observability import ( + find_snapshot_gaps, + format_gap_alert, + record_cycle_event, +) + + +class TestFindSnapshotGaps: + def test_no_gaps(self): + days = [date(2026, 6, 10), date(2026, 6, 11), date(2026, 6, 12)] + assert find_snapshot_gaps(days, days) == [] + + def test_finds_missing_day(self): + days = [date(2026, 6, 10), date(2026, 6, 11), date(2026, 6, 12)] + snaps = [date(2026, 6, 10), date(2026, 6, 12)] + assert find_snapshot_gaps(days, snaps) == [date(2026, 6, 11)] + + def test_sorted_output(self): + days = [date(2026, 6, 12), date(2026, 6, 10), date(2026, 6, 11)] + gaps = find_snapshot_gaps(days, []) + assert gaps == [date(2026, 6, 10), date(2026, 6, 11), date(2026, 6, 12)] + + def test_normalizes_datetime(self): + # datetime과 date가 섞여도 날짜로 정규화해 비교 + days = [datetime(2026, 6, 10, 10, 0), date(2026, 6, 11)] + snaps = [date(2026, 6, 10)] + assert find_snapshot_gaps(days, snaps) == [date(2026, 6, 11)] + + def test_extra_snapshots_ignored(self): + # 검사 대상이 아닌 날의 스냅샷은 무시(gap 판정에 영향 없음) + days = [date(2026, 6, 11)] + snaps = [date(2026, 6, 10), date(2026, 6, 11), date(2026, 6, 12)] + assert find_snapshot_gaps(days, snaps) == [] + + +class TestFormatGapAlert: + def test_includes_today_flag(self): + msg = format_gap_alert("kr_x", [date(2026, 6, 26)], today=date(2026, 6, 26)) + assert "오늘 포함" in msg + assert "kr_x" in msg + assert "1일" in msg + + def test_prior_gap_no_today_flag(self): + msg = format_gap_alert("kr_x", [date(2026, 6, 26)], today=date(2026, 6, 29)) + assert "오늘 포함" not in msg + assert "재실행 권장" in msg + + def test_truncates_many_gaps(self): + gaps = [date(2026, 6, d) for d in range(1, 10)] # 9일 + msg = format_gap_alert("kr_x", gaps, today=date(2026, 6, 10)) + assert "9일" in msg # 총 개수 + assert "외 4일" in msg # 최근 5개만 표기, 나머지 4일 요약 + + +class TestRecordCycleEvent: + def test_writes_event_row(self): + from database.models import OperationEvent, get_session, init_database + + init_database() + ok = record_cycle_event( + "CYCLE_START", "테스트 사이클 시작", strategy="basket_rebalance:test_x", mode="paper", + ) + assert ok is True + session = get_session() + try: + row = ( + session.query(OperationEvent) + .filter(OperationEvent.strategy == "basket_rebalance:test_x") + .filter(OperationEvent.event_type == "CYCLE_START") + .order_by(OperationEvent.id.desc()) + .first() + ) + assert row is not None + assert row.message == "테스트 사이클 시작" + finally: + session.close() + + def test_never_raises_on_bad_input(self): + # 관측 실패가 사이클을 막으면 안 된다 — 예외 대신 False 반환 + assert record_cycle_event("CYCLE_START", None) in (True, False) + + +class TestDetectSnapshotGapsForAccount: + """DB 스냅샷 + 영업일 판정을 엮어 결측 영업일을 찾는 수집 함수.""" + + def _add_snap(self, session, acct, d): + from database.models import PortfolioSnapshot + session.add(PortfolioSnapshot( + account_key=acct, date=datetime(d.year, d.month, d.day), + total_value=1_000_000, cash=0, invested=1_000_000, + )) + + def test_detects_missing_trading_day(self, monkeypatch): + import core.cycle_observability as co + from database.models import get_session, init_database + + init_database() + acct = "basket_rebalance:test_gap_x" + session = get_session() + try: + # 6/30(화) 결측, 나머지는 스냅샷 있음 + for d in (date(2026, 6, 29), date(2026, 7, 1), date(2026, 7, 2)): + self._add_snap(session, acct, d) + session.commit() + finally: + session.close() + + class _FakeTH: + def __init__(self, cfg): + pass + + def is_trading_day(self, d): + return d.weekday() < 5 # 월~금만 영업일(휴장일 무시 — 테스트 단순화) + + monkeypatch.setattr("core.trading_hours.TradingHours", _FakeTH) + gaps = co.detect_snapshot_gaps_for_account( + config=None, account_key=acct, today=date(2026, 7, 2), + ) + assert date(2026, 6, 30) in gaps + assert date(2026, 6, 29) not in gaps + assert date(2026, 7, 2) not in gaps + + def test_no_snapshots_returns_empty(self, monkeypatch): + # 운영 전(스냅샷 0건)이면 gap 판정 대상 아님 → 빈 목록 + import core.cycle_observability as co + from database.models import init_database + + init_database() + + class _FakeTH: + def __init__(self, cfg): + pass + + def is_trading_day(self, d): + return d.weekday() < 5 + + monkeypatch.setattr("core.trading_hours.TradingHours", _FakeTH) + gaps = co.detect_snapshot_gaps_for_account( + config=None, account_key="basket_rebalance:never_ran", today=date(2026, 7, 2), + ) + assert gaps == [] + + def test_pre_operation_days_not_flagged(self, monkeypatch): + # 운영 시작(첫 스냅샷) 이전 영업일은 결측이 아니다 + import core.cycle_observability as co + from database.models import get_session, init_database + + init_database() + acct = "basket_rebalance:test_start_clamp" + session = get_session() + try: + # 첫 스냅샷이 7/1 → 6/29·6/30은 운영 전이라 결측 아님 + self._add_snap(session, acct, date(2026, 7, 1)) + self._add_snap(session, acct, date(2026, 7, 2)) + session.commit() + finally: + session.close() + + class _FakeTH: + def __init__(self, cfg): + pass + + def is_trading_day(self, d): + return d.weekday() < 5 + + monkeypatch.setattr("core.trading_hours.TradingHours", _FakeTH) + gaps = co.detect_snapshot_gaps_for_account( + config=None, account_key=acct, today=date(2026, 7, 2), + ) + assert gaps == [] # 6/29, 6/30은 첫 스냅샷(7/1) 이전이라 제외 diff --git a/tests/test_rebalance_snapshot.py b/tests/test_rebalance_snapshot.py index 2e715f7..c1c4b8f 100644 --- a/tests/test_rebalance_snapshot.py +++ b/tests/test_rebalance_snapshot.py @@ -109,3 +109,99 @@ def test_dry_run_rebalance_does_not_send_daily_report(patched_rebalance, monkeyp monkeypatch.setattr("core.notifier.Notifier", MagicMock(return_value=fake_notifier)) main_mod.run_rebalance(_args(dry_run=True)) assert not fake_notifier.send_daily_report.called + + +# --- P0-1 사이클 관측성 배선(run_rebalance 통합) --- + +def _summary(**over): + base = {"total_value": 1_000_000, "cash": 1_000_000, "total_return": 0.0, + "mdd": 0.0, "position_count": 0} + base.update(over) + return base + + +def test_cycle_events_emitted_on_normal_paper_cycle(patched_rebalance, monkeypatch): + """정상 사이클: CYCLE_START → SNAPSHOT_SAVED → CYCLE_END(1/1 저장).""" + import main as main_mod + import core.cycle_observability as co + + events = [] + monkeypatch.setattr(co, "record_cycle_event", + lambda et, msg, **kw: events.append((et, msg, kw.get("severity", "info"))) or True) + monkeypatch.setattr(co, "detect_snapshot_gaps_for_account", lambda *a, **k: []) + patched_rebalance.save_daily_nav_snapshot.return_value = True + patched_rebalance._market_snapshot = {"005930": {"price": 61000.0}} + patched_rebalance.portfolio_mgr.get_portfolio_summary.return_value = _summary() + + main_mod.run_rebalance(_args(dry_run=False)) + + types = [e[0] for e in events] + assert "CYCLE_START" in types + assert "SNAPSHOT_SAVED" in types + assert "SNAPSHOT_SKIPPED" not in types + end = [e for e in events if e[0] == "CYCLE_END"] + assert end and "1/1" in end[0][1] + + +def test_snapshot_skipped_path_records_warning_and_undercounts(patched_rebalance, monkeypatch): + """스냅샷 스킵(가격 미확보 등): SNAPSHOT_SKIPPED(warning) + CYCLE_END 0/1 저장.""" + import main as main_mod + import core.cycle_observability as co + + events = [] + monkeypatch.setattr(co, "record_cycle_event", + lambda et, msg, **kw: events.append((et, msg, kw.get("severity", "info"))) or True) + monkeypatch.setattr(co, "detect_snapshot_gaps_for_account", lambda *a, **k: []) + patched_rebalance.save_daily_nav_snapshot.return_value = False + patched_rebalance._market_snapshot = {"005930": {"price": 61000.0}} + patched_rebalance.portfolio_mgr.get_portfolio_summary.return_value = _summary() + + main_mod.run_rebalance(_args(dry_run=False)) + + skipped = [e for e in events if e[0] == "SNAPSHOT_SKIPPED"] + assert skipped and skipped[0][2] == "warning" + assert "SNAPSHOT_SAVED" not in [e[0] for e in events] + end = [e for e in events if e[0] == "CYCLE_END"] + assert end and "0/1" in end[0][1] + + +def test_gap_today_missing_pages_critical(patched_rebalance, monkeypatch): + """오늘 결측이면 critical 경보(즉시 조치).""" + import main as main_mod + import core.cycle_observability as co + from unittest.mock import MagicMock + + fake_notifier = MagicMock() + monkeypatch.setattr("core.notifier.Notifier", MagicMock(return_value=fake_notifier)) + # detect가 '오늘'을 결측으로 반환 → today_missing True + monkeypatch.setattr(co, "detect_snapshot_gaps_for_account", + lambda cfg, key, today, **k: [today.date()]) + patched_rebalance._market_snapshot = {"005930": {"price": 61000.0}} + patched_rebalance.portfolio_mgr.get_portfolio_summary.return_value = _summary() + + main_mod.run_rebalance(_args(dry_run=False)) + + calls = fake_notifier.send_message.call_args_list + assert any(c.kwargs.get("critical") is True for c in calls) + + +def test_gap_prior_only_not_critical(patched_rebalance, monkeypatch): + """복구 불가한 과거 결측은 매일 critical로 울리지 않는다(피로 방지).""" + import main as main_mod + import core.cycle_observability as co + from datetime import date + from unittest.mock import MagicMock + + fake_notifier = MagicMock() + monkeypatch.setattr("core.notifier.Notifier", MagicMock(return_value=fake_notifier)) + monkeypatch.setattr(co, "detect_snapshot_gaps_for_account", + lambda cfg, key, today, **k: [date(2020, 1, 2)]) # 먼 과거 + patched_rebalance._market_snapshot = {"005930": {"price": 61000.0}} + patched_rebalance.portfolio_mgr.get_portfolio_summary.return_value = _summary() + + main_mod.run_rebalance(_args(dry_run=False)) + + calls = fake_notifier.send_message.call_args_list + # gap 경보는 발송되되 critical은 아니어야 한다 + assert any(c.kwargs.get("critical") is False for c in calls) + assert all(c.kwargs.get("critical") is not True for c in calls)