Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions core/cycle_observability.py
Original file line number Diff line number Diff line change
@@ -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
22 changes: 21 additions & 1 deletion docs/PAPER_MONTH1_REVIEW_AND_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 — 대기

Expand All @@ -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주 내

| # | 항목 | 축 | 내용 · 수용 기준 |
Expand Down
77 changes: 73 additions & 4 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)

Expand Down Expand Up @@ -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 요약 카드를 보낸다. 실패해도 사이클에는 영향 없음(채널은 보조).
Expand Down Expand Up @@ -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영업일 증거가 통째로 소실된다. 기존엔 상시 스케줄러 장마감에서만
Expand Down
Loading
Loading