Skip to content

Commit 1c215a1

Browse files
authored
Merge pull request #430 from easygap/feat/cycle-observability
feat: 사이클 관측성 — 결측 원인 추적 + 당일 결측 경보
2 parents 7574198 + 0d483d6 commit 1c215a1

5 files changed

Lines changed: 517 additions & 5 deletions

File tree

‎core/cycle_observability.py‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""일일 사이클 관측성 — 사이클 이벤트 기록 + 스냅샷 결측(gap) 감지.
2+
3+
한 달 운영 리뷰(docs/PAPER_MONTH1_REVIEW_AND_PLAN.md P0-1)의 배경: 2026-06-26
4+
스냅샷 결측은 operation_events에 사이클 이벤트가 전혀 없어 '왜 빠졌는지' 사후
5+
추적조차 불가능했다. 이 모듈은 두 가지를 제공한다.
6+
7+
1) record_cycle_event: 사이클 시작/종료/실패·스냅샷 저장/스킵을 남겨, 결측이
8+
나도 '언제·어디서 멈췄는지' 추적 가능하게 한다(best-effort, 사이클에 영향 없음).
9+
2) find_snapshot_gaps: 최근 영업일 중 스냅샷이 빠진 날을 찾는 순수 함수 —
10+
다음 사이클이 이를 당일 경보로 노출한다(결측 당일/익일 인지).
11+
12+
재시도(11:00·14:00) 자체는 스케줄링 영역이다. 사이클은 멱등((account_key, date)
13+
스냅샷 upsert, 드리프트 재평가)하므로 같은 날 재실행이 안전하다 — 결측일 재실행이
14+
그 날 스냅샷을 채운다. 이 모듈은 재시도의 '관측·경보' 절반을 담당한다.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from datetime import date
20+
from typing import Any, Iterable
21+
22+
from loguru import logger
23+
24+
25+
def _as_date(value: Any) -> Any:
26+
"""datetime/date를 date로 정규화(문자열·None은 그대로)."""
27+
if hasattr(value, "date") and callable(getattr(value, "date")):
28+
return value.date()
29+
return value
30+
31+
32+
def find_snapshot_gaps(
33+
trading_days: Iterable[Any],
34+
snapshot_dates: Iterable[Any],
35+
) -> list[date]:
36+
"""검사 대상 영업일 중 스냅샷이 없는 날을 정렬해 반환한다(순수 함수).
37+
38+
trading_days: 검사할 영업일(date/datetime) 목록.
39+
snapshot_dates: 스냅샷이 존재하는 날(date/datetime) 집합/목록.
40+
반환: 스냅샷이 빠진 영업일(date) 오름차순 목록.
41+
"""
42+
snaps = {_as_date(d) for d in snapshot_dates}
43+
targets = {_as_date(d) for d in trading_days}
44+
return sorted(d for d in targets if d not in snaps)
45+
46+
47+
def format_gap_alert(basket_name: str, gaps: list[Any], *, today: Any = None) -> str:
48+
"""결측 영업일 목록을 운영자 경보 문구로 만든다(순수 함수).
49+
50+
당일(today)이 결측 목록에 있으면 '오늘 포함'을 명시해 즉시 조치를 유도한다.
51+
"""
52+
gap_dates = [_as_date(g) for g in gaps]
53+
today_d = _as_date(today) if today is not None else None
54+
includes_today = today_d is not None and today_d in gap_dates
55+
shown = ", ".join(str(g) for g in gap_dates[-5:]) # 최근 5개만 표기
56+
more = f" 외 {len(gap_dates) - 5}일" if len(gap_dates) > 5 else ""
57+
head = f"⚠️ 바스켓 '{basket_name}' NAV 스냅샷 결측 {len(gap_dates)}일"
58+
tail = (
59+
" — 오늘 포함, 사이클 재실행 필요(커버리지 게이트 위험)"
60+
if includes_today
61+
else " — 일일 사이클 누락 의심, 재실행 권장"
62+
)
63+
return f"{head}: {shown}{more}{tail}"
64+
65+
66+
def detect_snapshot_gaps_for_account(
67+
config: Any,
68+
account_key: str,
69+
today: Any,
70+
*,
71+
lookback_calendar_days: int = 14,
72+
) -> list[date]:
73+
"""최근 구간의 영업일 중 이 계정 스냅샷이 빠진 날을 반환한다(impure 수집).
74+
75+
운영 시작 전(첫 스냅샷 이전) 영업일은 결측이 아니므로 제외한다 — 계정에
76+
스냅샷이 하나도 없으면 아직 운영 전으로 보고 빈 목록을 반환한다.
77+
스냅샷 저장 시도 '이후'에 호출해야 오늘이 정확히 판정된다(저장됨=정상, 스킵=결측).
78+
79+
lookback 기본 14일: 명절(추석·설) 연휴+주말 클러스터(최장 ~9-10일)를 넘겨 재개해도
80+
직전 결측을 놓치지 않게 한다. 그보다 오래된 결측은 이 경보 계층이 아니라 승격
81+
게이트(전체 기간 커버리지)와 헬스 점검(장기 stale)이 담당한다.
82+
"""
83+
from datetime import datetime, timedelta
84+
85+
from core.trading_hours import TradingHours
86+
from database.models import PortfolioSnapshot, get_session
87+
88+
today_d = _as_date(today)
89+
th = TradingHours(config)
90+
91+
session = get_session()
92+
try:
93+
snaps = (
94+
session.query(PortfolioSnapshot)
95+
.filter(PortfolioSnapshot.account_key == account_key)
96+
.all()
97+
)
98+
snap_dates = [_as_date(s.date) for s in snaps]
99+
finally:
100+
session.close()
101+
102+
if not snap_dates:
103+
return [] # 운영 전 — gap 판정 대상 아님
104+
earliest = min(snap_dates)
105+
106+
start = today_d - timedelta(days=lookback_calendar_days)
107+
trading_days: list[date] = []
108+
d = start
109+
while d <= today_d:
110+
if d >= earliest and th.is_trading_day(datetime(d.year, d.month, d.day)):
111+
trading_days.append(d)
112+
d += timedelta(days=1)
113+
114+
return find_snapshot_gaps(trading_days, snap_dates)
115+
116+
117+
def record_cycle_event(
118+
event_type: str,
119+
message: str,
120+
*,
121+
severity: str = "info",
122+
strategy: str | None = None,
123+
mode: str = "paper",
124+
detail: str | None = None,
125+
) -> bool:
126+
"""사이클 이벤트를 operation_events에 남긴다(best-effort).
127+
128+
event_type 규약: CYCLE_START / CYCLE_END / CYCLE_ERROR / SNAPSHOT_SAVED /
129+
SNAPSHOT_SKIPPED / SNAPSHOT_GAP. 기록 실패는 사이클을 막지 않는다(로그만).
130+
"""
131+
try:
132+
from database.models import OperationEvent, get_session
133+
134+
session = get_session()
135+
try:
136+
session.add(OperationEvent(
137+
event_type=event_type,
138+
severity=severity,
139+
strategy=strategy,
140+
message=message,
141+
detail=detail,
142+
mode=mode,
143+
))
144+
session.commit()
145+
finally:
146+
session.close()
147+
return True
148+
except Exception as e: # 관측 실패가 운영을 막으면 안 된다
149+
logger.debug("사이클 이벤트 기록 실패(무시): {} {} — {}", event_type, message, e)
150+
return False

‎docs/PAPER_MONTH1_REVIEW_AND_PLAN.md‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,7 +159,14 @@ P0-1(하트비트·재시도)을 먼저 머지하고 재시작하는 순서를
159159
> 결측예산 게이트 정합, min_days=0 방어, 경계 테스트 보강).
160160
> - 남은 저순위: KS11 벤치마크 조회 캐시 없음 → 바스켓 수(N)만큼 매일 재조회(현재 N=1이라 무해).
161161
> 공유 함수(`fetch_benchmark_return`) 변경이라 별도 처리 — P1에서 검토.
162-
> - ⬜ P0-1 사이클 하트비트/결측경보/재시도 — 재시작 전에 머지 예정
162+
> - ✅ **P0-1 사이클 하트비트/결측경보** — `core/cycle_observability.py` 신설.
163+
> CYCLE_START/END/ERROR·SNAPSHOT_SAVED/SKIPPED/GAP 이벤트를 `operation_events`에 남겨
164+
> 결측 원인 추적을 가능하게 하고(6/26은 이벤트가 없어 추적 불가였음), 최근 영업일 결측을
165+
> 당일 Discord 경보로 노출한다(순수 함수 `find_snapshot_gaps` + 실데이터로 6/26 감지 확인).
166+
> 재시도는 아래 "재시도(스케줄링)" 참고 — 사이클이 멱등이라 같은 날 재실행이 안전하다.
167+
> 적대적 리뷰 반영: 결측 판정 '오늘'을 스냅샷 귀속과 같은 KST로 맞춤(비KST 호스트 오탐 방지),
168+
> 룩백 14일로 확대(명절 연휴 클러스터 커버), live 동기화 실패 시 CYCLE_ERROR 기록,
169+
> run_rebalance 배선 통합 테스트 보강(스킵/critical 분기).
163170
> - ⬜ P0-2 SMTP 재발급 — 오너 액션
164171
> - ⬜ P1~P3 — 대기
165172
@@ -171,6 +178,19 @@ P0-1(하트비트·재시도)을 먼저 머지하고 재시작하는 순서를
171178
| 2 | **SMTP_PASSWORD 재발급** (오너 액션 ~5분) | 안정성 | 이메일 경보가 6/11부터 죽어 있어 Discord가 단일 장애점이다. Gmail 앱 비밀번호 재발급 → `.env` 갱신 → `tools/`의 알림 테스트로 확인. **수용: 경보 채널 2개 활성.** |
172179
| 3 | **일일 리포트 v2** | 편의성 | 현재 리포트(총평가금·현금·일일/누적·MDD·보유·매매)는 "얼마"만 있고 "왜"가 없다. 추가: ① 같은 구간 KS11과 격차 ② 배치율(실제 주식비중 vs 설계 80%) ③ 진행률(n/60, 커버리지 %, 잔여 결측 예산 n일) ④ 미체결 슬롯 경고(예: "하이닉스 0주 — n일째, 자본 결정 대기") ⑤ 누적 비용. 데이터는 전부 evaluator에 이미 있음 — 구현 위치는 #425의 스냅샷 리포트부(core/basket_rebalancer.py). **수용: 리포트만 보고 시장 대비/설계 대비/일정 대비 판단 가능.** |
173180

181+
#### 재시도(스케줄링) — 코드가 아니라 크론 설정
182+
183+
사이클(`--mode rebalance`)은 멱등하다: NAV 스냅샷은 `(account_key, date)` upsert이고,
184+
드리프트는 매번 현재 상태에서 재평가하므로 같은 날 재실행이 안전하다(중복 매매·이중 기록 없음).
185+
따라서 "재시도"는 코드 변경이 아니라 스케줄 작업에 항목을 더하는 일이다:
186+
187+
- 기존: `daily-basket-paper-rebalance` (10:00 KST)
188+
- 추가 권장: 같은 명령을 11:00·14:00 KST에 한 번 더. 10:00 실행이 일시적 사유(가격 조회 실패 등)로
189+
스냅샷을 못 남겼으면 11:00/14:00 재실행이 그 날 스냅샷을 채운다(당일 복구).
190+
- 이미 저장된 날 재실행은 upsert라 무해하고, P0-1의 SNAPSHOT_GAP 경보가 여전히 못 채운 결측을 알린다.
191+
- 한계: PC가 하루 종일 꺼져 있던 경우는 같은 날 복구가 구조적으로 불가능하다 — 이때는 다음 실행의
192+
결측 경보로 인지하고, 지난 거래일 종가 백필은 별도 도구가 필요하다(현재 범위 밖).
193+
174194
### P1 — 2주 내
175195

176196
| # | 항목 | 축 | 내용 · 수용 기준 |

‎main.py‎

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -654,8 +654,15 @@ def run_deploy_check(args) -> int:
654654

655655
def run_rebalance(args):
656656
"""바스켓 포트폴리오 리밸런싱 모드."""
657+
from datetime import datetime
658+
from zoneinfo import ZoneInfo
657659
from core.basket_rebalancer import BasketRebalancer
658660
from core.notifier import Notifier
661+
from core.cycle_observability import (
662+
detect_snapshot_gaps_for_account,
663+
format_gap_alert,
664+
record_cycle_event,
665+
)
659666

660667
config = Config.get()
661668
notifier = Notifier(config)
@@ -713,6 +720,13 @@ def run_rebalance(args):
713720
logger.info("🔄 바스켓 리밸런싱 시작 (바스켓: {}, dry_run: {})", basket_names, dry_run)
714721
logger.info("=" * 50)
715722

723+
# 사이클 하트비트: 결측이 나도 '언제 멈췄는지' 추적 가능하게 시작을 남긴다(6/26 교훈).
724+
if not dry_run:
725+
record_cycle_event(
726+
"CYCLE_START", f"리밸런싱 사이클 시작: {basket_names}", mode=mode,
727+
)
728+
cycle_snapshots_saved = 0
729+
716730
for name in basket_names:
717731
try:
718732
live_strategy_name = _rebalance_live_strategy_id(name)
@@ -728,10 +742,16 @@ def run_rebalance(args):
728742
if mode == "live" and not dry_run:
729743
sync_result = rebalancer.portfolio_mgr.sync_with_broker()
730744
if not sync_result.get("ok"):
745+
msg = sync_result.get("message", "sync failed")
731746
logger.error(
732-
"바스켓 '{}' live 리밸런싱 전 포지션 동기화 실패: {}",
733-
name,
734-
sync_result.get("message", "sync failed"),
747+
"바스켓 '{}' live 리밸런싱 전 포지션 동기화 실패: {}", name, msg,
748+
)
749+
# sys.exit는 SystemExit(BaseException)라 아래 except Exception에 안 걸려
750+
# CYCLE_END가 안 남는다 — 중도 사망 breadcrumb을 여기서 명시로 남긴다.
751+
record_cycle_event(
752+
"CYCLE_ERROR",
753+
f"바스켓 '{name}' live 동기화 실패로 사이클 중단: {msg}",
754+
severity="error", strategy=live_strategy_name, mode=mode,
735755
)
736756
sys.exit(1)
737757

@@ -764,7 +784,42 @@ def run_rebalance(args):
764784
# 트랙레코드: 거래 여부와 무관하게 바스켓 계정의 일일 NAV 스냅샷을 남긴다.
765785
# 보유 종목 가격이 전부 확보된 경우에만 저장(가짜 NAV 방지), 멱등 upsert.
766786
if not dry_run:
767-
rebalancer.save_daily_nav_snapshot()
787+
snapshot_saved = rebalancer.save_daily_nav_snapshot()
788+
# 사이클 관측·경보: 스냅샷 저장/스킵을 기록하고, 최근 영업일 결측을
789+
# 당일 경보로 노출한다(6/26류 조용한 누락 재발 방지). best-effort.
790+
try:
791+
if snapshot_saved:
792+
cycle_snapshots_saved += 1
793+
record_cycle_event(
794+
"SNAPSHOT_SAVED", f"바스켓 '{name}' NAV 스냅샷 저장",
795+
strategy=live_strategy_name, mode=mode,
796+
)
797+
else:
798+
record_cycle_event(
799+
"SNAPSHOT_SKIPPED",
800+
f"바스켓 '{name}' NAV 스냅샷 스킵 — 가격 미확보 등",
801+
severity="warning", strategy=live_strategy_name, mode=mode,
802+
)
803+
# 스냅샷 귀속(_nav_attribution_date)이 KST 기준이므로 결측 판정의
804+
# '오늘'도 KST로 맞춘다 — 호스트 TZ가 KST가 아니면(클라우드/CI) 당일
805+
# 결측 경보가 엉뚱한 날을 보거나 안 울리는 것을 방지(적대적 리뷰 medium).
806+
now = datetime.now(ZoneInfo("Asia/Seoul")).replace(tzinfo=None)
807+
gaps = detect_snapshot_gaps_for_account(
808+
config, live_strategy_name, now,
809+
)
810+
if gaps:
811+
alert = format_gap_alert(name, gaps, today=now)
812+
today_missing = now.date() in gaps
813+
# 오늘 결측은 즉시 조치(critical), 과거 결측 재알림은 경고 수준
814+
# — 복구 불가한 과거 결측을 매일 critical로 울리는 피로 방지.
815+
record_cycle_event(
816+
"SNAPSHOT_GAP", alert,
817+
severity="critical" if today_missing else "warning",
818+
strategy=live_strategy_name, mode=mode,
819+
)
820+
notifier.send_message(alert, critical=today_missing)
821+
except Exception as e:
822+
logger.debug("바스켓 '{}' 스냅샷 관측/경보 생략: {}", name, e)
768823
# 일일 디스코드 리포트: 상시 스케줄러의 장마감 리포트는 일일 CLI 운영
769824
# 에서는 돌지 않아 운영자가 받는 푸시가 0건이었다 — 사이클마다 바스켓
770825
# NAV 요약 카드를 보낸다. 실패해도 사이클에는 영향 없음(채널은 보조).
@@ -826,6 +881,20 @@ def run_rebalance(args):
826881
except Exception as e:
827882
logger.error("바스켓 '{}' 리밸런싱 실패: {}", name, e)
828883
notifier.send_message(f"바스켓 '{name}' 리밸런싱 오류: {e}")
884+
if not dry_run:
885+
# 사이클이 이 바스켓에서 죽었음을 남긴다 — 결측 원인 추적의 핵심.
886+
record_cycle_event(
887+
"CYCLE_ERROR", f"바스켓 '{name}' 리밸런싱 실패: {e}",
888+
severity="error", strategy=_rebalance_live_strategy_id(name), mode=mode,
889+
)
890+
891+
if not dry_run:
892+
# 사이클 정상 종료 기록: START는 있는데 END가 없으면 중도 사망으로 판별된다.
893+
record_cycle_event(
894+
"CYCLE_END",
895+
f"리밸런싱 사이클 종료: 스냅샷 {cycle_snapshots_saved}/{len(basket_names)} 저장",
896+
mode=mode,
897+
)
829898

830899
# DB 일일 백업: 트랙레코드(거래·포지션·NAV 시계열)가 단일 SQLite 파일이라
831900
# 손상 시 60영업일 증거가 통째로 소실된다. 기존엔 상시 스케줄러 장마감에서만

0 commit comments

Comments
 (0)